@cueloop/client 0.1.0-alpha.96 → 0.1.0-alpha.98
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/package.json +5 -5
- package/src/annotations/components/AnnotationCards.test.tsx +42 -0
- package/src/annotations/components/AnnotationCards.tsx +29 -6
- package/src/annotations/use-annotation-surface.tsx +245 -30
- package/src/app/App.thread-agent.test.ts +12 -0
- package/src/app/App.tsx +217 -97
- package/src/app/app-screens.tsx +2 -0
- package/src/app/components/AppShell.zoom.test.tsx +4 -3
- package/src/app/intent-dispatch.test.ts +1 -0
- package/src/diff/components/AnnotatableFileView.tsx +6 -0
- package/src/diff/components/DiffContentView.tsx +9 -0
- package/src/markdown/components/ThreadView.tsx +60 -16
- package/src/settings/config.test.ts +15 -0
- package/src/settings/config.ts +14 -4
- package/src/stories/__snapshots__/stories.test.tsx.snap +74 -0
- package/src/thread/agent-continuation.test.ts +32 -0
- package/src/thread/agent-continuation.ts +13 -0
- package/src/thread/agent-transcript.test.ts +161 -0
- package/src/thread/agent-transcript.ts +332 -0
- package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
- package/src/thread/components/AgentConfigControls.test.tsx +110 -0
- package/src/thread/components/AgentConfigControls.tsx +114 -0
- package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
- package/src/thread/components/AgentThreadPane.test.tsx +780 -0
- package/src/thread/components/AgentThreadPane.tsx +356 -0
- package/src/thread/components/ThreadFooter.tsx +3 -0
- package/src/thread/components/ThreadTree.test.tsx +41 -0
- package/src/thread/components/ThreadTree.tsx +25 -5
- package/src/thread/thread-controller.ts +34 -0
- package/src/thread/use-thread-agent.test.tsx +65 -0
- package/src/thread/use-thread-agent.ts +101 -0
- package/src/workbench/components/GridTabContent.tsx +6 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import { SCHEMA_VERSION, type Thread, type ThreadAgentState } from "@cueloop/schema";
|
|
5
|
+
import { AgentThreadPane, AgentThreadPrototype } from "./AgentThreadPane";
|
|
6
|
+
import { ThreadFooter } from "./ThreadFooter";
|
|
7
|
+
import { ThreadView } from "../../markdown/components/ThreadView";
|
|
8
|
+
import { buildDisplay, marksByDisplay } from "../../markdown/view-plan";
|
|
9
|
+
import { DARK } from "../../appearance/theme";
|
|
10
|
+
import {
|
|
11
|
+
locateText,
|
|
12
|
+
waitForText,
|
|
13
|
+
pressKey,
|
|
14
|
+
typeText,
|
|
15
|
+
settle,
|
|
16
|
+
waitForState,
|
|
17
|
+
} from "../../testing/test-support";
|
|
18
|
+
import type { ThreadAgentClient } from "../use-thread-agent";
|
|
19
|
+
|
|
20
|
+
const thread: Thread = {
|
|
21
|
+
schemaVersion: SCHEMA_VERSION,
|
|
22
|
+
id: "thread",
|
|
23
|
+
workspace: { repoRoot: "/tmp/project", branch: "main" },
|
|
24
|
+
artifact: { type: "plan", content: "Original artifact", meta: {} },
|
|
25
|
+
annotations: [],
|
|
26
|
+
revisions: [],
|
|
27
|
+
status: "pending",
|
|
28
|
+
message: null,
|
|
29
|
+
createdAt: "2026-10-04",
|
|
30
|
+
};
|
|
31
|
+
const noop = () => {};
|
|
32
|
+
const empty: ThreadAgentState = {
|
|
33
|
+
threadId: thread.id,
|
|
34
|
+
phase: { kind: "idle" },
|
|
35
|
+
messages: [],
|
|
36
|
+
tools: [],
|
|
37
|
+
comments: [],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function createTestAgentClient(initial: ThreadAgentState) {
|
|
41
|
+
let state = initial;
|
|
42
|
+
const prompts: { id: string; text: string; retry?: string }[] = [];
|
|
43
|
+
const client: ThreadAgentClient = {
|
|
44
|
+
agentGet: async () => state,
|
|
45
|
+
agentPrompt: async (params) => {
|
|
46
|
+
prompts.push(params);
|
|
47
|
+
if (params.text)
|
|
48
|
+
state = {
|
|
49
|
+
...state,
|
|
50
|
+
messages: [
|
|
51
|
+
...state.messages,
|
|
52
|
+
{
|
|
53
|
+
id: `answer-${prompts.length}`,
|
|
54
|
+
role: "agent",
|
|
55
|
+
text: "The timer survives cancellation.",
|
|
56
|
+
complete: true,
|
|
57
|
+
revision: 1,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return state;
|
|
63
|
+
},
|
|
64
|
+
agentCancel: async () => state,
|
|
65
|
+
agentComment: async () => state,
|
|
66
|
+
agentPermission: async () => ({ ...state, phase: { kind: "idle" } }),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return { client, prompts };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function artifactView(
|
|
73
|
+
value: Thread,
|
|
74
|
+
onReply: (id: string, body: string) => void = noop,
|
|
75
|
+
onUpdateAnnotation: (id: string, body: string) => void = noop,
|
|
76
|
+
) {
|
|
77
|
+
const display = buildDisplay(value.artifact.content);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<ThreadView
|
|
81
|
+
session={value}
|
|
82
|
+
display={display}
|
|
83
|
+
marks={marksByDisplay(value.annotations, display)}
|
|
84
|
+
quickActions={[]}
|
|
85
|
+
observer={false}
|
|
86
|
+
onAnnotate={noop}
|
|
87
|
+
onReply={onReply}
|
|
88
|
+
onUpdateAnnotation={onUpdateAnnotation}
|
|
89
|
+
onExit={noop}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test("typing on the final blank line invokes the agent while the existing footer keeps its action", async () => {
|
|
95
|
+
const { client, prompts } = createTestAgentClient(empty);
|
|
96
|
+
let reviewSubmissions = 0;
|
|
97
|
+
const setup = await testRender(
|
|
98
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
99
|
+
<AgentThreadPane
|
|
100
|
+
thread={thread}
|
|
101
|
+
client={client}
|
|
102
|
+
focused
|
|
103
|
+
theme={DARK}
|
|
104
|
+
onActiveChange={noop}
|
|
105
|
+
onOpenFile={noop}
|
|
106
|
+
>
|
|
107
|
+
{artifactView(thread)}
|
|
108
|
+
</AgentThreadPane>
|
|
109
|
+
<ThreadFooter repo="project" branch="main" onSubmit={() => reviewSubmissions++} />
|
|
110
|
+
</box>,
|
|
111
|
+
{ width: 100, height: 24, kittyKeyboard: true },
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await waitForText(setup, "Original artifact");
|
|
116
|
+
expect(setup.captureCharFrame()).not.toContain("Workspace:");
|
|
117
|
+
expect(setup.captureCharFrame()).not.toContain("Ask Agent");
|
|
118
|
+
const artifact = locateText(setup, "Original artifact");
|
|
119
|
+
|
|
120
|
+
await setup.mockMouse.click(artifact.column, artifact.row);
|
|
121
|
+
await typeText(setup, "Ignored unmarked input");
|
|
122
|
+
expect(setup.captureCharFrame()).not.toContain("Ignored unmarked input");
|
|
123
|
+
await setup.mockMouse.click(artifact.column, artifact.row + 2);
|
|
124
|
+
await typeText(setup, "Explain");
|
|
125
|
+
expect(setup.captureCharFrame()).not.toContain("● Explain");
|
|
126
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
127
|
+
expect(prompts).toHaveLength(0);
|
|
128
|
+
await setup.mockMouse.click(artifact.column, artifact.row);
|
|
129
|
+
await setup.mockMouse.click(artifact.column, artifact.row + 2);
|
|
130
|
+
await typeText(setup, " retries");
|
|
131
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
132
|
+
await waitForText(setup, "The timer survives cancellation.");
|
|
133
|
+
expect(prompts[0]?.text).toBe("Explain retries");
|
|
134
|
+
const send = locateText(setup, "Send message (0)");
|
|
135
|
+
|
|
136
|
+
await setup.mockMouse.click(send.column, send.row);
|
|
137
|
+
expect(reviewSubmissions).toBe(1);
|
|
138
|
+
expect(prompts).toHaveLength(1);
|
|
139
|
+
} finally {
|
|
140
|
+
setup.renderer.destroy();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("typing after a submitted original creates a reply instead of editing the frozen comment", async () => {
|
|
145
|
+
const annotated: Thread = {
|
|
146
|
+
...thread,
|
|
147
|
+
annotations: [
|
|
148
|
+
{
|
|
149
|
+
id: "original",
|
|
150
|
+
kind: "comment",
|
|
151
|
+
body: "Explain the timer",
|
|
152
|
+
anchor: {
|
|
153
|
+
quote: "Original",
|
|
154
|
+
prefix: "",
|
|
155
|
+
suffix: " artifact",
|
|
156
|
+
blockIndex: 0,
|
|
157
|
+
start: 0,
|
|
158
|
+
end: 8,
|
|
159
|
+
},
|
|
160
|
+
createdAt: thread.createdAt,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
const state: ThreadAgentState = {
|
|
165
|
+
...empty,
|
|
166
|
+
submissions: [
|
|
167
|
+
{
|
|
168
|
+
id: "turn",
|
|
169
|
+
commentId: "original",
|
|
170
|
+
prompt: "Explain the timer",
|
|
171
|
+
quote: "Original",
|
|
172
|
+
status: "completed",
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
const { client } = createTestAgentClient(state);
|
|
177
|
+
const replies: string[] = [];
|
|
178
|
+
const updates: string[] = [];
|
|
179
|
+
const setup = await testRender(
|
|
180
|
+
<AgentThreadPane
|
|
181
|
+
thread={annotated}
|
|
182
|
+
client={client}
|
|
183
|
+
focused
|
|
184
|
+
theme={DARK}
|
|
185
|
+
onActiveChange={noop}
|
|
186
|
+
onOpenFile={noop}
|
|
187
|
+
>
|
|
188
|
+
{artifactView(
|
|
189
|
+
annotated,
|
|
190
|
+
(_id, body) => replies.push(body),
|
|
191
|
+
(_id, body) => updates.push(body),
|
|
192
|
+
)}
|
|
193
|
+
</AgentThreadPane>,
|
|
194
|
+
{ width: 100, height: 24, kittyKeyboard: true },
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
await waitForText(setup, "✓✓");
|
|
199
|
+
const original = locateText(setup, "Explain the timer");
|
|
200
|
+
|
|
201
|
+
await setup.mockMouse.click(original.column, original.row);
|
|
202
|
+
await typeText(setup, "What about cleanup?");
|
|
203
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
204
|
+
expect(replies).toEqual(["What about cleanup?"]);
|
|
205
|
+
expect(updates).toEqual([]);
|
|
206
|
+
expect(annotated.annotations[0]?.body).toBe("Explain the timer");
|
|
207
|
+
} finally {
|
|
208
|
+
setup.renderer.destroy();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("failed mirrors expose Retry and permissions keep activity at the bottom", async () => {
|
|
213
|
+
const state: ThreadAgentState = {
|
|
214
|
+
...empty,
|
|
215
|
+
submissions: [
|
|
216
|
+
{
|
|
217
|
+
id: "failed",
|
|
218
|
+
commentId: "original",
|
|
219
|
+
prompt: "Check retries",
|
|
220
|
+
quote: "Original",
|
|
221
|
+
status: "failed",
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
phase: {
|
|
225
|
+
kind: "permission",
|
|
226
|
+
permission: {
|
|
227
|
+
id: "permission",
|
|
228
|
+
title: "Run retry tests",
|
|
229
|
+
options: [{ optionId: "deny", name: "Reject once", kind: "reject_once" }],
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
const { client, prompts } = createTestAgentClient(state);
|
|
234
|
+
const setup = await testRender(
|
|
235
|
+
<AgentThreadPane
|
|
236
|
+
thread={thread}
|
|
237
|
+
client={client}
|
|
238
|
+
focused
|
|
239
|
+
theme={DARK}
|
|
240
|
+
onActiveChange={noop}
|
|
241
|
+
onOpenFile={noop}
|
|
242
|
+
>
|
|
243
|
+
{artifactView(thread)}
|
|
244
|
+
</AgentThreadPane>,
|
|
245
|
+
{ width: 100, height: 24, kittyKeyboard: true },
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
await waitForText(setup, "Run retry tests");
|
|
250
|
+
const frame = setup.captureCharFrame();
|
|
251
|
+
|
|
252
|
+
expect(frame.match(/Thinking/g)).toHaveLength(1);
|
|
253
|
+
expect(frame.indexOf("Thinking")).toBeGreaterThan(frame.indexOf("Check retries"));
|
|
254
|
+
const retry = locateText(setup, "Retry");
|
|
255
|
+
|
|
256
|
+
await setup.mockMouse.click(retry.column, retry.row);
|
|
257
|
+
expect(prompts[0]?.retry).toBe("failed");
|
|
258
|
+
expect(prompts[0]?.text).toBe("");
|
|
259
|
+
} finally {
|
|
260
|
+
setup.renderer.destroy();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("three bottom prompts survive delayed acceptance and remain separate submissions", async () => {
|
|
265
|
+
const { client } = createTestAgentClient(empty);
|
|
266
|
+
let release: ((state: ThreadAgentState) => void) | undefined;
|
|
267
|
+
const prompts: string[] = [];
|
|
268
|
+
client.agentPrompt = async (params) => {
|
|
269
|
+
prompts.push(params.text);
|
|
270
|
+
if (prompts.length === 1)
|
|
271
|
+
return new Promise((resolve) => {
|
|
272
|
+
release = resolve;
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return empty;
|
|
276
|
+
};
|
|
277
|
+
const setup = await testRender(
|
|
278
|
+
<AgentThreadPane
|
|
279
|
+
thread={thread}
|
|
280
|
+
client={client}
|
|
281
|
+
focused
|
|
282
|
+
theme={DARK}
|
|
283
|
+
onActiveChange={noop}
|
|
284
|
+
onOpenFile={noop}
|
|
285
|
+
>
|
|
286
|
+
{artifactView(thread)}
|
|
287
|
+
</AgentThreadPane>,
|
|
288
|
+
{ width: 100, height: 24, kittyKeyboard: true },
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
await waitForText(setup, "Original artifact");
|
|
293
|
+
await pressKey(setup, "ARROW_DOWN");
|
|
294
|
+
await pressKey(setup, "ARROW_DOWN");
|
|
295
|
+
await typeText(setup, "First prompt");
|
|
296
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
297
|
+
await typeText(setup, "Second prompt");
|
|
298
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
299
|
+
await typeText(setup, "Third prompt");
|
|
300
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
301
|
+
expect(prompts).toEqual(["First prompt"]);
|
|
302
|
+
release!(empty);
|
|
303
|
+
await settle(setup);
|
|
304
|
+
expect(prompts).toEqual(["First prompt", "Second prompt", "Third prompt"]);
|
|
305
|
+
} finally {
|
|
306
|
+
setup.renderer.destroy();
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("typing on an accepted plain prompt cannot edit it or create an unmarked comment", async () => {
|
|
311
|
+
const state: ThreadAgentState = {
|
|
312
|
+
...empty,
|
|
313
|
+
messages: [
|
|
314
|
+
{ id: "prompt", role: "user", text: "Explain retries", complete: true, revision: 1 },
|
|
315
|
+
],
|
|
316
|
+
submissions: [
|
|
317
|
+
{ id: "prompt", commentId: "prompt", prompt: "Explain retries", status: "completed" },
|
|
318
|
+
],
|
|
319
|
+
};
|
|
320
|
+
const { client } = createTestAgentClient(state);
|
|
321
|
+
const replies: string[] = [];
|
|
322
|
+
client.agentComment = async (params) => {
|
|
323
|
+
replies.push(params.comment.replyTo ?? "");
|
|
324
|
+
return state;
|
|
325
|
+
};
|
|
326
|
+
const setup = await testRender(
|
|
327
|
+
<AgentThreadPane
|
|
328
|
+
thread={thread}
|
|
329
|
+
client={client}
|
|
330
|
+
focused
|
|
331
|
+
theme={DARK}
|
|
332
|
+
onActiveChange={noop}
|
|
333
|
+
onOpenFile={noop}
|
|
334
|
+
>
|
|
335
|
+
{artifactView(thread)}
|
|
336
|
+
</AgentThreadPane>,
|
|
337
|
+
{ width: 100, height: 24, kittyKeyboard: true },
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
await waitForText(setup, "Explain retries");
|
|
342
|
+
const prompt = locateText(setup, "Explain retries");
|
|
343
|
+
|
|
344
|
+
await setup.mockMouse.click(prompt.column, prompt.row);
|
|
345
|
+
await typeText(setup, "What about cancellation?");
|
|
346
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
347
|
+
expect(replies).toEqual([]);
|
|
348
|
+
expect(state.messages[0]?.text).toBe("Explain retries");
|
|
349
|
+
expect(setup.captureCharFrame()).not.toContain("What about cancellation?");
|
|
350
|
+
} finally {
|
|
351
|
+
setup.renderer.destroy();
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("Option+Enter saves a comment, Command+Enter leaves its draft, and Ctrl+Enter invokes", async () => {
|
|
356
|
+
const saved: string[] = [];
|
|
357
|
+
let invocations = 0;
|
|
358
|
+
const display = buildDisplay(thread.artifact.content);
|
|
359
|
+
const setup = await testRender(
|
|
360
|
+
<ThreadView
|
|
361
|
+
session={thread}
|
|
362
|
+
display={display}
|
|
363
|
+
marks={marksByDisplay([], display)}
|
|
364
|
+
quickActions={[]}
|
|
365
|
+
observer={false}
|
|
366
|
+
onAnnotate={(_span, body) => saved.push(body)}
|
|
367
|
+
onReply={noop}
|
|
368
|
+
onUpdateAnnotation={noop}
|
|
369
|
+
onExit={noop}
|
|
370
|
+
onInvoke={() => invocations++}
|
|
371
|
+
/>,
|
|
372
|
+
{ width: 100, height: 24, kittyKeyboard: true },
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
await waitForText(setup, "Original artifact");
|
|
377
|
+
const artifact = locateText(setup, "Original artifact");
|
|
378
|
+
|
|
379
|
+
await setup.mockMouse.drag(artifact.column, artifact.row, artifact.column + 8, artifact.row);
|
|
380
|
+
await typeText(setup, "Editable comment");
|
|
381
|
+
setup.mockInput.pressKey("RETURN", { super: true });
|
|
382
|
+
await settle(setup);
|
|
383
|
+
expect(saved).toEqual([]);
|
|
384
|
+
expect(invocations).toBe(0);
|
|
385
|
+
expect(setup.captureCharFrame()).toContain("Editable comment");
|
|
386
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
387
|
+
expect(saved).toEqual(["Editable comment"]);
|
|
388
|
+
expect(invocations).toBe(0);
|
|
389
|
+
await setup.mockMouse.drag(artifact.column, artifact.row, artifact.column + 8, artifact.row);
|
|
390
|
+
await typeText(setup, "Ask the agent");
|
|
391
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
392
|
+
expect(saved).toEqual(["Editable comment", "Ask the agent"]);
|
|
393
|
+
expect(invocations).toBe(1);
|
|
394
|
+
} finally {
|
|
395
|
+
setup.renderer.destroy();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("comments on a later paragraph of agent output save editable and submit independently", async () => {
|
|
400
|
+
let state: ThreadAgentState = {
|
|
401
|
+
...empty,
|
|
402
|
+
messages: [
|
|
403
|
+
{ id: "earlier", role: "agent", text: "Earlier reply.", complete: true, revision: 1 },
|
|
404
|
+
{
|
|
405
|
+
id: "output",
|
|
406
|
+
role: "agent",
|
|
407
|
+
text: "First paragraph.\n\nThe agent can explain this comment.\n\nLast paragraph.",
|
|
408
|
+
complete: true,
|
|
409
|
+
revision: 2,
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
};
|
|
413
|
+
const { client, prompts } = createTestAgentClient(state);
|
|
414
|
+
client.agentComment = async ({ comment }) => {
|
|
415
|
+
state = {
|
|
416
|
+
...state,
|
|
417
|
+
comments: [...state.comments.filter((entry) => entry.id !== comment.id), comment],
|
|
418
|
+
};
|
|
419
|
+
return state;
|
|
420
|
+
};
|
|
421
|
+
const prompt = client.agentPrompt;
|
|
422
|
+
client.agentPrompt = async (params) => {
|
|
423
|
+
await prompt(params);
|
|
424
|
+
state = { ...state, comments: state.comments.map((comment) => ({ ...comment, sent: true })) };
|
|
425
|
+
return state;
|
|
426
|
+
};
|
|
427
|
+
const setup = await testRender(
|
|
428
|
+
<AgentThreadPane
|
|
429
|
+
thread={thread}
|
|
430
|
+
client={client}
|
|
431
|
+
focused
|
|
432
|
+
theme={DARK}
|
|
433
|
+
onActiveChange={noop}
|
|
434
|
+
onOpenFile={noop}
|
|
435
|
+
>
|
|
436
|
+
{artifactView(thread)}
|
|
437
|
+
</AgentThreadPane>,
|
|
438
|
+
{ width: 100, height: 30, kittyKeyboard: true },
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
await waitForText(setup, "The agent can explain");
|
|
443
|
+
await waitForState(
|
|
444
|
+
setup,
|
|
445
|
+
() => setup.renderer.getCursorState().visible,
|
|
446
|
+
"continuation composer ready",
|
|
447
|
+
);
|
|
448
|
+
const output = locateText(setup, "The agent can explain");
|
|
449
|
+
|
|
450
|
+
await setup.mockMouse.drag(output.column, output.row, output.column + 9, output.row);
|
|
451
|
+
await typeText(setup, "Explain this to me");
|
|
452
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
453
|
+
await waitForState(setup, () => state.comments.length === 1, "agent output comment saved");
|
|
454
|
+
expect(state.comments).toHaveLength(1);
|
|
455
|
+
expect(state.comments[0]?.messageId).toBe("output");
|
|
456
|
+
expect(state.comments[0]?.anchor.quote).toBe("The agent");
|
|
457
|
+
expect(state.comments[0]?.sent).toBe(false);
|
|
458
|
+
expect(prompts).toHaveLength(0);
|
|
459
|
+
expect(setup.captureCharFrame()).toContain("Explain this to me");
|
|
460
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
461
|
+
expect(prompts).toHaveLength(1);
|
|
462
|
+
expect(state.comments[0]?.sent).toBe(true);
|
|
463
|
+
} finally {
|
|
464
|
+
setup.renderer.destroy();
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
for (const policy of [
|
|
469
|
+
{ enabled: false, observer: false, pixelPrototype: false },
|
|
470
|
+
{ enabled: true, observer: true, pixelPrototype: false },
|
|
471
|
+
]) {
|
|
472
|
+
test(`disabled or shared agent views retain the original document without opening a harness (${JSON.stringify(policy)})`, async () => {
|
|
473
|
+
let reads = 0;
|
|
474
|
+
const { client } = createTestAgentClient(empty);
|
|
475
|
+
client.agentGet = async () => {
|
|
476
|
+
reads++;
|
|
477
|
+
return empty;
|
|
478
|
+
};
|
|
479
|
+
const setup = await testRender(
|
|
480
|
+
<AgentThreadPrototype
|
|
481
|
+
{...policy}
|
|
482
|
+
thread={thread}
|
|
483
|
+
client={client}
|
|
484
|
+
focused
|
|
485
|
+
theme={DARK}
|
|
486
|
+
onActiveChange={noop}
|
|
487
|
+
onOpenFile={noop}
|
|
488
|
+
>
|
|
489
|
+
{artifactView(thread)}
|
|
490
|
+
</AgentThreadPrototype>,
|
|
491
|
+
{ width: 100, height: 24 },
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
await waitForText(setup, "Original artifact");
|
|
496
|
+
expect(reads).toBe(0);
|
|
497
|
+
expect(setup.captureCharFrame()).not.toContain("Thinking");
|
|
498
|
+
} finally {
|
|
499
|
+
setup.renderer.destroy();
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
test("empty Thread typing and accepted prompts use normal paragraph padding without comment cards", async () => {
|
|
505
|
+
const value = { ...thread, artifact: { ...thread.artifact, content: "" } };
|
|
506
|
+
let accepted = empty;
|
|
507
|
+
const { client, prompts } = createTestAgentClient(empty);
|
|
508
|
+
client.agentPrompt = async (params) => {
|
|
509
|
+
prompts.push(params);
|
|
510
|
+
accepted = {
|
|
511
|
+
...empty,
|
|
512
|
+
submissions: [
|
|
513
|
+
{ id: "plain-prompt", commentId: "plain-prompt", prompt: params.text, status: "completed" },
|
|
514
|
+
],
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
return accepted;
|
|
518
|
+
};
|
|
519
|
+
const setup = await testRender(
|
|
520
|
+
<AgentThreadPane
|
|
521
|
+
thread={value}
|
|
522
|
+
client={client}
|
|
523
|
+
focused
|
|
524
|
+
theme={DARK}
|
|
525
|
+
onActiveChange={noop}
|
|
526
|
+
onOpenFile={noop}
|
|
527
|
+
>
|
|
528
|
+
{artifactView(value)}
|
|
529
|
+
</AgentThreadPane>,
|
|
530
|
+
{ width: 100, height: 24, kittyKeyboard: true },
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
try {
|
|
534
|
+
await settle(setup);
|
|
535
|
+
await pressKey(setup, "ARROW_DOWN");
|
|
536
|
+
await typeText(setup, "Hello from the prompt");
|
|
537
|
+
expect(locateText(setup, "Hello from the prompt")).toMatchObject({ row: 1, column: 2 });
|
|
538
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
539
|
+
await waitForText(setup, "Hello from the prompt");
|
|
540
|
+
expect(prompts).toHaveLength(1);
|
|
541
|
+
expect(locateText(setup, "Hello from the prompt")).toMatchObject({ row: 1, column: 2 });
|
|
542
|
+
expect(setup.captureCharFrame()).not.toContain("●");
|
|
543
|
+
expect(setup.captureCharFrame()).not.toContain("✓");
|
|
544
|
+
} finally {
|
|
545
|
+
setup.renderer.destroy();
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("clicking whitespace below the conversation focuses a blinking prompt before typing", async () => {
|
|
550
|
+
const { client, prompts } = createTestAgentClient(empty);
|
|
551
|
+
const setup = await testRender(
|
|
552
|
+
<AgentThreadPane
|
|
553
|
+
thread={thread}
|
|
554
|
+
client={client}
|
|
555
|
+
focused
|
|
556
|
+
theme={DARK}
|
|
557
|
+
onActiveChange={noop}
|
|
558
|
+
onOpenFile={noop}
|
|
559
|
+
>
|
|
560
|
+
{artifactView(thread)}
|
|
561
|
+
</AgentThreadPane>,
|
|
562
|
+
{ width: 100, height: 24, kittyKeyboard: true },
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
await waitForText(setup, "Original artifact");
|
|
567
|
+
const text = locateText(setup, "Original artifact");
|
|
568
|
+
|
|
569
|
+
await setup.mockMouse.click(text.column + 20, text.row + 5);
|
|
570
|
+
await settle(setup);
|
|
571
|
+
expect(setup.renderer.getCursorState()).toMatchObject({ visible: true, blinking: true });
|
|
572
|
+
expect(prompts).toHaveLength(0);
|
|
573
|
+
await typeText(setup, "A prompt from whitespace");
|
|
574
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
575
|
+
expect(prompts[0]?.text).toBe("A prompt from whitespace");
|
|
576
|
+
await waitForText(setup, "The timer survives cancellation.");
|
|
577
|
+
await settle(setup);
|
|
578
|
+
const answer = locateText(setup, "The timer survives cancellation.");
|
|
579
|
+
const cursor = setup.renderer.getCursorState();
|
|
580
|
+
|
|
581
|
+
expect(cursor).toMatchObject({ visible: true, blinking: true, x: 3 });
|
|
582
|
+
expect(cursor.y).toBeGreaterThan(answer.row + 1);
|
|
583
|
+
await typeText(setup, "Continue after the reply");
|
|
584
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
585
|
+
expect(prompts[1]?.text).toBe("Continue after the reply");
|
|
586
|
+
} finally {
|
|
587
|
+
setup.renderer.destroy();
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("an arriving reply preserves a bottom draft and submits it as the next prompt", async () => {
|
|
592
|
+
const { client, prompts } = createTestAgentClient(empty);
|
|
593
|
+
let finish: ((state: ThreadAgentState) => void) | undefined;
|
|
594
|
+
const answer: ThreadAgentState = {
|
|
595
|
+
...empty,
|
|
596
|
+
messages: [
|
|
597
|
+
{
|
|
598
|
+
id: "arriving-answer",
|
|
599
|
+
role: "agent",
|
|
600
|
+
text: "An answer has arrived.",
|
|
601
|
+
complete: true,
|
|
602
|
+
revision: 1,
|
|
603
|
+
},
|
|
604
|
+
],
|
|
605
|
+
};
|
|
606
|
+
client.agentPrompt = async (params) => {
|
|
607
|
+
prompts.push(params);
|
|
608
|
+
if (prompts.length === 1)
|
|
609
|
+
return new Promise<ThreadAgentState>((resolve) => {
|
|
610
|
+
finish = resolve;
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
return answer;
|
|
614
|
+
};
|
|
615
|
+
const setup = await testRender(
|
|
616
|
+
<AgentThreadPane
|
|
617
|
+
thread={thread}
|
|
618
|
+
client={client}
|
|
619
|
+
focused
|
|
620
|
+
theme={DARK}
|
|
621
|
+
onActiveChange={noop}
|
|
622
|
+
onOpenFile={noop}
|
|
623
|
+
>
|
|
624
|
+
{artifactView(thread)}
|
|
625
|
+
</AgentThreadPane>,
|
|
626
|
+
{ width: 100, height: 24, kittyKeyboard: true },
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
try {
|
|
630
|
+
await waitForText(setup, "Original artifact");
|
|
631
|
+
const artifact = locateText(setup, "Original artifact");
|
|
632
|
+
|
|
633
|
+
await setup.mockMouse.click(artifact.column + 20, artifact.row + 5);
|
|
634
|
+
await typeText(setup, "First prompt");
|
|
635
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
636
|
+
await setup.mockMouse.click(artifact.column + 20, artifact.row + 5);
|
|
637
|
+
await typeText(setup, "Preserved second prompt");
|
|
638
|
+
finish!(answer);
|
|
639
|
+
await waitForText(setup, "An answer has arrived.");
|
|
640
|
+
expect(setup.captureCharFrame()).toContain("Preserved second prompt");
|
|
641
|
+
await typeText(setup, " after completion");
|
|
642
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
643
|
+
expect(prompts[1]?.text).toBe("Preserved second prompt after completion");
|
|
644
|
+
} finally {
|
|
645
|
+
setup.renderer.destroy();
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("clicking the first blank row after a reply opens continuation without losing a draft", async () => {
|
|
650
|
+
const state: ThreadAgentState = {
|
|
651
|
+
...empty,
|
|
652
|
+
messages: [
|
|
653
|
+
{ id: "answer", role: "agent", text: "The final reply.", complete: true, revision: 1 },
|
|
654
|
+
],
|
|
655
|
+
};
|
|
656
|
+
const { client, prompts } = createTestAgentClient(state);
|
|
657
|
+
const setup = await testRender(
|
|
658
|
+
<AgentThreadPane
|
|
659
|
+
thread={thread}
|
|
660
|
+
client={client}
|
|
661
|
+
focused
|
|
662
|
+
theme={DARK}
|
|
663
|
+
onActiveChange={noop}
|
|
664
|
+
onOpenFile={noop}
|
|
665
|
+
>
|
|
666
|
+
{artifactView(thread)}
|
|
667
|
+
</AgentThreadPane>,
|
|
668
|
+
{ width: 100, height: 24, kittyKeyboard: true },
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
await waitForText(setup, "The final reply.");
|
|
673
|
+
await pressKey(setup, "ESCAPE");
|
|
674
|
+
const reply = locateText(setup, "The final reply.");
|
|
675
|
+
|
|
676
|
+
await setup.mockMouse.click(reply.column + 5, reply.row + 1);
|
|
677
|
+
await settle(setup);
|
|
678
|
+
expect(setup.renderer.getCursorState()).toMatchObject({ visible: true, blinking: true });
|
|
679
|
+
await typeText(setup, "Continue here");
|
|
680
|
+
await setup.mockMouse.click(reply.column + 5, reply.row + 1);
|
|
681
|
+
await settle(setup);
|
|
682
|
+
expect(setup.renderer.getCursorState()).toMatchObject({ visible: true, blinking: true });
|
|
683
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
684
|
+
expect(prompts[0]?.text).toBe("Continue here");
|
|
685
|
+
} finally {
|
|
686
|
+
setup.renderer.destroy();
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("a rejected prompt returns visibly to the composer and can be edited before retry", async () => {
|
|
691
|
+
const { client, prompts } = createTestAgentClient(empty);
|
|
692
|
+
let reject = true;
|
|
693
|
+
const acceptPrompt = client.agentPrompt;
|
|
694
|
+
client.agentPrompt = async (params) => {
|
|
695
|
+
if (reject) {
|
|
696
|
+
reject = false;
|
|
697
|
+
throw new Error("Prompt rejected");
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return acceptPrompt(params);
|
|
701
|
+
};
|
|
702
|
+
const setup = await testRender(
|
|
703
|
+
<AgentThreadPane
|
|
704
|
+
thread={thread}
|
|
705
|
+
client={client}
|
|
706
|
+
focused
|
|
707
|
+
theme={DARK}
|
|
708
|
+
onActiveChange={noop}
|
|
709
|
+
onOpenFile={noop}
|
|
710
|
+
>
|
|
711
|
+
{artifactView(thread)}
|
|
712
|
+
</AgentThreadPane>,
|
|
713
|
+
{ width: 100, height: 24, kittyKeyboard: true },
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
try {
|
|
717
|
+
await waitForText(setup, "Original artifact");
|
|
718
|
+
const artifact = locateText(setup, "Original artifact");
|
|
719
|
+
await setup.mockMouse.click(artifact.column, artifact.row + 2);
|
|
720
|
+
await typeText(setup, "Explain retries");
|
|
721
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
722
|
+
await waitForText(setup, "Prompt rejected");
|
|
723
|
+
expect(setup.captureCharFrame()).toContain("Explain retries");
|
|
724
|
+
await typeText(setup, " please");
|
|
725
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
726
|
+
await waitForText(setup, "The timer survives cancellation.");
|
|
727
|
+
expect(prompts[0]?.text).toBe("Explain retries please");
|
|
728
|
+
} finally {
|
|
729
|
+
setup.renderer.destroy();
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("a failed mutation flush restores the prompt alongside a newer visible draft", async () => {
|
|
734
|
+
const { client, prompts } = createTestAgentClient(empty);
|
|
735
|
+
let failFlush: ((reason: Error) => void) | undefined;
|
|
736
|
+
let flushStarted = false;
|
|
737
|
+
const failedFlush = new Promise<void>((_resolve, reject) => {
|
|
738
|
+
failFlush = reject;
|
|
739
|
+
});
|
|
740
|
+
const setup = await testRender(
|
|
741
|
+
<AgentThreadPane
|
|
742
|
+
thread={thread}
|
|
743
|
+
client={client}
|
|
744
|
+
focused
|
|
745
|
+
theme={DARK}
|
|
746
|
+
onActiveChange={noop}
|
|
747
|
+
onOpenFile={noop}
|
|
748
|
+
flushMutations={() => {
|
|
749
|
+
if (flushStarted) return Promise.resolve();
|
|
750
|
+
flushStarted = true;
|
|
751
|
+
|
|
752
|
+
return failedFlush;
|
|
753
|
+
}}
|
|
754
|
+
>
|
|
755
|
+
{artifactView(thread)}
|
|
756
|
+
</AgentThreadPane>,
|
|
757
|
+
{ width: 100, height: 24, kittyKeyboard: true },
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
try {
|
|
761
|
+
await waitForText(setup, "Original artifact");
|
|
762
|
+
const artifact = locateText(setup, "Original artifact");
|
|
763
|
+
await setup.mockMouse.click(artifact.column, artifact.row + 2);
|
|
764
|
+
await typeText(setup, "First question");
|
|
765
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
766
|
+
await waitForState(setup, () => flushStarted, "mutation flush started");
|
|
767
|
+
await setup.mockMouse.click(artifact.column, artifact.row + 2);
|
|
768
|
+
await typeText(setup, "Newer draft");
|
|
769
|
+
failFlush!(new Error("Mutation flush failed"));
|
|
770
|
+
await waitForText(setup, "Mutation flush failed");
|
|
771
|
+
await waitForText(setup, "First question");
|
|
772
|
+
expect(setup.captureCharFrame()).toContain("Newer draft");
|
|
773
|
+
await typeText(setup, " edited");
|
|
774
|
+
await pressKey(setup, "RETURN", { ctrl: true });
|
|
775
|
+
await waitForText(setup, "The timer survives cancellation.");
|
|
776
|
+
expect(prompts[0]?.text).toBe("First question\nNewer draft edited");
|
|
777
|
+
} finally {
|
|
778
|
+
setup.renderer.destroy();
|
|
779
|
+
}
|
|
780
|
+
});
|