@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.
Files changed (32) hide show
  1. package/package.json +5 -5
  2. package/src/annotations/components/AnnotationCards.test.tsx +42 -0
  3. package/src/annotations/components/AnnotationCards.tsx +29 -6
  4. package/src/annotations/use-annotation-surface.tsx +245 -30
  5. package/src/app/App.thread-agent.test.ts +12 -0
  6. package/src/app/App.tsx +217 -97
  7. package/src/app/app-screens.tsx +2 -0
  8. package/src/app/components/AppShell.zoom.test.tsx +4 -3
  9. package/src/app/intent-dispatch.test.ts +1 -0
  10. package/src/diff/components/AnnotatableFileView.tsx +6 -0
  11. package/src/diff/components/DiffContentView.tsx +9 -0
  12. package/src/markdown/components/ThreadView.tsx +60 -16
  13. package/src/settings/config.test.ts +15 -0
  14. package/src/settings/config.ts +14 -4
  15. package/src/stories/__snapshots__/stories.test.tsx.snap +74 -0
  16. package/src/thread/agent-continuation.test.ts +32 -0
  17. package/src/thread/agent-continuation.ts +13 -0
  18. package/src/thread/agent-transcript.test.ts +161 -0
  19. package/src/thread/agent-transcript.ts +332 -0
  20. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  21. package/src/thread/components/AgentConfigControls.test.tsx +110 -0
  22. package/src/thread/components/AgentConfigControls.tsx +114 -0
  23. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  24. package/src/thread/components/AgentThreadPane.test.tsx +780 -0
  25. package/src/thread/components/AgentThreadPane.tsx +356 -0
  26. package/src/thread/components/ThreadFooter.tsx +3 -0
  27. package/src/thread/components/ThreadTree.test.tsx +41 -0
  28. package/src/thread/components/ThreadTree.tsx +25 -5
  29. package/src/thread/thread-controller.ts +34 -0
  30. package/src/thread/use-thread-agent.test.tsx +65 -0
  31. package/src/thread/use-thread-agent.ts +101 -0
  32. package/src/workbench/components/GridTabContent.tsx +6 -0
