1import type { RunId, ScreenReaderAnnouncements } from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";
3
4import type { TimelineEntry, WorkLogEntry } from "../../session-logic";
5import {
6 liveWorkEntryLabel,
7 timelineEntryRunId,
8 timelineEntryStartsResponse,
9} from "./MessagesTimeline.logic";
10import { markdownPlainText } from "./timelineMinimapItems";
11
12export interface TurnMessage {
13 readonly id: string;
14 /** Markdown. */
15 readonly text: string;
16 /** The agent stopped writing it and went on working. A turn's closing reply never is. */
17 readonly finished: boolean;
18 /** It arrived after the entry the caller last saw, unlike history loaded above it. */
19 readonly fresh: boolean;
20}
21
22export interface TurnDetail {
23 /** The step the agent is on, or null when its newest output is not a step. */
24 readonly activity: string | null;
25 /** The agent's messages in the latest turn, oldest first. */
26 readonly messages: ReadonlyArray<TurnMessage>;
27 /** The thread's newest entry. */
28 readonly tailId: string | null;
29}
30
31export const NO_TURN_DETAIL: TurnDetail = { activity: null, messages: [], tailId: null };
32
33const ACTIVITY_MAX_LENGTH = 120;
34// Parsing is synchronous, and nobody listens to a longer message through a live region.
35const MESSAGE_MAX_LENGTH = 2_000;
36
37/** The first `maxLength` code points of `text`, or null when it has no more than that. */
38function cutText(text: string, maxLength: number): string | null {
39 if (text.length <= maxLength) return null;
40 const characters = Array.from(text);
41 return characters.length > maxLength ? characters.slice(0, maxLength).join("") : null;
42}
43
44// On screen these labels are a path or a query beside an icon.
45function withVerb(work: WorkLogEntry, label: string) {
46 if (work.itemType === "file_change") return `Editing ${label}`;
47 if (work.itemType !== "file_search" && work.itemType !== "web_search") return label;
48 if (/^search(?:ed|ing)?\s/i.test(label)) return label;
49 return `${work.itemType === "web_search" ? "Searching the web for" : "Searching for"} ${label}`;
50}
51
52type MessageEntry = Extract<TimelineEntry, { kind: "message" }>;
53
54// A steer starts a new attempt in the same run. The reply it cut off also stops streaming,
55// so only one that completed before the steer finished; see completeSupersededReplyEntryIds.
56function completedBeforeSuperseded(entry: MessageEntry) {
57 const item = entry.projectedItem?.item;
58 const supersededAt = entry.attempt?.completedAt ?? null;
59 return (
60 item?.type === "assistant_message" &&
61 item.status === "completed" &&
62 item.completedAt !== null &&
63 supersededAt !== null &&
64 DateTime.isLessThan(item.completedAt, supersededAt)
65 );
66}
67
68function stepLabel(work: WorkLogEntry, workspaceRoot: string | undefined): string | null {
69 // Reasoning text is never read out.
70 if (work.itemType === "reasoning") return "Thinking";
71 const status = work.toolLifecycleStatus;
72 if (status === "failed" || status === "declined" || status === "stopped") return null;
73 const label = liveWorkEntryLabel(work, workspaceRoot, true).replace(/\s+/g, " ").trim();
74 if (label === "") return null;
75 const spoken = withVerb(work, label);
76 const cut = cutText(spoken, ACTIVITY_MAX_LENGTH);
77 return cut === null ? spoken : `${cut}…`;
78}
79
80/**
81 * What a screen reader can be told about the latest turn beyond its status.
82 * It reads the thread's entries before "Tool calls in chat" hides any, so what
83 * is spoken follows the announcement setting alone.
84 */
85export function turnDetail(
86 entries: ReadonlyArray<TimelineEntry>,
87 workspaceRoot: string | undefined,
88 knownTailId: string | null,
89): TurnDetail {
90 const messages: TurnMessage[] = [];
91 // Runs and attempts the agent kept working in after the entry being looked at.
92 const continuedRunIds = new Set<RunId | null>();
93 const continuedAttemptIds = new Set<string>();
94 const markContinued = (entry: TimelineEntry, runId: RunId | null) => {
95 continuedRunIds.add(runId);
96 if (entry.attempt !== undefined) continuedAttemptIds.add(entry.attempt.id);
97 };
98 let activity: string | null | undefined;
99 let fresh = true;
100 let index = entries.length - 1;
101 for (; index >= 0; index -= 1) {
102 const entry = entries[index]!;
103 if (timelineEntryStartsResponse(entry)) break;
104 if (entry.id === knownTailId) fresh = false;
105 if (entry.kind === "event") continue;
106 if (entry.kind === "work") {
107 const work = entry.entry;
108 if (work.sourceActivityKind === "context-compaction") {
109 activity ??= work.toolLifecycleStatus === "inProgress" ? work.label : null;
110 continue;
111 }
112 // Notices, todo lists and checkpoints sit beside the turn's steps without being one.
113 if (work.tone !== "tool" && work.itemType !== "reasoning") continue;
114 markContinued(entry, work.runId ?? null);
115 // A request is spoken as the thread's status; once answered, the step it was for goes on.
116 if (work.itemType === "approval_request" || work.itemType === "user_input_request") continue;
117 if (activity === undefined) activity = stepLabel(work, workspaceRoot);
118 continue;
119 }
120 if (entry.kind === "message" && entry.message.role !== "assistant") continue;
121 // A reply, plan or rendered page is newer than any step before it.
122 activity ??= null;
123 const runId = timelineEntryRunId(entry);
124 if (entry.kind === "message") {
125 messages.push({
126 id: entry.message.id,
127 text: entry.message.text,
128 finished:
129 !entry.message.streaming &&
130 continuedRunIds.has(runId) &&
131 (entry.attempt?.status !== "superseded" ||
132 continuedAttemptIds.has(entry.attempt.id) ||
133 completedBeforeSuperseded(entry)),
134 fresh,
135 });
136 }
137 markContinued(entry, runId);
138 }
139 // Nothing is known to be new when the entry last seen is gone, or there was none.
140 let tailKnown = !fresh;
141 if (knownTailId !== null) {
142 for (; !tailKnown && index >= 0; index -= 1) tailKnown = entries[index]!.id === knownTailId;
143 }
144 return {
145 activity: activity ?? null,
146 messages: (tailKnown
147 ? messages
148 : messages.map((message) => ({ ...message, fresh: false }))
149 ).reverse(),
150 tailId: entries.at(-1)?.id ?? null,
151 };
152}
153
154export interface TurnDetailSnapshot extends TurnDetail {
155 readonly level: ScreenReaderAnnouncements;
156 readonly status: "working" | "approval" | "input" | "idle";
157}
158
159export interface TurnDetailMemory {
160 readonly activity: string | null;
161 /** Each message seen in the turn, and whether it was already finished then. */
162 readonly messages: ReadonlyMap<string, boolean>;
163 readonly tailId: string | null;
164}
165
166/** The heading every agent message has in the timeline. */
167const MESSAGE_AUTHOR = "T3 Code";
168
169/**
170 * Decides which of the turn's messages and steps a screen reader hears next.
171 * The first snapshot of a thread is a silent baseline, like its status.
172 */
173export function nextTurnDetailAnnouncement(
174 previous: TurnDetailMemory | null,
175 next: TurnDetailSnapshot,
176): {
177 readonly memory: TurnDetailMemory;
178 readonly messages: ReadonlyArray<string>;
179 readonly activity: string | null;
180} {
181 const memory: TurnDetailMemory = {
182 // A step is spoken again after a pause for an approval or a question.
183 activity: next.status === "working" ? next.activity : null,
184 messages: new Map(next.messages.map((message) => [message.id, message.finished])),
185 tailId: next.tailId,
186 };
187 // A turn that already ended is history, however late its entries arrive.
188 if (previous === null || next.level === "status" || next.status === "idle") {
189 return { memory, messages: [], activity: null };
190 }
191 return {
192 memory,
193 messages: next.messages
194 .filter(
195 (message) =>
196 message.finished &&
197 previous.messages.get(message.id) !== true &&
198 (message.fresh || previous.messages.has(message.id)),
199 )
200 .map((message) => {
201 const cut = cutText(message.text, MESSAGE_MAX_LENGTH);
202 return cut === null
203 ? markdownPlainText(message.text)
204 : `${markdownPlainText(cut)} More in the thread.`;
205 })
206 .filter((text) => text !== "")
207 .map((text) => `${MESSAGE_AUTHOR}: ${text}`),
208 activity:
209 next.level === "everything" && memory.activity !== previous.activity ? memory.activity : null,
210 };
211}