@cueloop/client 0.1.0-alpha.98 → 0.1.0-alpha.99

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.
Files changed (60) hide show
  1. package/package.json +11 -11
  2. package/src/annotations/components/AnnotationCards.tsx +2 -2
  3. package/src/annotations/use-annotation-surface.tsx +13 -2
  4. package/src/app/App.deleted-scroll.test.tsx +1 -0
  5. package/src/app/App.diff.test.tsx +1 -0
  6. package/src/app/App.identity.test.tsx +1 -0
  7. package/src/app/App.regression.test.tsx +5 -0
  8. package/src/app/App.share-dialog.test.tsx +5 -0
  9. package/src/app/App.submit-confirm.test.tsx +3 -0
  10. package/src/app/App.test.tsx +1 -0
  11. package/src/app/App.tsx +6 -0
  12. package/src/app/App.visual.test.tsx +2 -0
  13. package/src/app/App.workbench.test.tsx +53 -0
  14. package/src/app/app-screens.tsx +1 -0
  15. package/src/app/components/AppShell.tsx +1 -0
  16. package/src/app/run.ts +1 -0
  17. package/src/diff/components/AnnotatableFileView.test.tsx +2 -0
  18. package/src/diff/components/AnnotatableFileView.tsx +2 -0
  19. package/src/diff/components/DiffContentView.scroll.test.tsx +1 -0
  20. package/src/diff/components/DiffContentView.tsx +1 -0
  21. package/src/diff/diff-intraline.test.ts +1 -0
  22. package/src/diff/diff-intraline.ts +2 -0
  23. package/src/diff/split-diff.ts +2 -0
  24. package/src/integrations/components/ShareDialog.tsx +1 -0
  25. package/src/markdown/components/ThreadView.tsx +2 -0
  26. package/src/markdown/mark-runs.ts +1 -0
  27. package/src/markdown/markdown-highlight.ts +2 -0
  28. package/src/performance/otlp-trace.test.ts +3 -0
  29. package/src/performance/otlp-trace.ts +4 -0
  30. package/src/performance/perf-report.ts +1 -0
  31. package/src/performance/perf-timings.ts +1 -0
  32. package/src/settings/components/quick-actions-editor.test.tsx +3 -0
  33. package/src/stories/stories-app.tsx +3 -0
  34. package/src/stories/story.ts +2 -0
  35. package/src/terminal/pty.ts +1 -0
  36. package/src/thread/agent-transcript.ts +4 -0
  37. package/src/thread/components/AgentConfigControls.test.tsx +2 -0
  38. package/src/thread/components/AgentThreadPane.test.tsx +54 -5
  39. package/src/thread/components/AgentThreadPane.tsx +11 -0
  40. package/src/thread/components/ThreadFooter.test.tsx +2 -0
  41. package/src/thread/components/thread-tree.test.ts +3 -0
  42. package/src/thread/components/thread-tree.ts +4 -0
  43. package/src/thread/thread-controller.curate.test.ts +1 -0
  44. package/src/thread/thread-controller.live-diff.test.ts +7 -0
  45. package/src/thread/thread-controller.share.test.ts +1 -0
  46. package/src/thread/thread-controller.ts +8 -0
  47. package/src/thread/use-thread-agent.test.tsx +143 -0
  48. package/src/thread/use-thread-agent.ts +22 -12
  49. package/src/ui/components/PromptDialog.test.tsx +4 -0
  50. package/src/ui/components/RootOverlay.tsx +1 -0
  51. package/src/ui/components/primitives/Dialog.test.tsx +1 -0
  52. package/src/workbench/components/ChangesColumn.tsx +1 -0
  53. package/src/workbench/components/ChangesFileTree.test.tsx +2 -0
  54. package/src/workbench/components/FileContentsView.tsx +2 -0
  55. package/src/workbench/components/GridTabContent.tsx +1 -0
  56. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  57. package/src/workbench/components/ProjectTreeView.tsx +1 -0
  58. package/src/workbench/components/editor-grid.test.ts +11 -0
  59. package/src/workbench/components/editor-grid.ts +10 -0
  60. package/src/workbench/use-changes-workbench.ts +5 -0
