@marimo-team/islands 0.24.3-dev64 → 0.24.3-dev69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ErrorBoundary-A-mTqnGP.js → ErrorBoundary-BwREXvnV.js} +1 -1
- package/dist/{cells-BSgNCBZ8.js → cells-BLjghRgH.js} +4 -7
- package/dist/{chat-ui-CygzVJ9s.js → chat-ui-ZVLuVQRR.js} +4 -4
- package/dist/{code-visibility-BKSYKzYN.js → code-visibility-kB7JdfSm.js} +2 -2
- package/dist/{common-Dyo2DjUU.js → common-aCry8UNj.js} +3 -3
- package/dist/{data-grid-overlay-editor-WZUSaLCR.js → data-grid-overlay-editor-x8iQJOw5.js} +1 -1
- package/dist/{error-entries-iJn-Nryx.js → error-entries-CJR74llu.js} +1 -1
- package/dist/{feedback-button-BHfX6PJR.js → feedback-button-DvHr2Ndh.js} +4 -4
- package/dist/{glide-data-editor-yX5MjvNo.js → glide-data-editor-yboPkXCU.js} +3 -3
- package/dist/{globals-CjGj4P9q.js → globals-D6yacf4y.js} +1 -1
- package/dist/{html-to-image-kpuRfdfZ.js → html-to-image-DYuNuZZd.js} +1 -1
- package/dist/main.js +10 -10
- package/dist/{number-overlay-editor-YhB8oBoI.js → number-overlay-editor-Dh9Q0MlF.js} +1 -1
- package/dist/{process-output-DFe2tVIY.js → process-output-BurYfV57.js} +2 -2
- package/dist/{reveal-component-DAp51sRP.js → reveal-component-BVyB4-pF.js} +3 -3
- package/package.json +1 -1
- package/src/components/chat/acp/__tests__/agent-panel-initialization.test.tsx +383 -0
- package/src/components/chat/acp/__tests__/agent-panel.test.tsx +10 -8
- package/src/components/chat/acp/__tests__/state.test.ts +2 -2
- package/src/components/chat/acp/agent-panel.tsx +70 -30
- package/src/components/chat/acp/state.ts +1 -1
- package/src/core/cells/__tests__/cells.test.ts +144 -0
- package/src/core/cells/cells.ts +7 -17
- package/src/core/wasm/__tests__/bridge.test.ts +5 -4
- package/src/core/wasm/__tests__/runtime-config.test.ts +8 -1
- package/src/core/wasm/bridge.ts +7 -8
- package/src/core/wasm/runtime-config.ts +4 -0
- package/src/core/wasm/worker/constants.ts +1 -0
- package/src/core/wasm/worker/getController.ts +3 -1
- package/src/core/wasm/worker/save-worker.ts +4 -10
- package/src/core/wasm/worker/types.ts +0 -1
- package/src/core/wasm/worker/worker.ts +4 -10
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { Provider } from "jotai";
|
|
5
|
+
import { JsonRpcError, type useAcpClient } from "use-acp";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { MockRequestClient } from "@/__mocks__/requests";
|
|
8
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
9
|
+
import { pendingAiPromptAtom } from "@/core/ai/state";
|
|
10
|
+
import { requestClientAtom } from "@/core/network/requests";
|
|
11
|
+
import { cwdAtom, filenameAtom } from "@/core/saving/file-state";
|
|
12
|
+
import { store } from "@/core/state/jotai";
|
|
13
|
+
import AgentPanel from "../agent-panel";
|
|
14
|
+
import {
|
|
15
|
+
addSession,
|
|
16
|
+
agentSessionStateAtom,
|
|
17
|
+
updateSessionExternalAgentSessionId,
|
|
18
|
+
} from "../state";
|
|
19
|
+
import type { AgentConnectionState, ExternalAgentSessionId } from "../types";
|
|
20
|
+
|
|
21
|
+
type Agent = NonNullable<ReturnType<typeof useAcpClient>["agent"]>;
|
|
22
|
+
type InitializeResponse = Awaited<ReturnType<Agent["initialize"]>>;
|
|
23
|
+
|
|
24
|
+
const { client, connect, disconnect, setActiveSessionId } = vi.hoisted(() => {
|
|
25
|
+
const client: {
|
|
26
|
+
agent: ReturnType<typeof createAgent>["agent"] | null;
|
|
27
|
+
connectionState: AgentConnectionState;
|
|
28
|
+
activeSessionId: ExternalAgentSessionId | null;
|
|
29
|
+
} = {
|
|
30
|
+
agent: null,
|
|
31
|
+
connectionState: { status: "connected" },
|
|
32
|
+
activeSessionId: null,
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
client,
|
|
36
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
37
|
+
disconnect: vi.fn(),
|
|
38
|
+
setActiveSessionId: (sessionId: ExternalAgentSessionId | null) => {
|
|
39
|
+
client.activeSessionId = sessionId;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
vi.mock("use-acp", async (importOriginal) => ({
|
|
45
|
+
...(await importOriginal<typeof import("use-acp")>()),
|
|
46
|
+
useAcpClient: () => ({
|
|
47
|
+
...client,
|
|
48
|
+
connect,
|
|
49
|
+
disconnect,
|
|
50
|
+
notifications: [],
|
|
51
|
+
setActiveSessionId,
|
|
52
|
+
clearNotifications: vi.fn(),
|
|
53
|
+
}),
|
|
54
|
+
}));
|
|
55
|
+
vi.mock("@/components/editor/ai/add-cell-with-ai", () => ({
|
|
56
|
+
PromptInput: () => null,
|
|
57
|
+
}));
|
|
58
|
+
vi.mock("../session-tabs", () => ({ SessionTabs: () => null }));
|
|
59
|
+
vi.mock("../blocks", () => ({ ReadyToChatBlock: () => null }));
|
|
60
|
+
|
|
61
|
+
const initialized: InitializeResponse = {
|
|
62
|
+
protocolVersion: 1,
|
|
63
|
+
agentCapabilities: {},
|
|
64
|
+
};
|
|
65
|
+
const advertisedAuth: InitializeResponse = {
|
|
66
|
+
...initialized,
|
|
67
|
+
authMethods: [{ id: "login", name: "Log in" }],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function createAgent() {
|
|
71
|
+
const initialization = Promise.withResolvers<InitializeResponse>();
|
|
72
|
+
const agent = {
|
|
73
|
+
initialize: vi
|
|
74
|
+
.fn<Agent["initialize"]>()
|
|
75
|
+
.mockReturnValue(initialization.promise),
|
|
76
|
+
authenticate: vi.fn<Agent["authenticate"]>().mockResolvedValue(undefined),
|
|
77
|
+
newSession: vi.fn<Agent["newSession"]>().mockImplementation(async () => {
|
|
78
|
+
client.activeSessionId = "new-session" as ExternalAgentSessionId;
|
|
79
|
+
return { sessionId: "new-session" };
|
|
80
|
+
}),
|
|
81
|
+
loadSession: vi
|
|
82
|
+
.fn<NonNullable<Agent["loadSession"]>>()
|
|
83
|
+
.mockImplementation(async ({ sessionId }) => {
|
|
84
|
+
client.activeSessionId = sessionId as ExternalAgentSessionId;
|
|
85
|
+
return {};
|
|
86
|
+
}),
|
|
87
|
+
cancel: vi.fn<Agent["cancel"]>().mockResolvedValue(undefined),
|
|
88
|
+
prompt: vi
|
|
89
|
+
.fn<Agent["prompt"]>()
|
|
90
|
+
.mockResolvedValue({ stopReason: "end_turn" }),
|
|
91
|
+
};
|
|
92
|
+
return { agent, initialization };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderPanel() {
|
|
96
|
+
const panel = () => (
|
|
97
|
+
<Provider store={store}>
|
|
98
|
+
<TooltipProvider>
|
|
99
|
+
<AgentPanel />
|
|
100
|
+
</TooltipProvider>
|
|
101
|
+
</Provider>
|
|
102
|
+
);
|
|
103
|
+
const view = render(panel());
|
|
104
|
+
return { ...view, refresh: () => view.rerender(panel()) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function expectNoSessionRequests(
|
|
108
|
+
agent: ReturnType<typeof createAgent>["agent"],
|
|
109
|
+
) {
|
|
110
|
+
expect(agent.newSession).not.toHaveBeenCalled();
|
|
111
|
+
expect(agent.loadSession).not.toHaveBeenCalled();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
describe("AgentPanel initialization", () => {
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
client.agent = null;
|
|
117
|
+
client.activeSessionId = null;
|
|
118
|
+
client.connectionState = { status: "connected" };
|
|
119
|
+
store.set(requestClientAtom, MockRequestClient.create());
|
|
120
|
+
store.set(filenameAtom, "notebook.py");
|
|
121
|
+
store.set(cwdAtom, "/notebooks");
|
|
122
|
+
store.set(pendingAiPromptAtom, null);
|
|
123
|
+
store.set(
|
|
124
|
+
agentSessionStateAtom,
|
|
125
|
+
addSession({ sessions: [], activeTabId: null }, { agentId: "claude" }),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
store.set(pendingAiPromptAtom, null);
|
|
131
|
+
store.set(agentSessionStateAtom, { sessions: [], activeTabId: null });
|
|
132
|
+
store.set(filenameAtom, null);
|
|
133
|
+
store.set(cwdAtom, null);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it.each([null, "saved-session"])(
|
|
137
|
+
"waits for initialization before creating/loading %s",
|
|
138
|
+
async (sessionId) => {
|
|
139
|
+
const { agent, initialization } = createAgent();
|
|
140
|
+
client.agent = agent;
|
|
141
|
+
if (sessionId) {
|
|
142
|
+
store.set(agentSessionStateAtom, (state) =>
|
|
143
|
+
updateSessionExternalAgentSessionId(
|
|
144
|
+
state,
|
|
145
|
+
sessionId as ExternalAgentSessionId,
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const { refresh } = renderPanel();
|
|
150
|
+
expect(agent.initialize).toHaveBeenCalledOnce();
|
|
151
|
+
expectNoSessionRequests(agent);
|
|
152
|
+
expect(
|
|
153
|
+
screen.queryByRole("button", { name: "Restart" }),
|
|
154
|
+
).not.toBeInTheDocument();
|
|
155
|
+
|
|
156
|
+
await act(async () => initialization.resolve(initialized));
|
|
157
|
+
expect(agent.authenticate).not.toHaveBeenCalled();
|
|
158
|
+
if (sessionId) {
|
|
159
|
+
expect(agent.loadSession).toHaveBeenCalledExactlyOnceWith({
|
|
160
|
+
sessionId,
|
|
161
|
+
cwd: "/notebooks",
|
|
162
|
+
mcpServers: [],
|
|
163
|
+
});
|
|
164
|
+
expect(agent.newSession).not.toHaveBeenCalled();
|
|
165
|
+
} else {
|
|
166
|
+
expect(agent.newSession).toHaveBeenCalledExactlyOnceWith({
|
|
167
|
+
cwd: "/notebooks",
|
|
168
|
+
mcpServers: [],
|
|
169
|
+
_meta: undefined,
|
|
170
|
+
});
|
|
171
|
+
expect(agent.loadSession).not.toHaveBeenCalled();
|
|
172
|
+
}
|
|
173
|
+
expect(
|
|
174
|
+
screen.getByRole("button", { name: "Restart" }),
|
|
175
|
+
).toBeInTheDocument();
|
|
176
|
+
refresh();
|
|
177
|
+
expect(
|
|
178
|
+
agent.newSession.mock.calls.length +
|
|
179
|
+
agent.loadSession.mock.calls.length,
|
|
180
|
+
).toBe(1);
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
it("uses existing CLI credentials even when the agent advertises login methods", async () => {
|
|
185
|
+
const { agent, initialization } = createAgent();
|
|
186
|
+
agent.authenticate.mockRejectedValue(new Error("Method not implemented."));
|
|
187
|
+
client.agent = agent;
|
|
188
|
+
renderPanel();
|
|
189
|
+
|
|
190
|
+
await act(async () => initialization.resolve(advertisedAuth));
|
|
191
|
+
expect(agent.authenticate).not.toHaveBeenCalled();
|
|
192
|
+
expect(agent.newSession).toHaveBeenCalledOnce();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("surfaces initialization failures and offers a connection retry", async () => {
|
|
196
|
+
const { agent, initialization } = createAgent();
|
|
197
|
+
client.agent = agent;
|
|
198
|
+
const { refresh } = renderPanel();
|
|
199
|
+
|
|
200
|
+
await act(async () => initialization.reject(new Error("Handshake failed")));
|
|
201
|
+
expect(await screen.findByText("Handshake failed")).toBeInTheDocument();
|
|
202
|
+
expectNoSessionRequests(agent);
|
|
203
|
+
fireEvent.click(screen.getByRole("button", { name: "Retry connection" }));
|
|
204
|
+
expect(disconnect).toHaveBeenCalledOnce();
|
|
205
|
+
expect(connect).toHaveBeenCalledTimes(2);
|
|
206
|
+
|
|
207
|
+
const retried = createAgent();
|
|
208
|
+
client.agent = retried.agent;
|
|
209
|
+
refresh();
|
|
210
|
+
expect(screen.queryByText("Handshake failed")).not.toBeInTheDocument();
|
|
211
|
+
expectNoSessionRequests(retried.agent);
|
|
212
|
+
await act(async () => retried.initialization.resolve(initialized));
|
|
213
|
+
expect(retried.agent.newSession).toHaveBeenCalledOnce();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("surfaces missing CLI credentials and lets the user retry after login", async () => {
|
|
217
|
+
const { agent, initialization } = createAgent();
|
|
218
|
+
const authError = new JsonRpcError({
|
|
219
|
+
code: -32_000,
|
|
220
|
+
message: "Authentication required",
|
|
221
|
+
data: { message: "Run claude /login in the terminal" },
|
|
222
|
+
});
|
|
223
|
+
agent.newSession
|
|
224
|
+
.mockRejectedValueOnce(authError)
|
|
225
|
+
.mockRejectedValueOnce(authError);
|
|
226
|
+
client.agent = agent;
|
|
227
|
+
renderPanel();
|
|
228
|
+
|
|
229
|
+
await act(async () => initialization.resolve(advertisedAuth));
|
|
230
|
+
expect(
|
|
231
|
+
await screen.findByText("Run claude /login in the terminal"),
|
|
232
|
+
).toBeInTheDocument();
|
|
233
|
+
expect(agent.authenticate).not.toHaveBeenCalled();
|
|
234
|
+
await act(async () => {
|
|
235
|
+
fireEvent.click(screen.getByRole("button", { name: "Restart session" }));
|
|
236
|
+
});
|
|
237
|
+
expect(agent.newSession).toHaveBeenCalledTimes(2);
|
|
238
|
+
expect(
|
|
239
|
+
screen.getByText("Run claude /login in the terminal"),
|
|
240
|
+
).toBeInTheDocument();
|
|
241
|
+
await act(async () => {
|
|
242
|
+
fireEvent.click(screen.getByRole("button", { name: "Restart session" }));
|
|
243
|
+
});
|
|
244
|
+
expect(agent.newSession).toHaveBeenCalledTimes(3);
|
|
245
|
+
expect(
|
|
246
|
+
screen.queryByText("Run claude /login in the terminal"),
|
|
247
|
+
).not.toBeInTheDocument();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("does not authenticate or start a session for an agent switched away during initialization", async () => {
|
|
251
|
+
const old = createAgent();
|
|
252
|
+
const next = createAgent();
|
|
253
|
+
client.agent = old.agent;
|
|
254
|
+
renderPanel();
|
|
255
|
+
act(() => {
|
|
256
|
+
client.agent = next.agent;
|
|
257
|
+
store.set(agentSessionStateAtom, (state) =>
|
|
258
|
+
addSession(state, { agentId: "codex" }),
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await act(async () => old.initialization.resolve(advertisedAuth));
|
|
263
|
+
expect(old.agent.authenticate).not.toHaveBeenCalled();
|
|
264
|
+
expectNoSessionRequests(old.agent);
|
|
265
|
+
expectNoSessionRequests(next.agent);
|
|
266
|
+
await act(async () => next.initialization.resolve(initialized));
|
|
267
|
+
expect(next.agent.newSession).toHaveBeenCalledOnce();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("ignores a late initialization error from the previous agent", async () => {
|
|
271
|
+
const old = createAgent();
|
|
272
|
+
const next = createAgent();
|
|
273
|
+
client.agent = old.agent;
|
|
274
|
+
renderPanel();
|
|
275
|
+
act(() => {
|
|
276
|
+
client.agent = next.agent;
|
|
277
|
+
store.set(agentSessionStateAtom, (state) =>
|
|
278
|
+
addSession(state, { agentId: "codex" }),
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
await act(async () => next.initialization.resolve(initialized));
|
|
282
|
+
await act(async () =>
|
|
283
|
+
old.initialization.reject(new Error("Old agent failed")),
|
|
284
|
+
);
|
|
285
|
+
expect(screen.queryByText("Old agent failed")).not.toBeInTheDocument();
|
|
286
|
+
expect(next.agent.newSession).toHaveBeenCalledOnce();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("ignores the old handshake when the same agent reconnects before it finishes", async () => {
|
|
290
|
+
const { agent, initialization } = createAgent();
|
|
291
|
+
const reinitialization = Promise.withResolvers<InitializeResponse>();
|
|
292
|
+
agent.initialize
|
|
293
|
+
.mockReturnValueOnce(initialization.promise)
|
|
294
|
+
.mockReturnValueOnce(reinitialization.promise);
|
|
295
|
+
client.agent = agent;
|
|
296
|
+
const { refresh } = renderPanel();
|
|
297
|
+
|
|
298
|
+
client.connectionState = { status: "disconnected" };
|
|
299
|
+
refresh();
|
|
300
|
+
client.connectionState = { status: "connected" };
|
|
301
|
+
refresh();
|
|
302
|
+
expect(agent.initialize).toHaveBeenCalledTimes(2);
|
|
303
|
+
await act(async () => initialization.resolve(advertisedAuth));
|
|
304
|
+
expect(agent.authenticate).not.toHaveBeenCalled();
|
|
305
|
+
expectNoSessionRequests(agent);
|
|
306
|
+
await act(async () => reinitialization.resolve(initialized));
|
|
307
|
+
expect(agent.newSession).toHaveBeenCalledOnce();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("invalidates readiness on disconnect and reinitializes before resuming", async () => {
|
|
311
|
+
const { agent, initialization } = createAgent();
|
|
312
|
+
const reinitialization = Promise.withResolvers<InitializeResponse>();
|
|
313
|
+
agent.initialize
|
|
314
|
+
.mockReturnValueOnce(initialization.promise)
|
|
315
|
+
.mockReturnValueOnce(reinitialization.promise);
|
|
316
|
+
client.agent = agent;
|
|
317
|
+
const { refresh } = renderPanel();
|
|
318
|
+
await act(async () => initialization.resolve(initialized));
|
|
319
|
+
expect(agent.newSession).toHaveBeenCalledOnce();
|
|
320
|
+
|
|
321
|
+
client.connectionState = { status: "disconnected" };
|
|
322
|
+
refresh();
|
|
323
|
+
client.connectionState = { status: "connected" };
|
|
324
|
+
refresh();
|
|
325
|
+
expect(agent.loadSession).not.toHaveBeenCalled();
|
|
326
|
+
await act(async () => reinitialization.resolve(initialized));
|
|
327
|
+
expect(agent.loadSession).toHaveBeenCalledExactlyOnceWith({
|
|
328
|
+
sessionId: "new-session",
|
|
329
|
+
cwd: "/notebooks",
|
|
330
|
+
mcpServers: [],
|
|
331
|
+
});
|
|
332
|
+
expect(agent.newSession).toHaveBeenCalledOnce();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("keeps a queued prompt pending until initialization and session loading finish", async () => {
|
|
336
|
+
const { agent, initialization } = createAgent();
|
|
337
|
+
const loading = Promise.withResolvers<undefined>();
|
|
338
|
+
agent.loadSession.mockImplementation(async ({ sessionId }) => {
|
|
339
|
+
await loading.promise;
|
|
340
|
+
client.activeSessionId = sessionId as ExternalAgentSessionId;
|
|
341
|
+
return {};
|
|
342
|
+
});
|
|
343
|
+
client.agent = agent;
|
|
344
|
+
client.activeSessionId = "saved-session" as ExternalAgentSessionId;
|
|
345
|
+
store.set(agentSessionStateAtom, (state) =>
|
|
346
|
+
updateSessionExternalAgentSessionId(
|
|
347
|
+
state,
|
|
348
|
+
"saved-session" as ExternalAgentSessionId,
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
store.set(pendingAiPromptAtom, { prompt: "Help", submit: true });
|
|
352
|
+
renderPanel();
|
|
353
|
+
expect(store.get(pendingAiPromptAtom)).toEqual({
|
|
354
|
+
prompt: "Help",
|
|
355
|
+
submit: true,
|
|
356
|
+
});
|
|
357
|
+
expect(agent.prompt).not.toHaveBeenCalled();
|
|
358
|
+
|
|
359
|
+
await act(async () => initialization.resolve(initialized));
|
|
360
|
+
expect(agent.loadSession).toHaveBeenCalledOnce();
|
|
361
|
+
expect(agent.prompt).not.toHaveBeenCalled();
|
|
362
|
+
expect(store.get(pendingAiPromptAtom)).toEqual({
|
|
363
|
+
prompt: "Help",
|
|
364
|
+
submit: true,
|
|
365
|
+
});
|
|
366
|
+
await act(async () => loading.resolve(undefined));
|
|
367
|
+
expect(agent.prompt).toHaveBeenCalledOnce();
|
|
368
|
+
expect(agent.loadSession.mock.invocationCallOrder[0]).toBeLessThan(
|
|
369
|
+
agent.prompt.mock.invocationCallOrder[0],
|
|
370
|
+
);
|
|
371
|
+
expect(store.get(pendingAiPromptAtom)).toBeNull();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("does not finish initialization after unmount", async () => {
|
|
375
|
+
const { agent, initialization } = createAgent();
|
|
376
|
+
client.agent = agent;
|
|
377
|
+
const { unmount } = renderPanel();
|
|
378
|
+
unmount();
|
|
379
|
+
await act(async () => initialization.resolve(advertisedAuth));
|
|
380
|
+
expect(agent.authenticate).not.toHaveBeenCalled();
|
|
381
|
+
expectNoSessionRequests(agent);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -90,14 +90,16 @@ vi.mock("../context-utils", async (importOriginal) => ({
|
|
|
90
90
|
}));
|
|
91
91
|
vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn() }));
|
|
92
92
|
|
|
93
|
-
function renderPanel() {
|
|
94
|
-
|
|
93
|
+
async function renderPanel() {
|
|
94
|
+
const view = render(
|
|
95
95
|
<Provider store={store}>
|
|
96
96
|
<TooltipProvider>
|
|
97
97
|
<AgentPanel />
|
|
98
98
|
</TooltipProvider>
|
|
99
99
|
</Provider>,
|
|
100
100
|
);
|
|
101
|
+
await screen.findByRole("textbox", { name: "Prompt" });
|
|
102
|
+
return view;
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
function submitPrompt(prompt = "Explain this notebook") {
|
|
@@ -138,7 +140,7 @@ describe("AgentPanel prompt submission", () => {
|
|
|
138
140
|
|
|
139
141
|
it("preserves the draft, attachment and title when the notebook is unnamed", async () => {
|
|
140
142
|
store.set(filenameAtom, null);
|
|
141
|
-
renderPanel();
|
|
143
|
+
await renderPanel();
|
|
142
144
|
const title = store.get(selectedTabAtom)?.title;
|
|
143
145
|
fireEvent.change(screen.getByLabelText("Attachment"), {
|
|
144
146
|
target: {
|
|
@@ -181,7 +183,7 @@ describe("AgentPanel prompt submission", () => {
|
|
|
181
183
|
"shows a rejected prompt and allows another submission after dismissal (%s)",
|
|
182
184
|
async (error) => {
|
|
183
185
|
agent.prompt.mockRejectedValueOnce(error);
|
|
184
|
-
renderPanel();
|
|
186
|
+
await renderPanel();
|
|
185
187
|
submitPrompt();
|
|
186
188
|
|
|
187
189
|
expect(await screen.findByText(/Agent unavailable/)).toBeInTheDocument();
|
|
@@ -205,7 +207,7 @@ describe("AgentPanel prompt submission", () => {
|
|
|
205
207
|
data: { message: "Sign in to the agent" },
|
|
206
208
|
}),
|
|
207
209
|
);
|
|
208
|
-
renderPanel();
|
|
210
|
+
await renderPanel();
|
|
209
211
|
submitPrompt();
|
|
210
212
|
|
|
211
213
|
expect(await screen.findByText("Sign in to the agent")).toBeInTheDocument();
|
|
@@ -221,7 +223,7 @@ describe("AgentPanel prompt submission", () => {
|
|
|
221
223
|
vi.mocked(parseContextFromPrompt).mockRejectedValueOnce(
|
|
222
224
|
new Error("Context unavailable"),
|
|
223
225
|
);
|
|
224
|
-
renderPanel();
|
|
226
|
+
await renderPanel();
|
|
225
227
|
submitPrompt();
|
|
226
228
|
|
|
227
229
|
expect(await screen.findByText("Context unavailable")).toBeInTheDocument();
|
|
@@ -234,7 +236,7 @@ describe("AgentPanel prompt submission", () => {
|
|
|
234
236
|
|
|
235
237
|
it("reports failures from a queued prompt through the same handler", async () => {
|
|
236
238
|
agent.prompt.mockRejectedValueOnce(new Error("Queued prompt failed"));
|
|
237
|
-
renderPanel();
|
|
239
|
+
await renderPanel();
|
|
238
240
|
act(() =>
|
|
239
241
|
store.set(pendingAiPromptAtom, {
|
|
240
242
|
prompt: "Help with this cell",
|
|
@@ -255,7 +257,7 @@ describe("AgentPanel prompt submission", () => {
|
|
|
255
257
|
it("clears busy state when a cancelled prompt settles", async () => {
|
|
256
258
|
const response = Promise.withResolvers<{ stopReason: string }>();
|
|
257
259
|
agent.prompt.mockReturnValueOnce(response.promise);
|
|
258
|
-
renderPanel();
|
|
260
|
+
await renderPanel();
|
|
259
261
|
submitPrompt();
|
|
260
262
|
await waitFor(() => expect(agent.prompt).toHaveBeenCalledOnce());
|
|
261
263
|
expect(screen.getByText("Agent is working...")).toBeInTheDocument();
|
|
@@ -673,14 +673,14 @@ describe("state utility functions", () => {
|
|
|
673
673
|
it("should return correct command for claude on non-Windows", () => {
|
|
674
674
|
vi.spyOn(shortcuts, "isPlatformWindows").mockReturnValue(false);
|
|
675
675
|
expect(getAgentConnectionCommand("claude")).toMatchInlineSnapshot(`
|
|
676
|
-
"npx stdio-to-ws "npx @
|
|
676
|
+
"npx stdio-to-ws "npx @agentclientprotocol/claude-agent-acp" --port 3017"
|
|
677
677
|
`);
|
|
678
678
|
});
|
|
679
679
|
|
|
680
680
|
it("should return correct command for claude on Windows", () => {
|
|
681
681
|
vi.spyOn(shortcuts, "isPlatformWindows").mockReturnValue(true);
|
|
682
682
|
expect(getAgentConnectionCommand("claude")).toMatchInlineSnapshot(`
|
|
683
|
-
"npx stdio-to-ws "cmd /c npx @
|
|
683
|
+
"npx stdio-to-ws "cmd /c npx @agentclientprotocol/claude-agent-acp" --port 3017"
|
|
684
684
|
`);
|
|
685
685
|
});
|
|
686
686
|
|
|
@@ -718,13 +718,28 @@ const AgentPanel: React.FC = () => {
|
|
|
718
718
|
clearNotifications,
|
|
719
719
|
} = acpClient;
|
|
720
720
|
|
|
721
|
+
const isConnected = connectionState.status === "connected";
|
|
722
|
+
const [initializedConnection, setInitializedConnection] = useState<{
|
|
723
|
+
agent: NonNullable<typeof agent>;
|
|
724
|
+
wsUrl: string;
|
|
725
|
+
} | null>(null);
|
|
726
|
+
const isAgentReady =
|
|
727
|
+
isConnected &&
|
|
728
|
+
agent !== null &&
|
|
729
|
+
initializedConnection?.agent === agent &&
|
|
730
|
+
initializedConnection.wsUrl === wsUrl;
|
|
731
|
+
|
|
721
732
|
useEffect(() => {
|
|
722
|
-
|
|
733
|
+
setInitializedConnection(null);
|
|
734
|
+
setActiveSessionId(null);
|
|
735
|
+
if (!agent || !isConnected) {
|
|
723
736
|
return;
|
|
724
737
|
}
|
|
738
|
+
let cancelled = false;
|
|
739
|
+
setError(null);
|
|
725
740
|
|
|
726
|
-
const
|
|
727
|
-
|
|
741
|
+
const initialize = async () => {
|
|
742
|
+
await agent.initialize({
|
|
728
743
|
protocolVersion: 1,
|
|
729
744
|
clientCapabilities: {
|
|
730
745
|
fs: {
|
|
@@ -734,23 +749,26 @@ const AgentPanel: React.FC = () => {
|
|
|
734
749
|
},
|
|
735
750
|
});
|
|
736
751
|
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
await agent.authenticate({ methodId: authMethods[0].id });
|
|
752
|
+
// Agents use credentials from their CLI login. Advertised auth methods
|
|
753
|
+
// describe login options, not whether authentication is required.
|
|
754
|
+
if (!cancelled) {
|
|
755
|
+
setInitializedConnection({ agent, wsUrl });
|
|
742
756
|
}
|
|
743
757
|
};
|
|
744
758
|
|
|
745
|
-
|
|
746
|
-
|
|
759
|
+
initialize().catch((error) => {
|
|
760
|
+
if (!cancelled) {
|
|
761
|
+
logger.error("Failed to initialize agent", { error });
|
|
762
|
+
setError(error instanceof Error ? error : String(error));
|
|
763
|
+
}
|
|
747
764
|
});
|
|
748
|
-
|
|
765
|
+
return () => {
|
|
766
|
+
cancelled = true;
|
|
767
|
+
};
|
|
768
|
+
}, [agent, isConnected, wsUrl, setActiveSessionId]);
|
|
749
769
|
|
|
750
770
|
// Auto-connect to agent when we have an active session, but only once per session
|
|
751
771
|
useEffect(() => {
|
|
752
|
-
setActiveSessionId(null);
|
|
753
|
-
|
|
754
772
|
if (wsUrl === NO_WS_SET) {
|
|
755
773
|
return;
|
|
756
774
|
}
|
|
@@ -770,7 +788,7 @@ const AgentPanel: React.FC = () => {
|
|
|
770
788
|
}, [wsUrl]);
|
|
771
789
|
|
|
772
790
|
const handleNewSession = useEvent(async () => {
|
|
773
|
-
if (!agent) {
|
|
791
|
+
if (!agent || !isAgentReady) {
|
|
774
792
|
return;
|
|
775
793
|
}
|
|
776
794
|
|
|
@@ -814,7 +832,7 @@ const AgentPanel: React.FC = () => {
|
|
|
814
832
|
|
|
815
833
|
const handleResumeSession = useEvent(
|
|
816
834
|
async (previousSessionId: ExternalAgentSessionId) => {
|
|
817
|
-
if (!agent) {
|
|
835
|
+
if (!agent || !isAgentReady) {
|
|
818
836
|
return;
|
|
819
837
|
}
|
|
820
838
|
logger.debug("Resuming agent session", {
|
|
@@ -825,6 +843,8 @@ const AgentPanel: React.FC = () => {
|
|
|
825
843
|
}
|
|
826
844
|
creatingOrResumingSession.current = true;
|
|
827
845
|
try {
|
|
846
|
+
// Loading replays the agent's history through session notifications.
|
|
847
|
+
clearNotifications(previousSessionId);
|
|
828
848
|
const loadedSession = await agent.loadSession({
|
|
829
849
|
sessionId: previousSessionId,
|
|
830
850
|
cwd: getCwd(),
|
|
@@ -848,12 +868,19 @@ const AgentPanel: React.FC = () => {
|
|
|
848
868
|
},
|
|
849
869
|
);
|
|
850
870
|
|
|
851
|
-
|
|
852
|
-
|
|
871
|
+
const handleRestartSession = useEvent(async () => {
|
|
872
|
+
setError(null);
|
|
873
|
+
try {
|
|
874
|
+
await handleNewSession();
|
|
875
|
+
} catch (error) {
|
|
876
|
+
setError(error instanceof Error ? error : String(error));
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// Create or resume a session once initialization finishes.
|
|
853
881
|
const tabLastActiveSessionId = selectedTab?.externalAgentSessionId;
|
|
854
882
|
useEffect(() => {
|
|
855
|
-
|
|
856
|
-
if (!isConnected || !selectedTab || !agent) {
|
|
883
|
+
if (!isAgentReady || !selectedTab || !agent) {
|
|
857
884
|
return;
|
|
858
885
|
}
|
|
859
886
|
|
|
@@ -893,12 +920,12 @@ const AgentPanel: React.FC = () => {
|
|
|
893
920
|
|
|
894
921
|
createOrResumeSession();
|
|
895
922
|
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
896
|
-
}, [
|
|
923
|
+
}, [isAgentReady, agent, tabLastActiveSessionId, activeSessionId]);
|
|
897
924
|
|
|
898
925
|
// Handler for prompt submission
|
|
899
926
|
const handlePromptSubmit = useEvent(
|
|
900
927
|
async (_e: KeyboardEvent | undefined, prompt: string) => {
|
|
901
|
-
if (!activeSessionId || !agent || isLoading) {
|
|
928
|
+
if (!activeSessionId || !agent || !isAgentReady || isLoading) {
|
|
902
929
|
return;
|
|
903
930
|
}
|
|
904
931
|
|
|
@@ -983,7 +1010,7 @@ const AgentPanel: React.FC = () => {
|
|
|
983
1010
|
!activeSessionId ||
|
|
984
1011
|
!agent ||
|
|
985
1012
|
isLoading ||
|
|
986
|
-
|
|
1013
|
+
!isAgentReady ||
|
|
987
1014
|
!pendingPrompt
|
|
988
1015
|
) {
|
|
989
1016
|
return;
|
|
@@ -999,7 +1026,7 @@ const AgentPanel: React.FC = () => {
|
|
|
999
1026
|
activeSessionId,
|
|
1000
1027
|
agent,
|
|
1001
1028
|
isLoading,
|
|
1002
|
-
|
|
1029
|
+
isAgentReady,
|
|
1003
1030
|
pendingPrompt,
|
|
1004
1031
|
setPendingPrompt,
|
|
1005
1032
|
handlePromptSubmit,
|
|
@@ -1019,7 +1046,10 @@ const AgentPanel: React.FC = () => {
|
|
|
1019
1046
|
logger.debug("Manual connect requested", {
|
|
1020
1047
|
currentStatus: connectionState.status,
|
|
1021
1048
|
});
|
|
1022
|
-
|
|
1049
|
+
setError(null);
|
|
1050
|
+
void connect().catch((error) => {
|
|
1051
|
+
setError(error instanceof Error ? error : String(error));
|
|
1052
|
+
});
|
|
1023
1053
|
});
|
|
1024
1054
|
|
|
1025
1055
|
// Handler for manual disconnect
|
|
@@ -1112,14 +1142,22 @@ const AgentPanel: React.FC = () => {
|
|
|
1112
1142
|
className="w-3/4 mx-auto mt-10"
|
|
1113
1143
|
error={displayError}
|
|
1114
1144
|
action={
|
|
1115
|
-
|
|
1145
|
+
!isAgentReady ? (
|
|
1116
1146
|
<Button
|
|
1117
1147
|
variant="linkDestructive"
|
|
1118
1148
|
size="sm"
|
|
1119
1149
|
onClick={() => {
|
|
1120
|
-
|
|
1121
|
-
|
|
1150
|
+
disconnect();
|
|
1151
|
+
handleManualConnect();
|
|
1122
1152
|
}}
|
|
1153
|
+
>
|
|
1154
|
+
Retry connection
|
|
1155
|
+
</Button>
|
|
1156
|
+
) : isAuthError ? (
|
|
1157
|
+
<Button
|
|
1158
|
+
variant="linkDestructive"
|
|
1159
|
+
size="sm"
|
|
1160
|
+
onClick={handleRestartSession}
|
|
1123
1161
|
>
|
|
1124
1162
|
Restart session
|
|
1125
1163
|
</Button>
|
|
@@ -1153,14 +1191,16 @@ const AgentPanel: React.FC = () => {
|
|
|
1153
1191
|
}
|
|
1154
1192
|
|
|
1155
1193
|
const isLoadingSession =
|
|
1156
|
-
|
|
1194
|
+
isConnected && (!isAgentReady || tabLastActiveSessionId == null);
|
|
1157
1195
|
if (isLoadingSession) {
|
|
1158
1196
|
return (
|
|
1159
1197
|
<DelayMount milliseconds={delay}>
|
|
1160
1198
|
<div className="flex items-center justify-center h-full min-h-[200px] flex-col">
|
|
1161
1199
|
<Spinner size="medium" className="text-primary" />
|
|
1162
1200
|
<span className="text-sm text-muted-foreground">
|
|
1163
|
-
|
|
1201
|
+
{isAgentReady
|
|
1202
|
+
? "Creating a new session..."
|
|
1203
|
+
: "Initializing agent..."}
|
|
1164
1204
|
</span>
|
|
1165
1205
|
</div>
|
|
1166
1206
|
</DelayMount>
|
|
@@ -1232,7 +1272,7 @@ const AgentPanel: React.FC = () => {
|
|
|
1232
1272
|
currentAgentId={selectedTab?.agentId}
|
|
1233
1273
|
onConnect={handleManualConnect}
|
|
1234
1274
|
onDisconnect={handleManualDisconnect}
|
|
1235
|
-
onRestartThread={
|
|
1275
|
+
onRestartThread={isAgentReady ? handleRestartSession : undefined}
|
|
1236
1276
|
hasActiveSession={true}
|
|
1237
1277
|
shouldShowConnectionControl={wsUrl !== NO_WS_SET}
|
|
1238
1278
|
/>
|