Skip to content
Source
Sign inSign up

threadActivityAnnouncement.test.ts

221 lines7.1 KB

1import {
2 MessageId,
3 type OrchestrationV2Run,
4 ProviderInstanceId,
5 RunId,
6 ThreadId,
7} from "@t3tools/contracts";
8import * as DateTime from "effect/DateTime";
9import { describe, expect, it } from "vite-plus/test";
10​
11import {
12 announcedRun,
13 nextThreadAnnouncement,
14 type ThreadActivitySnapshot,
15 type ThreadAnnouncementMemory,
16} from "./threadActivityAnnouncement.ts";
17​
18const firstRun = RunId.make("run-1");
19const secondRun = RunId.make("run-2");
20​
21const idle = (
22 runId: RunId | null,
23 runStatus: ThreadActivitySnapshot["runStatus"],
24): ThreadActivitySnapshot => ({ status: "idle", requestId: null, runId, runStatus });
25const busy = (
26 status: Exclude<ThreadActivitySnapshot["status"], "idle"> = "working",
27 runId: RunId | null = secondRun,
28 runStatus: ThreadActivitySnapshot["runStatus"] = "running",
29 requestId: string | null = status === "working" ? null : "request-1",
30): ThreadActivitySnapshot => ({ status, requestId, runId, runStatus });
31​
32function speak(snapshots: ReadonlyArray<ThreadActivitySnapshot>) {
33 let memory: ThreadAnnouncementMemory | null = null;
34 const spoken: string[] = [];
35 for (const snapshot of snapshots) {
36 const result = nextThreadAnnouncement(memory, snapshot);
37 memory = result.memory;
38 if (result.announcement !== null) spoken.push(result.announcement);
39 }
40 return spoken;
42​
43describe("nextThreadAnnouncement", () => {
44 it("follows a turn from start to finish", () => {
45 expect(
46 speak([idle(firstRun, "completed"), busy(), busy(), idle(secondRun, "completed")]),
47 ).toEqual(["Working", "Done"]);
48 });
49​
50 it("says Working once when a send looks idle before its run appears", () => {
51 expect(
52 speak([idle(null, null), busy(), idle(null, null), busy(), idle(secondRun, "completed")]),
53 ).toEqual(["Working", "Done"]);
54 });
55​
56 it("announces a queued message that starts as the last turn ends", () => {
57 const thirdRun = RunId.make("run-3");
58 expect(
59 speak([
60 idle(firstRun, "completed"),
61 busy("working", firstRun, "completed"),
62 busy(),
63 busy("working", secondRun, "completed"),
64 busy("working", thirdRun, "starting"),
65 idle(thirdRun, "completed"),
66 ]),
67 ).toEqual(["Working", "Working", "Done"]);
68 // The last turn's run is still settling when the follow-up is sent.
69 expect(
70 speak([
71 idle(firstRun, "running"),
72 busy("working", firstRun, "running"),
73 busy("working", secondRun, "starting"),
74 ]),
75 ).toEqual(["Working"]);
76 expect(
77 speak([
78 idle(firstRun, "running"),
79 busy("working", firstRun, "running"),
80 idle(firstRun, "completed"),
81 busy("working", secondRun, "starting"),
82 idle(secondRun, "completed"),
83 ]),
84 ).toEqual(["Working", "Done"]);
85 expect(
86 speak([
87 busy("working", firstRun, "running"),
88 idle(firstRun, "running"),
89 busy("working", firstRun, "running"),
90 busy("working", secondRun, "starting"),
91 ]),
92 ).toEqual(["Working"]);
93 });
94​
95 it("treats a run that starts while the thread looks idle as the next turn", () => {
96 const thirdRun = RunId.make("run-3");
97 // A resume or a turn started from another device.
98 expect(
99 speak([
100 idle(firstRun, "completed"),
101 idle(secondRun, "starting"),
102 busy("working", secondRun, "running"),
103 idle(secondRun, "completed"),
104 ]),
105 ).toEqual(["Working", "Done"]);
106 // A queued message waiting to start after the last turn.
107 expect(
108 speak([
109 idle(firstRun, "completed"),
110 busy(),
111 idle(thirdRun, "queued"),
112 idle(thirdRun, "starting"),
113 busy("working", thirdRun, "running"),
114 idle(thirdRun, "completed"),
115 ]),
116 ).toEqual(["Working", "Working", "Done"]);
117 });
118​
119 it("stays silent when a thread opens mid-turn or after one", () => {
120 expect(speak([idle(firstRun, "completed"), idle(firstRun, "completed")])).toEqual([]);
121 expect(speak([busy(), busy()])).toEqual([]);
122 });
123​
124 it("announces the end of a turn that was already running when the thread opened", () => {
125 expect(speak([busy(), idle(secondRun, "failed")])).toEqual(["Failed"]);
126 });
127​
128 it("says how the turn ended when a queued message was cancelled before it started", () => {
129 const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
130 const run = (id: RunId, ordinal: number, status: OrchestrationV2Run["status"]) =>
131 ({
132 id,
133 threadId: ThreadId.make("thread-1"),
134 ordinal,
135 providerInstanceId: ProviderInstanceId.make("codex"),
136 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
137 providerThreadId: null,
138 userMessageId: MessageId.make(`message-${ordinal}`),
139 rootNodeId: null,
140 activeAttemptId: null,
141 status,
142 requestedAt: now,
143 startedAt: status === "cancelled" ? null : now,
144 completedAt: now,
145 checkpointId: null,
146 contextHandoffId: null,
147 }) satisfies OrchestrationV2Run;
148 const cancelledRun = RunId.make("run-3");
149 const ended = announcedRun({ runId: cancelledRun, status: "cancelled", startedAt: null }, [
150 run(secondRun, 1, "completed"),
151 run(cancelledRun, 2, "cancelled"),
152 ]);
153 expect(speak([busy(), idle(ended?.runId ?? null, ended?.status ?? null)])).toEqual(["Done"]);
154 });
155​
156 it("waits for the run to settle before announcing how the turn ended", () => {
157 expect(
158 speak([
159 idle(firstRun, "completed"),
160 busy(),
161 idle(secondRun, "running"),
162 idle(secondRun, "interrupted"),
163 ]),
164 ).toEqual(["Working", "Stopped"]);
165 });
166​
167 it("does not report the previous turn's ending when work stops before a new run exists", () => {
168 expect(
169 speak([
170 idle(firstRun, "completed"),
171 busy("working", firstRun, "completed"),
172 idle(firstRun, "completed"),
173 busy(),
174 idle(secondRun, "completed"),
175 ]),
176 ).toEqual(["Working", "Working", "Done"]);
177 // The previous run finished while this client looked idle.
178 expect(
179 speak([
180 idle(firstRun, "completed"),
181 idle(secondRun, "completed"),
182 busy("working", secondRun, "completed"),
183 idle(secondRun, "completed"),
184 ]),
185 ).toEqual(["Working"]);
186 });
187​
188 it("lets the next turn start after one is rolled back", () => {
189 expect(
190 speak([
191 idle(firstRun, "completed"),
192 busy(),
193 idle(secondRun, "rolled_back"),
194 busy("working", RunId.make("run-3")),
195 ]),
196 ).toEqual(["Working", "Working"]);
197 });
198​
199 it("announces each request once without repeating that work resumed", () => {
200 expect(
201 speak([
202 busy(),
203 busy("approval"),
204 busy("approval"),
205 busy(),
206 busy("input"),
207 idle(secondRun, "completed"),
208 ]),
209 ).toEqual(["Needs approval", "Needs your input", "Done"]);
210 });
211​
212 it("announces a second request of the same kind once the first is answered", () => {
213 expect(
214 speak([
215 busy(),
216 busy("approval", secondRun, "running", "request-1"),
217 busy("approval", secondRun, "running", "request-2"),
218 ]),
219 ).toEqual(["Needs approval", "Needs approval"]);
220 });
221});