@@ -169,6 +169,7 @@ export function projectThreadConversation(
169
169
  submissionId?: string,
170
170
  ): void => {
171
171
  const blocks: DisplayBlock[] = buildDisplay(quote);
172
+
172
173
  if (!blocks.length)
173
174
  blocks.push({
174
175
  type: "same",
@@ -198,6 +199,7 @@ export function projectThreadConversation(
198
199
  const replies = [...thread.annotations, ...state.comments].filter(
199
200
  (entry) => entry.replyTo === commentId,
200
201
  );
202
+
201
203
  for (const reply of replies) {
202
204
  const replica = {
203
205
  ...annotation,
@@ -227,6 +229,7 @@ export function projectThreadConversation(
227
229
  ],
228
230
  ],
229
231
  ]);
232
+
230
233
  for (const [index, values] of localMarks) {
231
234
  marks.set(
232
235
  index + offset,
@@ -273,6 +276,7 @@ export function projectThreadConversation(
273
276
  );
274
277
  }
275
278
  };
279
+
276
280
  if (state.submissions?.length) {
277
281
  for (const submission of state.submissions) {
278
282
  // A prompt may use its submission ID as a discussion root; that is not an inline comment.
@@ -43,6 +43,7 @@ const initial: ThreadAgentState = {
43
43
 
44
44
  test("model and reasoning choices share the existing overlay and close after selection", async () => {
45
45
  const choices: string[] = [];
46
+
46
47
  function TestMenus(): React.ReactNode {
47
48
  const menu = useMenuControlState();
48
49
  const [state, setState] = useState(initial);
@@ -95,6 +96,7 @@ test("model and reasoning choices share the existing overlay and close after sel
95
96
  await click("Second model");
96
97
  expect(setup.captureCharFrame()).not.toContain("Extra model 17");
97
98
  const model = locateText(setup, "First model");
99
+
98
100
  for (let count = 0; count < 12; count++)
99
101
  await setup.mockMouse.scroll(model.column, model.row, "down");
100
102
  await settle(setup);
@@ -127,8 +127,9 @@ test("typing on the final blank line invokes the agent while the existing footer
127
127
  expect(prompts).toHaveLength(0);
128
128
  await setup.mockMouse.click(artifact.column, artifact.row);
129
129
  await setup.mockMouse.click(artifact.column, artifact.row + 2);
130
- await typeText(setup, " retries");
131
- await pressKey(setup, "RETURN", { ctrl: true });
130
+ // Submit the typing burst before waiting for content-change repaint.
131
+ await setup.mockInput.typeText(" retries");
132
+ setup.mockInput.pressKey("RETURN", { ctrl: true });
132
133
  await waitForText(setup, "The timer survives cancellation.");
133
134
  expect(prompts[0]?.text).toBe("Explain retries");
134
135
  const send = locateText(setup, "Send message (0)");
@@ -265,6 +266,7 @@ test("three bottom prompts survive delayed acceptance and remain separate submis
265
266
  const { client } = createTestAgentClient(empty);
266
267
  let release: ((state: ThreadAgentState) => void) | undefined;
267
268
  const prompts: string[] = [];
269
+
268
270
  client.agentPrompt = async (params) => {
269
271
  prompts.push(params.text);
270
272
  if (prompts.length === 1)
@@ -319,8 +321,10 @@ test("typing on an accepted plain prompt cannot edit it or create an unmarked co
319
321
  };
320
322
  const { client } = createTestAgentClient(state);
321
323
  const replies: string[] = [];
324
+
322
325
  client.agentComment = async (params) => {
323
326
  replies.push(params.comment.replyTo ?? "");
327
+
324
328
  return state;
325
329
  };
326
330
  const setup = await testRender(
@@ -411,17 +415,21 @@ test("comments on a later paragraph of agent output save editable and submit ind
411
415
  ],
412
416
  };
413
417
  const { client, prompts } = createTestAgentClient(state);
418
+
414
419
  client.agentComment = async ({ comment }) => {
415
420
  state = {
416
421
  ...state,
417
422
  comments: [...state.comments.filter((entry) => entry.id !== comment.id), comment],
418
423
  };
424
+
419
425
  return state;
420
426
  };
421
427
  const prompt = client.agentPrompt;
428
+
422
429
  client.agentPrompt = async (params) => {
423
430
  await prompt(params);
424
431
  state = { ...state, comments: state.comments.map((comment) => ({ ...comment, sent: true })) };
432
+
425
433
  return state;
426
434
  };
427
435
  const setup = await testRender(
@@ -472,8 +480,10 @@ for (const policy of [
472
480
  test(`disabled or shared agent views retain the original document without opening a harness (${JSON.stringify(policy)})`, async () => {
473
481
  let reads = 0;
474
482
  const { client } = createTestAgentClient(empty);
483
+
475
484
  client.agentGet = async () => {
476
485
  reads++;
486
+
477
487
  return empty;
478
488
  };
479
489
  const setup = await testRender(
@@ -505,6 +515,7 @@ test("empty Thread typing and accepted prompts use normal paragraph padding with
505
515
  const value = { ...thread, artifact: { ...thread.artifact, content: "" } };
506
516
  let accepted = empty;
507
517
  const { client, prompts } = createTestAgentClient(empty);
518
+
508
519
  client.agentPrompt = async (params) => {
509
520
  prompts.push(params);
510
521
  accepted = {
@@ -567,7 +578,15 @@ test("clicking whitespace below the conversation focuses a blinking prompt befor
567
578
  const text = locateText(setup, "Original artifact");
568
579
 
569
580
  await setup.mockMouse.click(text.column + 20, text.row + 5);
570
- await settle(setup);
581
+ await waitForState(
582
+ setup,
583
+ () => {
584
+ const cursor = setup.renderer.getCursorState();
585
+
586
+ return cursor.visible && cursor.blinking;
587
+ },
588
+ "continuation cursor focused",
589
+ );
571
590
  expect(setup.renderer.getCursorState()).toMatchObject({ visible: true, blinking: true });
572
591
  expect(prompts).toHaveLength(0);
573
592
  await typeText(setup, "A prompt from whitespace");
@@ -603,6 +622,7 @@ test("an arriving reply preserves a bottom draft and submits it as the next prom
603
622
  },
604
623
  ],
605
624
  };
625
+
606
626
  client.agentPrompt = async (params) => {
607
627
  prompts.push(params);
608
628
  if (prompts.length === 1)
@@ -670,15 +690,41 @@ test("clicking the first blank row after a reply opens continuation without losi
670
690
 
671
691
  try {
672
692
  await waitForText(setup, "The final reply.");
693
+ await waitForState(
694
+ setup,
695
+ () => setup.renderer.currentFocusedEditor !== null,
696
+ "initial continuation focus ready",
697
+ );
673
698
  await pressKey(setup, "ESCAPE");
699
+ await waitForState(
700
+ setup,
701
+ () => setup.renderer.currentFocusedEditor === null,
702
+ "continuation dismissed",
703
+ );
674
704
  const reply = locateText(setup, "The final reply.");
675
705
 
676
706
  await setup.mockMouse.click(reply.column + 5, reply.row + 1);
677
- await settle(setup);
707
+ await waitForState(
708
+ setup,
709
+ () => {
710
+ const cursor = setup.renderer.getCursorState();
711
+
712
+ return setup.renderer.currentFocusedEditor !== null && cursor.visible && cursor.blinking;
713
+ },
714
+ "continuation cursor focused",
715
+ );
678
716
  expect(setup.renderer.getCursorState()).toMatchObject({ visible: true, blinking: true });
679
717
  await typeText(setup, "Continue here");
680
718
  await setup.mockMouse.click(reply.column + 5, reply.row + 1);
681
- await settle(setup);
719
+ await waitForState(
720
+ setup,
721
+ () => {
722
+ const cursor = setup.renderer.getCursorState();
723
+
724
+ return setup.renderer.currentFocusedEditor !== null && cursor.visible && cursor.blinking;
725
+ },
726
+ "continuation cursor focused",
727
+ );
682
728
  expect(setup.renderer.getCursorState()).toMatchObject({ visible: true, blinking: true });
683
729
  await pressKey(setup, "RETURN", { ctrl: true });
684
730
  expect(prompts[0]?.text).toBe("Continue here");
@@ -691,6 +737,7 @@ test("a rejected prompt returns visibly to the composer and can be edited before
691
737
  const { client, prompts } = createTestAgentClient(empty);
692
738
  let reject = true;
693
739
  const acceptPrompt = client.agentPrompt;
740
+
694
741
  client.agentPrompt = async (params) => {
695
742
  if (reject) {
696
743
  reject = false;
@@ -716,6 +763,7 @@ test("a rejected prompt returns visibly to the composer and can be edited before
716
763
  try {
717
764
  await waitForText(setup, "Original artifact");
718
765
  const artifact = locateText(setup, "Original artifact");
766
+
719
767
  await setup.mockMouse.click(artifact.column, artifact.row + 2);
720
768
  await typeText(setup, "Explain retries");
721
769
  await pressKey(setup, "RETURN", { ctrl: true });
@@ -760,6 +808,7 @@ test("a failed mutation flush restores the prompt alongside a newer visible draf
760
808
  try {
761
809
  await waitForText(setup, "Original artifact");
762
810
  const artifact = locateText(setup, "Original artifact");
811
+
763
812
  await setup.mockMouse.click(artifact.column, artifact.row + 2);
764
813
  await typeText(setup, "First question");
765
814
  await pressKey(setup, "RETURN", { ctrl: true });
@@ -75,6 +75,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
75
75
  const busy = state.phase.kind === "running" || state.phase.kind === "permission";
76
76
 
77
77
  const [pulse, setPulse] = useState(false);
78
+
78
79
  useEffect(() => {
79
80
  if (!busy) return;
80
81
  const timer = setInterval(() => setPulse((value) => !value), 600);
@@ -99,8 +100,10 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
99
100
  />,
100
101
  ),
101
102
  );
103
+
102
104
  useEffect(() => {
103
105
  notifyActive(true);
106
+
104
107
  return () => notifyActive(false);
105
108
  }, []);
106
109
  useEffect(() => {
@@ -122,8 +125,10 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
122
125
  try {
123
126
  while (invocations.current.length) {
124
127
  const input = invocations.current.shift()!;
128
+
125
129
  try {
126
130
  const writesSaved = (await Promise.all(input.writes)).every(Boolean);
131
+
127
132
  if (!writesSaved) {
128
133
  rejected.push(input.text);
129
134
  continue;
@@ -147,12 +152,14 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
147
152
  }
148
153
  };
149
154
  const invokeRef = useRef<() => Promise<void>>(async () => {});
155
+
150
156
  useEffect(() => {
151
157
  invokeRef.current = invoke;
152
158
  });
153
159
  const notifyInvoke = useEffectEvent((invoke: (() => void) | undefined) =>
154
160
  props.onInvokeChange?.(invoke),
155
161
  );
162
+
156
163
  useEffect(() => {
157
164
  notifyInvoke(() => void invokeRef.current());
158
165
 
@@ -208,6 +215,7 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
208
215
  };
209
216
 
210
217
  const notifyActions = useEffectEvent(() => props.onActionsChange?.(actionFor));
218
+
211
219
  useEffect(() => {
212
220
  notifyActions();
213
221
  }, [state, projection]);
@@ -257,14 +265,17 @@ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
257
265
 
258
266
  if (source?.kind === "tail") {
259
267
  draft.current = body;
268
+
260
269
  return;
261
270
  }
262
271
  if (source?.kind === "mirror") {
263
272
  if (source.commentId) reply(source.commentId, body);
273
+
264
274
  return;
265
275
  }
266
276
  if (source?.kind === "artifact" && end?.kind === "artifact") {
267
277
  child?.onAnnotate(span, body);
278
+
268
279
  return;
269
280
  }
270
281
  if (
@@ -21,11 +21,13 @@ test("a long branch truncates on one line and keeps the send control in place",
21
21
  </box>,
22
22
  { width: 60, height: 3 },
23
23
  );
24
+
24
25
  await settle(setup);
25
26
  await settle(setup);
26
27
 
27
28
  const frame = setup.captureCharFrame();
28
29
  const footerRow = frame.split("\n").find((line) => line.includes("cueloop"))!;
30
+
29
31
  // the repo/branch context and the send control share the single footer row
30
32
  expect(footerRow).toContain("Send message (0)");
31
33
  // the branch is clipped to an ellipsis, not wrapped onto a second row
@@ -4,6 +4,7 @@ import { groupInbox, projectName } from "./thread-tree";
4
4
 
5
5
  function session(id: string, title: string, rootCommit?: string, remote?: string): Thread {
6
6
  const workspace: WorkspaceKey = { repoRoot: `/home/dev/${id}-checkout`, branch: "main" };
7
+
7
8
  if (rootCommit !== undefined) workspace.rootCommit = rootCommit;
8
9
  if (remote !== undefined) workspace.remote = remote;
9
10
 
@@ -61,6 +62,7 @@ describe("groupInbox", () => {
61
62
 
62
63
  // Assert
63
64
  const labels = rows.map((row) => `${row.kind}:${"label" in row ? row.label : row.id}`);
65
+
64
66
  expect(labels).toEqual([
65
67
  "section:Projects",
66
68
  "project:widget",
@@ -83,6 +85,7 @@ describe("groupInbox", () => {
83
85
 
84
86
  // Assert
85
87
  const projectRows = rows.filter((row) => row.kind === "project");
88
+
86
89
  expect(projectRows).toHaveLength(1);
87
90
  expect(ordered).toHaveLength(2);
88
91
  });
@@ -18,6 +18,7 @@ export function projectName(workspace: WorkspaceKey): string {
18
18
  .split(/[/:]/)
19
19
  .filter((part) => part.length > 0)
20
20
  .at(-1);
21
+
21
22
  if (base !== undefined) return base;
22
23
  }
23
24
 
@@ -66,12 +67,14 @@ export function groupInbox(
66
67
  }
67
68
 
68
69
  const key = session.workspace.rootCommit;
70
+
69
71
  if (key === undefined) {
70
72
  standalone.push(session);
71
73
  continue;
72
74
  }
73
75
 
74
76
  const existing = projects.get(key);
77
+
75
78
  if (existing !== undefined) existing.sessions.push(session);
76
79
  else projects.set(key, { name: projectName(session.workspace), sessions: [session] });
77
80
  }
@@ -89,6 +92,7 @@ export function groupInbox(
89
92
  }
90
93
 
91
94
  const projectEntries = [...projects.entries()].sort((a, b) => a[1].name.localeCompare(b[1].name));
95
+
92
96
  if (projectEntries.length > 0) {
93
97
  rows.push({ kind: "section", id: "section:projects", label: "Projects" });
94
98
  for (const [key, project] of projectEntries) {
@@ -450,6 +450,7 @@ describe("diff file fold", () => {
450
450
  { path: "src/gone.ts", oldContents: "old line\n", newContents: "", status: "deleted" },
451
451
  ];
452
452
  const session = diffSession(files);
453
+
453
454
  session.artifact.content = patch;
454
455
  session.revisions[0]!.content = patch;
455
456
  const { controller } = await connected(session);
@@ -134,6 +134,7 @@ describe("live working-tree diff for a non-diff thread", () => {
134
134
  openClient: async () => fakeClient(session),
135
135
  shareTransport,
136
136
  });
137
+
137
138
  controller.connect();
138
139
  await tick();
139
140
 
@@ -169,6 +170,7 @@ describe("live working-tree diff for a non-diff thread", () => {
169
170
  openClient: async () => client,
170
171
  shareTransport,
171
172
  });
173
+
172
174
  controller.connect();
173
175
  await tick();
174
176
  await controller.repoChanges();
@@ -213,11 +215,13 @@ describe("live working-tree diff for a non-diff thread", () => {
213
215
  openClient: async () => client,
214
216
  shareTransport,
215
217
  });
218
+
216
219
  controller.connect();
217
220
  await tick();
218
221
 
219
222
  // thread A's diff is still in flight when the user switches to thread B
220
223
  const inFlightA = controller.repoChanges();
224
+
221
225
  controller.open("ses_B");
222
226
  await controller.repoChanges();
223
227
  // B's diff is showing; now A's stale response lands
@@ -272,6 +276,7 @@ describe("frozen vs live diff by thread kind", () => {
272
276
  openClient: async () => fakeClient(session),
273
277
  shareTransport,
274
278
  });
279
+
275
280
  controller.connect();
276
281
  await tick();
277
282
 
@@ -294,6 +299,7 @@ describe("frozen vs live diff by thread kind", () => {
294
299
  openClient: async () => client,
295
300
  shareTransport,
296
301
  });
302
+
297
303
  controller.connect();
298
304
  await tick();
299
305
 
@@ -338,6 +344,7 @@ describe("serve mode pins the served thread to a frozen snapshot", () => {
338
344
  meta: { workbench: true, snapshot: true },
339
345
  },
340
346
  });
347
+
341
348
  controller.connect();
342
349
  await tick();
343
350
 
@@ -303,6 +303,7 @@ describe("reply", () => {
303
303
 
304
304
  // Act
305
305
  const id = controller.reply("a1", "agreed");
306
+
306
307
  await tick();
307
308
 
308
309
  // Assert
@@ -829,6 +829,7 @@ class Controller implements ReviewController {
829
829
 
830
830
  async projectFiles(): Promise<string[]> {
831
831
  const session = this.snapshot.session;
832
+
832
833
  if (this.client?.projectFiles === undefined || !session) return [];
833
834
 
834
835
  return this.client.projectFiles(session.id);
@@ -836,6 +837,7 @@ class Controller implements ReviewController {
836
837
 
837
838
  async readFile(path: string): Promise<string | null> {
838
839
  const session = this.snapshot.session;
840
+
839
841
  if (this.client?.fileContents === undefined || !session) return null;
840
842
 
841
843
  return this.client.fileContents(session.id, path);
@@ -861,6 +863,7 @@ class Controller implements ReviewController {
861
863
  async repoChanges(): Promise<readonly DiffFileContents[]> {
862
864
  // a plain diff review pins its captured snapshot; a workbench thread and every other thread reflect the live working tree
863
865
  const session = this.snapshot.session;
866
+
864
867
  if (readsFrozenDiff(session)) return session!.artifact.files ?? [];
865
868
  const initialFiles = this.consumeInitialDiffFiles(session);
866
869
 
@@ -871,6 +874,7 @@ class Controller implements ReviewController {
871
874
  this.sidebarRepoRoot(),
872
875
  session?.artifact.meta.workbench ? (session.artifact.meta.vcs ?? "git") : undefined,
873
876
  );
877
+
874
878
  // a thread switch during the request would let this response overwrite the new thread's diff,
875
879
  // showing changes from the wrong repo; compare the thread id, not the object, so an unrelated
876
880
  // re-render that replaced the snapshot for the SAME thread does not drop its diff to "No changes"
@@ -885,6 +889,7 @@ class Controller implements ReviewController {
885
889
  file.oldContents !== diff.files[index]?.oldContents ||
886
890
  file.newContents !== diff.files[index]?.newContents,
887
891
  );
892
+
888
893
  if (changed) {
889
894
  this.liveDiff = diff;
890
895
  // rows() derive from the fresh patch; re-render so an open diff tab repaints
@@ -1004,6 +1009,7 @@ class Controller implements ReviewController {
1004
1009
  content: "",
1005
1010
  meta: { title: "New Thread" },
1006
1011
  });
1012
+
1007
1013
  this.locallyViewed.clear();
1008
1014
  this.viewingId = thread.id;
1009
1015
  this.update({ session: thread });
@@ -1737,6 +1743,7 @@ class Controller implements ReviewController {
1737
1743
  const actionBodies = Object.fromEntries(
1738
1744
  slashItemsFrom(this.quickActions).map((item) => [item.name, item.body]),
1739
1745
  );
1746
+
1740
1747
  this.client!.sessionSendMessage(session.id, message, summary, actionBodies)
1741
1748
  .then((resolved) => {
1742
1749
  // The completion overlay heading already states the message, so the
@@ -1835,6 +1842,7 @@ class Controller implements ReviewController {
1835
1842
 
1836
1843
  if (!links.some((link) => link.id === id)) return;
1837
1844
  const allowlist = input.requireAuth ? input.allowlist : [];
1845
+
1838
1846
  // re-push the link's access to its blob: an allowlist for private, "public" clears it
1839
1847
  void this.shareTransport
1840
1848
  .push(id, [], input.requireAuth ? { githubLogins: allowlist } : "public")
@@ -38,8 +38,10 @@ test("late configuration and action responses cannot replace a newer accepted st
38
38
  agentComment: async () => state,
39
39
  agentPermission: async () => state,
40
40
  };
41
+
41
42
  function TestAgent(): React.ReactNode {
42
43
  const agent = useThreadAgent("test", undefined, client);
44
+
43
45
  useEffect(() => {
44
46
  request = () => agent.act((api) => api.agentPrompt({ id: "test", text: "Prompt" }));
45
47
  }, [agent]);
@@ -51,6 +53,7 @@ test("late configuration and action responses cannot replace a newer accepted st
51
53
  try {
52
54
  await waitForText(setup, "idle");
53
55
  const pending = request();
56
+
54
57
  await request();
55
58
  await waitForText(setup, "running");
56
59
  first!(initial);
@@ -63,3 +66,143 @@ test("late configuration and action responses cannot replace a newer accepted st
63
66
  setup.renderer.destroy();
64
67
  }
65
68
  });
69
+
70
+ test("production subscription shows daemon updates and reconnects without an injected client", async () => {
71
+ const { mkdtempSync, rmSync } = await import("node:fs");
72
+ const { tmpdir } = await import("node:os");
73
+ const { join } = await import("node:path");
74
+ const { DaemonServer } = await import("@cueloop/daemon");
75
+ const { DaemonClient } = await import("@cueloop/daemon/client");
76
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-ui-subscription-"));
77
+ const adapter = {
78
+ id: "ui-subscription",
79
+ label: "UI subscription",
80
+ connect({ onEvent }: Parameters<import("@cueloop/schema").AgentHarnessAdapter["connect"]>[0]) {
81
+ return {
82
+ start: async () => "ui-session",
83
+ prompt: async () => {
84
+ onEvent({ kind: "message" as const, id: "answer", text: "Updated answer" });
85
+
86
+ return { outcome: "completed" as const };
87
+ },
88
+ cancel() {},
89
+ permission() {},
90
+ close() {},
91
+ };
92
+ },
93
+ };
94
+ const options = { home, idleExitMs: 0, threadAgent: { enabled: true, adapter } };
95
+ let server = new DaemonServer(options);
96
+
97
+ server.start();
98
+ const client = await DaemonClient.connect({ home });
99
+ const thread = await client.sessionCreate(
100
+ { repoRoot: home, branch: "main" },
101
+ { type: "plan", content: "Watch this", meta: {} },
102
+ );
103
+
104
+ function TestAgentSubscription(): React.ReactNode {
105
+ const agent = useThreadAgent(thread.id, home);
106
+
107
+ return (
108
+ <text>
109
+ {agent.client ? "Connected " : "Connecting "}
110
+ {agent.state.messages.map((message) => message.text).join(" ")}
111
+ {agent.error}
112
+ </text>
113
+ );
114
+ }
115
+ const setup = await testRender(<TestAgentSubscription />, { width: 80, height: 4 });
116
+
117
+ try {
118
+ await waitForText(setup, "Connected");
119
+ await client.agentPrompt({ id: thread.id, text: "Question" });
120
+ await waitForText(setup, "Updated answer");
121
+ server.stop();
122
+ server = new DaemonServer(options);
123
+ server.start();
124
+ const resumed = await DaemonClient.connect({ home });
125
+
126
+ try {
127
+ await resumed.agentPrompt({ id: thread.id, text: "Continuation" });
128
+ } finally {
129
+ resumed.close();
130
+ }
131
+ await waitForText(setup, "Continuation");
132
+ expect(setup.captureCharFrame()).toContain("Updated answer");
133
+ } finally {
134
+ setup.renderer.destroy();
135
+ client.close();
136
+ server.stop();
137
+ rmSync(home, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ test("reconnecting during a running turn preserves state without a configuration error", async () => {
142
+ const { mkdtempSync, rmSync } = await import("node:fs");
143
+ const { tmpdir } = await import("node:os");
144
+ const { join } = await import("node:path");
145
+ const { DaemonServer } = await import("@cueloop/daemon");
146
+ const { DaemonClient } = await import("@cueloop/daemon/client");
147
+ const { createTestSdkHarness } = await import("../../../../test/helpers/sdk-harness");
148
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-ui-running-"));
149
+ const harness = createTestSdkHarness();
150
+ const server = new DaemonServer({
151
+ home,
152
+ idleExitMs: 0,
153
+ threadAgent: { enabled: true, adapter: harness.adapter },
154
+ });
155
+
156
+ server.start();
157
+ const client = await DaemonClient.connect({ home });
158
+ const thread = await client.sessionCreate(
159
+ { repoRoot: home, branch: "main" },
160
+ { type: "plan", content: "Watch this", meta: {} },
161
+ );
162
+ let connection: InstanceType<typeof DaemonClient> | undefined;
163
+ let connections = 0;
164
+
165
+ function TestRunningSubscription(): React.ReactNode {
166
+ const agent = useThreadAgent(thread.id, home);
167
+
168
+ useEffect(() => {
169
+ if (agent.client instanceof DaemonClient && agent.client !== connection) {
170
+ connection = agent.client;
171
+ connections++;
172
+ }
173
+ }, [agent.client]);
174
+
175
+ return (
176
+ <text>
177
+ {connections} {agent.state.phase.kind} {agent.error || "No error"}
178
+ </text>
179
+ );
180
+ }
181
+ const setup = await testRender(<TestRunningSubscription />, { width: 100, height: 4 });
182
+
183
+ try {
184
+ await waitForText(setup, "idle");
185
+ await client.agentPrompt({ id: thread.id, text: "Question" });
186
+ await waitForText(setup, "running");
187
+ expect((await client.agentConfigure({ id: thread.id })).phase.kind).toBe("running");
188
+ const rejected = await client
189
+ .agentConfigure({ id: thread.id, configId: "model", value: "other" })
190
+ .catch((error: Error) => error);
191
+
192
+ expect(rejected).toMatchObject({
193
+ code: "internal",
194
+ message: "Thread agent configuration waits for the current turn",
195
+ });
196
+ connection!.close();
197
+ await waitForText(setup, "2 running");
198
+ harness.complete();
199
+ await waitForText(setup, "2 idle");
200
+ expect(setup.captureCharFrame()).toContain("No error");
201
+ expect(setup.captureCharFrame()).not.toContain("configuration waits");
202
+ } finally {
203
+ setup.renderer.destroy();
204
+ client.close();
205
+ server.stop();
206
+ rmSync(home, { recursive: true, force: true });
207
+ }
208
+ });