@@ -0,0 +1,356 @@
1
+ import React, { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
2
+ import {
3
+ newAnnotationId,
4
+ agentCommentRoot,
5
+ type Thread,
6
+ type ThreadAgentState,
7
+ } from "@cueloop/schema";
8
+ import type { Theme } from "../../appearance/theme";
9
+ import { ThreadView, type ThreadViewProps } from "../../markdown/components/ThreadView";
10
+ import { buildDisplay, marksByDisplay } from "../../markdown/view-plan";
11
+ import { projectThreadConversation, commentOnAgentSpan } from "../agent-transcript";
12
+ import { useThreadAgent, type ThreadAgentClient } from "../use-thread-agent";
13
+ import { agentContinuation } from "../agent-continuation";
14
+ import { AgentConfigControls } from "./AgentConfigControls";
15
+
16
+ /** The opt-in prototype extends the existing Thread body and retains its surrounding chrome. */
17
+ export interface AgentThreadPaneProps {
18
+ thread: Thread;
19
+ home?: string;
20
+ theme: Theme;
21
+ children: React.ReactNode;
22
+ focused: boolean;
23
+ suspended?: boolean;
24
+ onActiveChange: (active: boolean) => void;
25
+ onOpenFile: (path: string) => void;
26
+ onNextPane?: (backward: boolean) => void;
27
+ onControlsChange?: (controls: React.ReactNode) => void;
28
+ onActionsChange?: (
29
+ action: (id: string) => { label: string; run: () => void } | undefined,
30
+ ) => void;
31
+ onRevealReply?: () => void;
32
+ onInvokeChange?: (invoke: (() => void) | undefined) => void;
33
+ onStateChange?: (state: ThreadAgentState) => void;
34
+ flushMutations?: () => Promise<void>;
35
+ client?: ThreadAgentClient;
36
+ }
37
+
38
+ /** Inline input and the final blank line invoke the harness without replacing Send message. */
39
+ export function AgentThreadPane(props: AgentThreadPaneProps): React.ReactNode {
40
+ const { thread, theme } = props;
41
+ const agent = useThreadAgent(thread.id, props.home, props.client);
42
+ const child =
43
+ React.isValidElement<ThreadViewProps>(props.children) && props.children.type === ThreadView
44
+ ? props.children.props
45
+ : undefined;
46
+ const baseDisplay =
47
+ child?.display ??
48
+ buildDisplay(
49
+ thread.artifact.type === "diff"
50
+ ? (thread.artifact.meta.prBrief ?? "")
51
+ : (thread.workingCopy ?? thread.artifact.content),
52
+ );
53
+ const baseMarks = child?.marks ?? marksByDisplay(thread.annotations, baseDisplay);
54
+ const projection = useMemo(
55
+ () =>
56
+ projectThreadConversation(
57
+ thread,
58
+ agent.state,
59
+ baseDisplay,
60
+ baseMarks,
61
+ agent.state.phase.kind !== "idle" || Boolean(agent.error),
62
+ ),
63
+ [thread, agent.state, agent.error, baseDisplay, baseMarks],
64
+ );
65
+ const [requestedBlock, setRequestedBlock] = useState<{ blockIndex: number }>();
66
+ const draft = useRef("");
67
+ const [promptRestoreRequest, setPromptRestoreRequest] =
68
+ useState<ThreadViewProps["promptRestoreRequest"]>();
69
+ const restoreSequence = useRef(0);
70
+ const invoking = useRef(false);
71
+ const invocations = useRef<{ text: string; writes: Promise<boolean>[] }[]>([]);
72
+ const pendingWrites = useRef<Promise<boolean>[]>([]);
73
+ const state = agent.state;
74
+ const continuation = agentContinuation(state);
75
+ const busy = state.phase.kind === "running" || state.phase.kind === "permission";
76
+
77
+ const [pulse, setPulse] = useState(false);
78
+ useEffect(() => {
79
+ if (!busy) return;
80
+ const timer = setInterval(() => setPulse((value) => !value), 600);
81
+
82
+ return () => clearInterval(timer);
83
+ }, [busy]);
84
+ const notifyActive = useEffectEvent((active: boolean) => props.onActiveChange(active));
85
+ const notifyState = useEffectEvent(() => props.onStateChange?.(state));
86
+ const notifyControls = useEffectEvent(() =>
87
+ props.onControlsChange?.(
88
+ <AgentConfigControls
89
+ state={state}
90
+ theme={theme}
91
+ onConfigure={(configId, value) =>
92
+ void agent.act((client) => {
93
+ if (!client.agentConfigure)
94
+ return Promise.reject(new Error("Agent configuration is unavailable"));
95
+
96
+ return client.agentConfigure({ id: thread.id, configId, value });
97
+ })
98
+ }
99
+ />,
100
+ ),
101
+ );
102
+ useEffect(() => {
103
+ notifyActive(true);
104
+ return () => notifyActive(false);
105
+ }, []);
106
+ useEffect(() => {
107
+ notifyState();
108
+ }, [state]);
109
+ useEffect(() => {
110
+ notifyControls();
111
+ }, [state, theme]);
112
+
113
+ const invoke = async (): Promise<void> => {
114
+ if (thread.status !== "pending") return;
115
+ invocations.current.push({ text: draft.current, writes: pendingWrites.current.splice(0) });
116
+ draft.current = "";
117
+ if (invoking.current) return;
118
+ invoking.current = true;
119
+
120
+ const rejected: string[] = [];
121
+
122
+ try {
123
+ while (invocations.current.length) {
124
+ const input = invocations.current.shift()!;
125
+ try {
126
+ const writesSaved = (await Promise.all(input.writes)).every(Boolean);
127
+ if (!writesSaved) {
128
+ rejected.push(input.text);
129
+ continue;
130
+ }
131
+ await props.flushMutations?.();
132
+ const accepted = await agent.act((client) =>
133
+ client.agentPrompt({ id: thread.id, text: input.text }),
134
+ );
135
+
136
+ if (!accepted) rejected.push(input.text);
137
+ } catch (error) {
138
+ rejected.push(input.text);
139
+ agent.setError(String(error));
140
+ }
141
+ }
142
+ } finally {
143
+ invoking.current = false;
144
+ const text = rejected.filter(Boolean).join("\n");
145
+
146
+ if (text) setPromptRestoreRequest({ id: ++restoreSequence.current, text });
147
+ }
148
+ };
149
+ const invokeRef = useRef<() => Promise<void>>(async () => {});
150
+ useEffect(() => {
151
+ invokeRef.current = invoke;
152
+ });
153
+ const notifyInvoke = useEffectEvent((invoke: (() => void) | undefined) =>
154
+ props.onInvokeChange?.(invoke),
155
+ );
156
+ useEffect(() => {
157
+ notifyInvoke(() => void invokeRef.current());
158
+
159
+ return () => notifyInvoke(undefined);
160
+ }, []);
161
+ const reply = (id: string, body: string): void => {
162
+ const origin = projection.mirrors.get(id)?.commentId ?? id;
163
+ const root = agentCommentRoot(state, origin);
164
+
165
+ if (root)
166
+ pendingWrites.current.push(
167
+ agent.act((client) =>
168
+ client.agentComment({
169
+ id: thread.id,
170
+ comment: {
171
+ ...root,
172
+ id: newAnnotationId(),
173
+ body,
174
+ sent: false,
175
+ author: undefined,
176
+ replyTo: root.replyTo ?? root.id,
177
+ },
178
+ }),
179
+ ),
180
+ );
181
+ else child?.onReply(origin, body);
182
+ };
183
+ const actionFor = (id: string) => {
184
+ const mirror = projection.mirrors.get(id);
185
+ const submission = state.submissions?.find(
186
+ (entry) => entry.id === mirror?.submissionId || entry.commentId === id,
187
+ );
188
+
189
+ if (!submission) return undefined;
190
+ if (submission.status === "failed")
191
+ return {
192
+ label: "Retry",
193
+ run: () =>
194
+ void agent.act((client) =>
195
+ client.agentPrompt({ id: thread.id, text: "", retry: submission.id }),
196
+ ),
197
+ };
198
+
199
+ return {
200
+ label: "View reply",
201
+ run: () => {
202
+ props.onRevealReply?.();
203
+ setRequestedBlock({
204
+ blockIndex: projection.destinations.get(submission.id) ?? projection.tailIndex,
205
+ });
206
+ },
207
+ };
208
+ };
209
+
210
+ const notifyActions = useEffectEvent(() => props.onActionsChange?.(actionFor));
211
+ useEffect(() => {
212
+ notifyActions();
213
+ }, [state, projection]);
214
+
215
+ return (
216
+ <ThreadView
217
+ {...child}
218
+ session={projection.session}
219
+ display={projection.display}
220
+ marks={projection.marks}
221
+ quickActions={child?.quickActions ?? []}
222
+ observer={false}
223
+ resolved={child?.resolved ?? thread.status !== "pending"}
224
+ suspended={!props.focused || props.suspended}
225
+ theme={theme}
226
+ requestedBlock={requestedBlock}
227
+ isAnnotationReadOnly={(id) =>
228
+ projection.mirrors.has(id) ||
229
+ Boolean(state.submissions?.some((entry) => entry.commentId === id)) ||
230
+ Boolean(state.comments.find((entry) => entry.id === id)?.sent)
231
+ }
232
+ annotationAction={actionFor}
233
+ onInvoke={() => void invoke()}
234
+ isPromptBlock={(index) => index === projection.tailIndex}
235
+ promptRestoreRequest={promptRestoreRequest}
236
+ promptFocusRequest={
237
+ continuation.kind === "ready"
238
+ ? { replyId: continuation.replyId, blockIndex: projection.tailIndex }
239
+ : undefined
240
+ }
241
+ canAnnotateBlock={(index) => {
242
+ const source = projection.sources[index];
243
+
244
+ return (
245
+ source?.kind !== "activity" &&
246
+ (source?.kind !== "message" ||
247
+ Boolean(
248
+ state.messages.find(
249
+ (entry) => entry.id === source.messageId && entry.role === "agent",
250
+ )?.complete,
251
+ ))
252
+ );
253
+ }}
254
+ onAnnotate={(span, body) => {
255
+ const source = projection.sources[span.start.blockIndex];
256
+ const end = projection.sources[span.end.blockIndex];
257
+
258
+ if (source?.kind === "tail") {
259
+ draft.current = body;
260
+ return;
261
+ }
262
+ if (source?.kind === "mirror") {
263
+ if (source.commentId) reply(source.commentId, body);
264
+ return;
265
+ }
266
+ if (source?.kind === "artifact" && end?.kind === "artifact") {
267
+ child?.onAnnotate(span, body);
268
+ return;
269
+ }
270
+ if (
271
+ source?.kind !== "message" ||
272
+ end?.kind !== "message" ||
273
+ source.messageId !== end.messageId
274
+ )
275
+ return;
276
+ const message = state.messages.find((entry) => entry.id === source.messageId)!;
277
+ const localState = { ...state, messages: [message] };
278
+ const comment = commentOnAgentSpan(
279
+ thread,
280
+ localState,
281
+ {
282
+ start: { ...span.start, blockIndex: source.blockIndex + 1 },
283
+ end: { ...span.end, blockIndex: end.blockIndex + 1 },
284
+ },
285
+ body,
286
+ newAnnotationId(),
287
+ );
288
+
289
+ pendingWrites.current.push(
290
+ agent.act((client) => client.agentComment({ id: thread.id, comment })),
291
+ );
292
+ }}
293
+ onReply={reply}
294
+ onUpdateAnnotation={(id, body) => {
295
+ const comment = state.comments.find((entry) => entry.id === id);
296
+
297
+ if (comment)
298
+ pendingWrites.current.push(
299
+ agent.act((client) =>
300
+ client.agentComment({ id: thread.id, comment: { ...comment, body } }),
301
+ ),
302
+ );
303
+ else child?.onUpdateAnnotation(id, body);
304
+ }}
305
+ onExit={child?.onExit ?? (() => {})}
306
+ renderBlock={(index) =>
307
+ index === projection.activityIndex ? (
308
+ <box style={{ flexDirection: "column", paddingLeft: 2 }}>
309
+ {busy ? <text fg={pulse ? theme.textDim : theme.textMuted}>Thinking…</text> : null}
310
+ {state.phase.kind === "permission" ? (
311
+ <>
312
+ <text fg={theme.text}>{state.phase.permission.title}</text>
313
+ <box style={{ flexDirection: "row", gap: 2 }}>
314
+ {state.phase.permission.options.map((option) => (
315
+ <text
316
+ key={option.optionId}
317
+ fg={theme.blue}
318
+ onMouseUp={() => {
319
+ if (state.phase.kind === "permission")
320
+ void agent.act((client) =>
321
+ client.agentPermission({
322
+ id: thread.id,
323
+ requestId:
324
+ state.phase.kind === "permission" ? state.phase.permission.id : "",
325
+ optionId: option.optionId,
326
+ }),
327
+ );
328
+ }}
329
+ >
330
+ {option.name}
331
+ </text>
332
+ ))}
333
+ </box>
334
+ </>
335
+ ) : null}
336
+ {agent.error ? <text fg={theme.red}>{agent.error}</text> : null}
337
+ </box>
338
+ ) : undefined
339
+ }
340
+ />
341
+ );
342
+ }
343
+
344
+ /** The prototype is local and opt-in; shared views keep their existing renderer. */
345
+ export function AgentThreadPrototype(
346
+ props: AgentThreadPaneProps & { enabled: boolean; observer: boolean; pixelPrototype: boolean },
347
+ ): React.ReactNode {
348
+ if (!props.enabled || props.observer || props.pixelPrototype) return props.children;
349
+
350
+ return <AgentThreadPane {...props} />;
351
+ }
352
+
353
+ /** Embedded-agent chords apply only while the Thread owns focus. */
354
+ export function agentOwnsKeyboard(active: boolean, focusedPane: string): boolean {
355
+ return active && focusedPane === "thread";
356
+ }
@@ -10,6 +10,7 @@ import { truncateTitle } from "../../ui/components/truncate-title";
10
10
  import { NERD } from "../../ui/components/primitives/icons";
11
11
 
12
12
  export interface ThreadFooterProps {
13
+ controls?: React.ReactNode;
13
14
  repo: string;
14
15
  branch: string;
15
16
  onSubmit?: () => void;
@@ -27,6 +28,7 @@ const SEPARATOR = " / ";
27
28
  export const THREAD_FOOTER_HEIGHT = 2;
28
29
 
29
30
  export function ThreadFooter({
31
+ controls,
30
32
  repo,
31
33
  branch,
32
34
  onSubmit,
@@ -66,6 +68,7 @@ export function ThreadFooter({
66
68
  </text>
67
69
  </box>
68
70
  <box style={{ flexGrow: 1 }} />
71
+ {controls}
69
72
  <box onMouseUp={canSubmit ? onSubmit : undefined} style={{ flexShrink: 0 }}>
70
73
  <text fg={canSubmit ? tokens.accent : tokens.textDim}>
71
74
  {`Send message (${pendingAnnotations})`}
@@ -0,0 +1,41 @@
1
+ import { expect, test } from "bun:test";
2
+ import React from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { ThreadTree } from "./ThreadTree";
5
+ import { TooltipProvider } from "../../ui/components/Tooltip";
6
+ import { locateText, waitForText } from "../../testing/test-support";
7
+
8
+ test("New Thread button sits beside Threads and exposes its hover tooltip", async () => {
9
+ let created = 0;
10
+ const setup = await testRender(
11
+ <TooltipProvider>
12
+ <ThreadTree rows={[]} cursor={0} onCreateThread={() => created++} />
13
+ </TooltipProvider>,
14
+ { width: 30, height: 8 },
15
+ );
16
+
17
+ try {
18
+ await waitForText(setup, "Threads");
19
+ const button = locateText(setup, "+");
20
+ const heading = locateText(setup, "Threads");
21
+
22
+ expect(button.row).toBe(heading.row);
23
+ await setup.mockMouse.moveTo(button.column, button.row);
24
+ await waitForText(setup, "New Thread");
25
+ await setup.mockMouse.click(button.column, button.row);
26
+ expect(created).toBe(1);
27
+ } finally {
28
+ setup.renderer.destroy();
29
+ }
30
+ });
31
+
32
+ test("ordinary sidebar has no New Thread button", async () => {
33
+ const setup = await testRender(<ThreadTree rows={[]} cursor={0} />, { width: 30, height: 8 });
34
+
35
+ try {
36
+ await waitForText(setup, "no threads");
37
+ expect(setup.captureCharFrame()).not.toContain("+");
38
+ } finally {
39
+ setup.renderer.destroy();
40
+ }
41
+ });
@@ -37,6 +37,8 @@ export interface ThreadTreeProps {
37
37
  onPin?: (id: string) => void;
38
38
  /** Rename a thread's title (the menu's Rename). */
39
39
  onRename?: (id: string, title: string) => void;
40
+ /** Create an empty Thread; omitting it retains the ordinary sidebar. */
41
+ onCreateThread?: () => void;
40
42
  theme?: Theme;
41
43
  }
42
44
 
@@ -192,6 +194,7 @@ export function ThreadTree({
192
194
  onRequestDelete,
193
195
  onPin,
194
196
  onRename,
197
+ onCreateThread,
195
198
  theme,
196
199
  }: ThreadTreeProps): React.ReactNode {
197
200
  const tokens = useComponentTheme(theme);
@@ -201,17 +204,34 @@ export function ThreadTree({
201
204
  const titleWidth = Math.max(8, width - 9);
202
205
  const closeMenu = (): void => menuControl.closeMenu();
203
206
 
207
+ const visibleRows =
208
+ onCreateThread && !rows.some((row) => row.id === "section:threads")
209
+ ? [...rows, { kind: "section" as const, id: "section:threads", label: "Threads" }]
210
+ : rows;
211
+
204
212
  return (
205
213
  <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1 }}>
206
- {rows.length === 0 ? (
214
+ {visibleRows.length === 0 ? (
207
215
  <text fg={tokens.textDim}>no threads</text>
208
216
  ) : (
209
- rows.map((row, index) => {
217
+ visibleRows.map((row, index) => {
210
218
  if (row.kind === "section") {
211
219
  return (
212
- <text key={row.id} fg={tokens.textDim} style={{ marginTop: index > 0 ? 1 : 0 }}>
213
- {row.label}
214
- </text>
220
+ <box key={row.id} style={{ flexDirection: "row", marginTop: index > 0 ? 1 : 0 }}>
221
+ <text fg={tokens.textDim}>{row.label}</text>
222
+ {row.id === "section:threads" && onCreateThread ? (
223
+ <>
224
+ <box style={{ flexGrow: 1 }} />
225
+ <IconButton
226
+ glyph="+"
227
+ tip="New Thread"
228
+ onPress={onCreateThread}
229
+ marginRight={1}
230
+ theme={theme}
231
+ />
232
+ </>
233
+ ) : null}
234
+ </box>
215
235
  );
216
236
  }
217
237
 
@@ -324,6 +324,8 @@ export interface ReviewController {
324
324
  refreshDiff(): Promise<void>;
325
325
  /** Open a session from the inbox. */
326
326
  open(id: string): void;
327
+ /** Create and open an empty local Thread for a new conversation. */
328
+ createEmptyThread(): Promise<void>;
327
329
  /** Delete a session for good (inbox delete); the inbox refreshes on the event. */
328
330
  deleteSession(id: string): void;
329
331
  /** Rename a session's title; the inbox refreshes on the event. */
@@ -386,6 +388,7 @@ export interface ReviewController {
386
388
  annotatePrototype(selector: string, quote: string, body: string): string | undefined;
387
389
  /** Rewrite a stored annotation's body in place (the rail-card edit). */
388
390
  updateAnnotation(id: string, body: string): void;
391
+ flushMutations?(): Promise<void>;
389
392
  removeAnnotation(id: string): void;
390
393
  setWorkingCopy(content: string | undefined): void;
391
394
  /** Enter the guided walk at the first unviewed file (diff sessions). */
@@ -472,6 +475,7 @@ const DERIVED_CACHE_LIMIT = 8;
472
475
  class Controller implements ReviewController {
473
476
  readonly readOnly: boolean;
474
477
  private client: ThreadClient | null = null;
478
+ private pendingMutations = new Set<Promise<Thread>>();
475
479
  private closed = false;
476
480
  /** The controller's state lives in a zustand store; internal reads go through the snapshot getter. */
477
481
  private readonly store: StoreApi<ControllerSnapshot> = createStore<ControllerSnapshot>(() => ({
@@ -956,7 +960,14 @@ class Controller implements ReviewController {
956
960
  * builds on the first instead of on the stale snapshot. Answers arrive in
957
961
  * request order over the socket and replace the guess with the daemon's copy.
958
962
  */
963
+ /** Agent invocation waits until the preceding comments reach the daemon. */
964
+ async flushMutations(): Promise<void> {
965
+ await Promise.all(this.pendingMutations);
966
+ }
967
+
959
968
  private applyOptimistic(expected: Thread, mutation: Promise<Thread>): void {
969
+ this.pendingMutations.add(mutation);
970
+ void mutation.finally(() => this.pendingMutations.delete(mutation)).catch(() => {});
960
971
  this.update({ session: expected });
961
972
  mutation
962
973
  .then((session) => this.update({ session }))
@@ -980,6 +991,29 @@ class Controller implements ReviewController {
980
991
  void this.refreshSession(id);
981
992
  }
982
993
 
994
+ async createEmptyThread(): Promise<void> {
995
+ if (this.readOnly || !this.client?.sessionCreate) return;
996
+ const workspace = this.snapshot.session?.workspace ?? {
997
+ repoRoot: this.options.cwd ?? process.cwd(),
998
+ branch: "detached",
999
+ };
1000
+
1001
+ try {
1002
+ const thread = await this.client.sessionCreate(workspace, {
1003
+ type: "plan",
1004
+ content: "",
1005
+ meta: { title: "New Thread" },
1006
+ });
1007
+ this.locallyViewed.clear();
1008
+ this.viewingId = thread.id;
1009
+ this.update({ session: thread });
1010
+ } catch (cause) {
1011
+ this.setStatus(
1012
+ `New Thread failed: ${cause instanceof Error ? cause.message : String(cause)}`,
1013
+ );
1014
+ }
1015
+ }
1016
+
983
1017
  deleteSession(id: string): void {
984
1018
  if (this.readOnly) return this.setStatus("observer - read-only");
985
1019
  // a deleted thread's links must not outlive it; revoke every one first, best-effort, so an
@@ -0,0 +1,65 @@
1
+ import React, { useEffect } from "react";
2
+ import { expect, test } from "bun:test";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import type { ThreadAgentState } from "@cueloop/schema";
5
+ import { useThreadAgent, type ThreadAgentClient } from "./use-thread-agent";
6
+ import { waitForText, settle } from "../testing/test-support";
7
+
8
+ const initial: ThreadAgentState = {
9
+ threadId: "test",
10
+ phase: { kind: "idle" },
11
+ messages: [],
12
+ tools: [],
13
+ comments: [],
14
+ };
15
+
16
+ test("late configuration and action responses cannot replace a newer accepted state", async () => {
17
+ let state = initial;
18
+ let configure: ((value: ThreadAgentState) => void) | undefined;
19
+ let first: ((value: ThreadAgentState) => void) | undefined;
20
+ let request = async () => false;
21
+ let calls = 0;
22
+ const client: ThreadAgentClient = {
23
+ agentGet: async () => state,
24
+ agentConfigure: async () =>
25
+ new Promise((resolve) => {
26
+ configure = resolve;
27
+ }),
28
+ agentPrompt: async () => {
29
+ if (++calls === 1)
30
+ return new Promise((resolve) => {
31
+ first = resolve;
32
+ });
33
+ state = { ...initial, phase: { kind: "running" } };
34
+
35
+ return state;
36
+ },
37
+ agentCancel: async () => state,
38
+ agentComment: async () => state,
39
+ agentPermission: async () => state,
40
+ };
41
+ function TestAgent(): React.ReactNode {
42
+ const agent = useThreadAgent("test", undefined, client);
43
+ useEffect(() => {
44
+ request = () => agent.act((api) => api.agentPrompt({ id: "test", text: "Prompt" }));
45
+ }, [agent]);
46
+
47
+ return <text>{agent.state.phase.kind}</text>;
48
+ }
49
+ const setup = await testRender(<TestAgent />, { width: 40, height: 4 });
50
+
51
+ try {
52
+ await waitForText(setup, "idle");
53
+ const pending = request();
54
+ await request();
55
+ await waitForText(setup, "running");
56
+ first!(initial);
57
+ await pending;
58
+ configure!(initial);
59
+ await settle(setup);
60
+ expect(setup.captureCharFrame()).toContain("running");
61
+ expect(setup.captureCharFrame()).not.toContain("idle");
62
+ } finally {
63
+ setup.renderer.destroy();
64
+ }
65
+ });