1import { RunId, RuntimeRequestId } from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 type BackgroundThreadMemory,
7 type BackgroundThreadSnapshot,
8 nextBackgroundThreadAnnouncement,
9 resolveBackgroundThreadRequest,
10} from "./backgroundThreadAnnouncement";
11
12const firstRun = RunId.make("run-1");
13const secondRun = RunId.make("run-2");
14
15function thread(
16 key: string,
17 title: string,
18 state: Partial<Omit<BackgroundThreadSnapshot, "key" | "title">> = {},
19): BackgroundThreadSnapshot {
20 return {
21 key,
22 title,
23 request: null,
24 requestId: null,
25 runId: firstRun,
26 runStatus: "running",
27 activeRunId: null,
28 snoozed: false,
29 ...state,
30 };
31}
32const login = (state?: Partial<BackgroundThreadSnapshot>) =>
33 thread("env:login", "Fix login redirect", state);
34const docs = (state?: Partial<BackgroundThreadSnapshot>) =>
35 thread("env:docs", "Update docs", state);
36
37function speak(
38 frames: ReadonlyArray<{
39 readonly threads: ReadonlyArray<BackgroundThreadSnapshot>;
40 readonly open?: string;
41 }>,
42) {
43 let memory: BackgroundThreadMemory = new Map();
44 const spoken: string[] = [];
45 for (const frame of frames) {
46 const result = nextBackgroundThreadAnnouncement(memory, frame.threads, frame.open ?? null);
47 memory = result.memory;
48 if (result.announcement !== null) spoken.push(result.announcement);
49 }
50 return spoken;
51}
52
53describe("nextBackgroundThreadAnnouncement", () => {
54 it("names the thread with the same words the open thread uses", () => {
55 expect(
56 speak([
57 { threads: [login()] },
58 { threads: [login({ request: "approval" })] },
59 { threads: [login()] },
60 { threads: [login({ request: "input" })] },
61 { threads: [login({ runStatus: "completed" })] },
62 { threads: [login({ runId: secondRun, runStatus: "failed" })] },
63 { threads: [login({ runId: RunId.make("run-3"), runStatus: "interrupted" })] },
64 ]),
65 ).toEqual([
66 "Needs approval: Fix login redirect",
67 "Needs your input: Fix login redirect",
68 "Done: Fix login redirect",
69 "Failed: Fix login redirect",
70 "Stopped: Fix login redirect",
71 ]);
72 });
73
74 it("announces each run's ending once", () => {
75 expect(
76 speak([
77 { threads: [login()] },
78 { threads: [login({ runStatus: "completed" })] },
79 // Marking the thread unread or renaming it changes nothing about the run.
80 { threads: [login({ runStatus: "completed", title: "Fix the login redirect" })] },
81 // The next run can finish between two updates, so it never looks busy here.
82 { threads: [login({ runId: secondRun, runStatus: "completed" })] },
83 { threads: [login({ runId: RunId.make("run-3"), runStatus: "rolled_back" })] },
84 ]),
85 ).toEqual(["Done: Fix login redirect", "Done: Fix login redirect"]);
86 });
87
88 it("announces a second request of the same kind", () => {
89 expect(
90 speak([
91 { threads: [login()] },
92 { threads: [login({ request: "approval", requestId: "request-1" })] },
93 { threads: [login({ request: "approval", requestId: "request-2" })] },
94 ]),
95 ).toEqual(["Needs approval: Fix login redirect", "Needs approval: Fix login redirect"]);
96 });
97
98 it("doesn't call a thread stopped when a queued message is cancelled while it works", () => {
99 expect(
100 speak([
101 { threads: [login({ activeRunId: firstRun })] },
102 {
103 threads: [login({ runId: secondRun, runStatus: "cancelled", activeRunId: firstRun })],
104 },
105 { threads: [login({ runId: secondRun, runStatus: "cancelled" })] },
106 ]),
107 ).toEqual([]);
108 });
109
110 it("stays quiet for threads it sees for the first time, like on launch or reconnect", () => {
111 expect(
112 speak([
113 { threads: [login({ runStatus: "completed" }), docs({ request: "approval" })] },
114 { threads: [login({ runStatus: "completed" }), docs({ request: "approval" })] },
115 // The environment drops out while it resyncs, then comes back changed.
116 { threads: [] },
117 {
118 threads: [
119 login({ runId: secondRun, runStatus: "failed" }),
120 docs({ runStatus: "completed" }),
121 ],
122 },
123 ]),
124 ).toEqual([]);
125 });
126
127 it("leaves the open thread to its own announcer", () => {
128 expect(
129 speak([
130 { threads: [login(), docs()], open: "env:login" },
131 { threads: [login({ runStatus: "completed" }), docs()], open: "env:login" },
132 // Switching away from a thread doesn't replay its ending.
133 { threads: [login({ runStatus: "completed" }), docs()], open: "env:docs" },
134 {
135 threads: [login({ runStatus: "completed" }), docs({ request: "input" })],
136 open: "env:login",
137 },
138 ]),
139 ).toEqual(["Needs your input: Update docs"]);
140 });
141
142 it("keeps a snoozed thread quiet, even after it wakes", () => {
143 expect(
144 speak([
145 { threads: [login({ snoozed: true })] },
146 { threads: [login({ snoozed: true, runStatus: "completed" })] },
147 { threads: [login({ runStatus: "completed" })] },
148 ]),
149 ).toEqual([]);
150 });
151
152 it("speaks threads that change together in one announcement", () => {
153 expect(
154 speak([
155 { threads: [login(), docs()] },
156 { threads: [login({ runStatus: "completed" }), docs({ request: "approval" })] },
157 ]),
158 ).toEqual(["Done: Fix login redirect. Needs approval: Update docs"]);
159 });
160});
161
162describe("resolveBackgroundThreadRequest", () => {
163 const question = (id: string) => ({
164 id: RuntimeRequestId.make(id),
165 kind: "user_input" as const,
166 createdAt: DateTime.makeUnsafe(0),
167 });
168 const childApproval = (parentQuestion: string | null) =>
169 resolveBackgroundThreadRequest({
170 hasPendingApprovals: false,
171 hasPendingUserInput: parentQuestion !== null,
172 pendingSubagentRequest: "approval",
173 source: { pendingRuntimeRequest: parentQuestion === null ? null : question(parentQuestion) },
174 });
175
176 it("raises a subagent's request on the parent without borrowing the parent's request id", () => {
177 expect(childApproval(null)).toEqual({ request: "approval", requestId: null });
178 // The parent's own questions come and go while the child's approval waits.
179 expect(
180 speak([
181 { threads: [login()] },
182 { threads: [login(childApproval(null))] },
183 { threads: [login(childApproval("question-1"))] },
184 { threads: [login(childApproval("question-2"))] },
185 { threads: [login(childApproval(null))] },
186 ]),
187 ).toEqual(["Needs approval: Fix login redirect"]);
188 });
189});