Skip to content
Source
Sign inSign up

backgroundThreadAnnouncement.ts

88 lines3.9 KB

1import type { OrchestrationV2RunStatus, RunId } from "@t3tools/contracts";
2​
3import type { SidebarThreadSummary } from "../types";
4import {
5 runOutcomeText,
6 THREAD_STATUS_TEXT,
7} from "@t3tools/client-runtime/state/thread-activity-announcement";
8​
9export interface BackgroundThreadSnapshot {
10 readonly key: string;
11 readonly title: string;
12 readonly request: "approval" | "input" | null;
13 /** Null for a subagent's request, which the parent's shell only knows by kind. */
14 readonly requestId: string | null;
15 readonly runId: RunId | null;
16 readonly runStatus: OrchestrationV2RunStatus | null;
17 /** The run doing work now, which can be older than a queued run cancelled while it ran. */
18 readonly activeRunId: RunId | null;
19 /** A snoozed thread stays hidden until its timer, so it stays quiet too. */
20 readonly snoozed: boolean;
22​
23/** The request and its id come from the same source, so a subagent's request never takes the parent's id. */
24export function resolveBackgroundThreadRequest(
25 thread: Pick<
26 SidebarThreadSummary,
27 "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest"
28 > & {
29 readonly source: Pick<SidebarThreadSummary["source"], "pendingRuntimeRequest">;
30 },
31): Pick<BackgroundThreadSnapshot, "request" | "requestId"> {
32 const ownRequestId = thread.source.pendingRuntimeRequest?.id ?? null;
33 if (thread.hasPendingApprovals) return { request: "approval", requestId: ownRequestId };
34 if (thread.pendingSubagentRequest === "approval") return { request: "approval", requestId: null };
35 if (thread.hasPendingUserInput) return { request: "input", requestId: ownRequestId };
36 if (thread.pendingSubagentRequest === "user_input") return { request: "input", requestId: null };
37 return { request: null, requestId: null };
39​
40interface BackgroundThreadMemoryEntry {
41 readonly request: BackgroundThreadSnapshot["request"];
42 readonly requestId: string | null;
43 /** The last run whose ending is known, so each run's ending is announced once. */
44 readonly settledRunId: RunId | null;
46​
47export type BackgroundThreadMemory = ReadonlyMap<string, BackgroundThreadMemoryEntry>;
48​
49/**
50 * Only live environments supply threads. First sightings establish a silent baseline
51 * so opening the app or reconnecting does not read out every thread.
52 */
53export function nextBackgroundThreadAnnouncement(
54 previous: BackgroundThreadMemory,
55 threads: ReadonlyArray<BackgroundThreadSnapshot>,
56 openThreadKey: string | null,
57): { readonly memory: BackgroundThreadMemory; readonly announcement: string | null } {
58 const memory = new Map<string, BackgroundThreadMemoryEntry>();
59 const announcements: string[] = [];
60 for (const thread of threads) {
61 const before = previous.get(thread.key);
62 const outcome = runOutcomeText(thread.runStatus);
63 const settled = outcome !== null || thread.runStatus === "rolled_back";
64 memory.set(thread.key, {
65 request: thread.request,
66 requestId: thread.requestId,
67 settledRunId: settled ? thread.runId : (before?.settledRunId ?? null),
68 });
69 if (before === undefined || thread.key === openThreadKey || thread.snoozed) continue;
70 if (
71 thread.request !== null &&
72 (thread.request !== before.request || thread.requestId !== before.requestId)
73 ) {
74 announcements.push(`${THREAD_STATUS_TEXT[thread.request]}: ${thread.title}`);
75 }
76 // While another run still works, the latest run's ending (a queued message
77 // cancelled before it started) isn't the thread finishing. The shell then
78 // only reports the latest run, so the working run's own ending stays quiet.
79 const anotherRunWorking = thread.activeRunId !== null && thread.activeRunId !== thread.runId;
80 if (outcome !== null && thread.runId !== before.settledRunId && !anotherRunWorking) {
81 announcements.push(`${outcome}: ${thread.title}`);
82 }
83 }
84 return {
85 memory,
86 announcement: announcements.length === 0 ? null : announcements.join(". "),
87 };