Skip to content
Source
Sign inSign up
Open History to see the last commit

threadActivityAnnouncement.ts

176 lines6.5 KB

1import type { OrchestrationV2Run, OrchestrationV2RunStatus, RunId } from "@t3tools/contracts";
2import { latestExecutedRun } from "@t3tools/shared/orchestrationV2ThreadError";
3​
4export type ThreadActivityStatus = "working" | "approval" | "input" | "idle";
5​
6/** Background threads say these too, so a status sounds the same wherever it's heard. */
7export const THREAD_STATUS_TEXT = {
8 working: "Working",
9 approval: "Needs approval",
10 input: "Needs your input",
11 done: "Done",
12 failed: "Failed",
13 stopped: "Stopped",
14} as const;
15​
16export interface ThreadActivitySnapshot {
17 readonly status: ThreadActivityStatus;
18 /** The pending approval or question, when there is one. */
19 readonly requestId: string | null;
20 readonly runId: RunId | null;
21 readonly runStatus: OrchestrationV2RunStatus | null;
23​
24export interface ThreadAnnouncementMemory {
25 readonly snapshot: ThreadActivitySnapshot;
26 /**
27 * The turn under way: the run it belongs to, "unknown" until that run shows
28 * up, or null between turns. Its ending is announced once, then it clears.
29 */
30 readonly turnRunId: RunId | "unknown" | null;
31 /** The run whose ending is already known, so a stale run is never announced as this turn's. */
32 readonly settledRunId: RunId | null;
33 /** The run shown while idle belongs to the last turn, even if it is still settling. */
34 readonly idleRunId: RunId | null;
36​
37const ACTIVE_RUN_STATUSES: ReadonlySet<OrchestrationV2RunStatus | null> = new Set([
38 "preparing",
39 "starting",
40 "running",
41 "waiting",
42]);
43​
44const STARTING_RUN_STATUSES: ReadonlySet<OrchestrationV2RunStatus | null> = new Set([
45 "queued",
46 "preparing",
47 "starting",
48]);
49​
50export function runOutcomeText(status: OrchestrationV2RunStatus | null): string | null {
51 switch (status) {
52 case "completed":
53 return THREAD_STATUS_TEXT.done;
54 case "failed":
55 return THREAD_STATUS_TEXT.failed;
56 case "interrupted":
57 case "cancelled":
58 return THREAD_STATUS_TEXT.stopped;
59 default:
60 return null;
61 }
63​
64interface RunState {
65 readonly runId: RunId;
66 readonly status: OrchestrationV2RunStatus;
68​
69/**
70 * The thread's detail hasn't caught up with the live thread shell: it isn't
71 * loaded yet, or a cached copy still shows an older run or state. Reading such
72 * a frame as the baseline replays what already happened, so it's skipped.
73 */
74export function isDetailBehindShell(detailRun: RunState | null, shellRun: RunState | null) {
75 return (
76 shellRun !== null &&
77 (detailRun?.runId !== shellRun.runId || detailRun.status !== shellRun.status)
78 );
80​
81/**
82 * A queued message cancelled before it started keeps its place in line, so it
83 * shows up as the thread's latest run. It never ran, so the turn ended however
84 * the last run that did ran.
85 */
86export function announcedRun(
87 activityRun: (RunState & { readonly startedAt: string | null }) | null,
88 runs: ReadonlyArray<OrchestrationV2Run>,
89): RunState | null {
90 if (activityRun?.status !== "cancelled" || activityRun.startedAt !== null) return activityRun;
91 const run = latestExecutedRun(runs);
92 return run === null ? null : { runId: run.id, status: run.status };
94​
95/**
96 * Decides what a screen reader hears when the open thread's turn changes state.
97 * The first snapshot of a thread is a silent baseline, so opening or switching
98 * threads never replays what already happened.
99 */
100export function nextThreadAnnouncement(
101 previous: ThreadAnnouncementMemory | null,
102 next: ThreadActivitySnapshot,
103): { readonly memory: ThreadAnnouncementMemory; readonly announcement: string | null } {
104 const idle = next.status === "idle";
105 // A run still starting up while the thread looks idle (a queued message,
106 // a resume) is the next turn's, not a leftover from the last one.
107 const idleRunId =
108 idle && !STARTING_RUN_STATUSES.has(next.runStatus) ? next.runId : (previous?.idleRunId ?? null);
109 // A run doing new work; the run left over from the last turn doesn't count.
110 const newRunId =
111 !idle && ACTIVE_RUN_STATUSES.has(next.runStatus) && next.runId !== idleRunId
112 ? next.runId
113 : null;
114​
115 if (previous === null) {
116 return {
117 memory: {
118 snapshot: next,
119 turnRunId: idle ? null : (newRunId ?? "unknown"),
120 settledRunId: runOutcomeText(next.runStatus) === null ? null : next.runId,
121 idleRunId,
122 },
123 announcement: null,
124 };
125 }
126 const memory = { ...previous, snapshot: next, idleRunId };
127 const { turnRunId } = previous;
128​
129 if (!idle) {
130 // Sending can look idle for a moment before the server reports the run,
131 // so a turn is tied to its run as soon as one appears, and only another
132 // run taking over (a queued message starting) is a new turn.
133 const turnStarted =
134 turnRunId === null ||
135 (newRunId !== null && turnRunId !== "unknown" && newRunId !== turnRunId);
136 const nextTurnRunId =
137 turnStarted || turnRunId === "unknown" ? (newRunId ?? "unknown") : turnRunId;
138 const announcement =
139 next.status === "approval"
140 ? previous.snapshot.status === "approval" && previous.snapshot.requestId === next.requestId
141 ? null
142 : THREAD_STATUS_TEXT.approval
143 : next.status === "input"
144 ? previous.snapshot.status === "input" && previous.snapshot.requestId === next.requestId
145 ? null
146 : THREAD_STATUS_TEXT.input
147 : turnStarted
148 ? THREAD_STATUS_TEXT.working
149 : null;
150 return { memory: { ...memory, turnRunId: nextTurnRunId }, announcement };
151 }
152​
153 // Work can stop a moment before the run settles; the ending waits for the run.
154 const outcome = runOutcomeText(next.runStatus);
155 if (turnRunId === null) {
156 // A run that settled unseen is history too, or the next failed send would claim it.
157 const settledRunId = outcome === null ? previous.settledRunId : next.runId;
158 return { memory: { ...memory, settledRunId }, announcement: null };
159 }
160 if (next.runStatus === "rolled_back") {
161 return { memory: { ...memory, turnRunId: null, settledRunId: next.runId }, announcement: null };
162 }
163 if (outcome === null) return { memory, announcement: null };
164 // Work stopped without a new run, e.g. a send that failed; the retry is a new turn.
165 if (next.runId === previous.settledRunId) {
166 return { memory: { ...memory, turnRunId: null }, announcement: null };
167 }
168 // The last turn's run settling while this turn waits for its own run is not this turn's ending.
169 if (turnRunId === "unknown" && next.runId === previous.idleRunId) {
170 return { memory: { ...memory, settledRunId: next.runId }, announcement: null };
171 }
172 return {
173 memory: { ...memory, turnRunId: null, settledRunId: next.runId },
174 announcement: outcome,
175 };