authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-16 15:32:02-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 15:32:02-03:00
log052c7ae53e46c705d5fd5dfa995275bc3100744e
tree77c0d98ae0a4422e2b22ba2ef26db45b16840ab1
parentf4ef5155fb8aab7776a41fc8ec73d5367f53ba1f
signature Signed by PGP key B5690EEEBB952194

feat(chat): show provider thinking traces (#11784)

Co-authored-by: maria-rcks <mwria.rocks@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>

32 files changed, 1923 insertions(+), 117 deletions(-)

apps/mobile/src/connection/environment-cache-store.test.ts+83-1
...@@ -1,4 +1,12 @@...@@ -1,4 +1,12 @@
1import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts";1import {
2 EnvironmentId,
3 ThreadId,
4 ProjectId,
5 ProviderInstanceId,
6 MessageId,
7 type OrchestrationThreadDetailSnapshot,
8 type VcsListRefsResult,
9} from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";10import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";12import * as Option from "effect/Option";
...@@ -69,6 +77,80 @@ function makeDatabase() {...@@ -69,6 +77,80 @@ function makeDatabase() {
69}77}
7078
71describe("mobile SQLite environment cache store", () => {79describe("mobile SQLite environment cache store", () => {
80 it.effect(
81 "invalidates pre-thinking thread caches while preserving current snapshots and other caches",
82 () =>
83 Effect.gen(function* () {
84 const memory = makeDatabase();
85 const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database));
86 const now = "2026-09-04T00:00:00.000Z";
87 const snapshot: OrchestrationThreadDetailSnapshot = {
88 snapshotSequence: 2,
89 thread: {
90 id: ThreadId.make("thread-1"),
91 projectId: ProjectId.make("project-1"),
92 title: "Thread",
93 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
94 runtimeMode: "full-access",
95 interactionMode: "default",
96 branch: null,
97 pullRequests: [],
98 worktreePath: null,
99 latestTurn: null,
100 createdAt: now,
101 updatedAt: now,
102 archivedAt: null,
103 settledOverride: null,
104 settledAt: null,
105 deletedAt: null,
106 messages: [
107 {
108 id: MessageId.make("thinking-1"),
109 role: "reasoning",
110 text: "Checking the evidence.",
111 turnId: null,
112 streaming: false,
113 createdAt: now,
114 updatedAt: now,
115 },
116 ],
117 proposedPlans: [],
118 activities: [],
119 checkpoints: [],
120 session: null,
121 },
122 page: { beforeCursor: null, hasMore: false, snapshotSequence: 2, threadSequence: 2 },
123 };
124 yield* store.saveThread(ENVIRONMENT_ID, snapshot);
125 yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS);
126 expect(yield* store.loadThread(ENVIRONMENT_ID, snapshot.thread.id)).toEqual(
127 Option.some(snapshot),
128 );
129 const id = cacheId(ENVIRONMENT_ID, "thread", snapshot.thread.id);
130 memory.values.set(
131 id,
132 JSON.stringify({
133 schemaVersion: 3,
134 environmentId: ENVIRONMENT_ID,
135 threadId: snapshot.thread.id,
136 snapshot: {
137 ...snapshot,
138 thread: {
139 ...snapshot.thread,
140 messages: snapshot.thread.messages.map((message) => ({
141 ...message,
142 role: "system",
143 })),
144 },
145 },
146 }),
147 );
148 expect(yield* store.loadThread(ENVIRONMENT_ID, snapshot.thread.id)).toEqual(Option.none());
149 expect(memory.removed).toEqual([id]);
150 expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.some(REFS));
151 }),
152 );
153
72 it.effect("round-trips schema-validated VCS refs", () =>154 it.effect("round-trips schema-validated VCS refs", () =>
73 Effect.gen(function* () {155 Effect.gen(function* () {
74 const memory = makeDatabase();156 const memory = makeDatabase();
apps/mobile/src/connection/environment-cache-store.ts+3-1
...@@ -21,7 +21,9 @@ const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;...@@ -21,7 +21,9 @@ const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
21// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump21// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump
22// makes pre-pagination clients discard the record instead of decoding a22// makes pre-pagination clients discard the record instead of decoding a
23// partial thread as complete (rollback safety).23// partial thread as complete (rollback safety).
24const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 3;24// v4 reloads pre-thinking caches whose system-role fallback would otherwise
25// survive afterSequence resume and hide settled reasoning messages.
26const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 4;
25const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1;27const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1;
26const VCS_REFS_CACHE_SCHEMA_VERSION = 1;28const VCS_REFS_CACHE_SCHEMA_VERSION = 1;
2729
apps/mobile/src/features/threads/ThreadFeed.tsx+92-8
...@@ -144,6 +144,7 @@ import {...@@ -144,6 +144,7 @@ import {
144} from "@t3tools/mobile-markdown-text/links";144} from "@t3tools/mobile-markdown-text/links";
145import {145import {
146 deriveThreadFeedPresentation,146 deriveThreadFeedPresentation,
147 deriveUnsettledTurnId,
147 isContextCompactionActivityGroup,148 isContextCompactionActivityGroup,
148 type ThreadFeedEntry,149 type ThreadFeedEntry,
149 type ThreadFeedLatestTurn,150 type ThreadFeedLatestTurn,
...@@ -158,6 +159,7 @@ import {...@@ -158,6 +159,7 @@ import {
158 collapsedWorkLogHeight,159 collapsedWorkLogHeight,
159 ThreadAgentSpawnCard,160 ThreadAgentSpawnCard,
160 ThreadDisclosureChevron,161 ThreadDisclosureChevron,
162 ThreadReasoningRow,
161 ThreadWorkGroupToggle,163 ThreadWorkGroupToggle,
162 ThreadThinkingRow,164 ThreadThinkingRow,
163 ThreadWorkLog,165 ThreadWorkLog,
...@@ -193,6 +195,9 @@ import {...@@ -193,6 +195,9 @@ import {
193 ThreadMarkdownImageView,195 ThreadMarkdownImageView,
194} from "./ThreadMarkdownImage";196} from "./ThreadMarkdownImage";
195197
198/** `ml-7` gutter plus the `px-3` padding of the expanded reasoning container. */
199const REASONING_CONTENT_INSET = 52;
200
196const WIDE_MARKDOWN_BLOCK_OPTIONS = {201const WIDE_MARKDOWN_BLOCK_OPTIONS = {
197 // Native iOS blockquotes and adjacent selectable text are separate layout202 // Native iOS blockquotes and adjacent selectable text are separate layout
198 // chunks. Giving their shrink-to-fit bubble a definite width keeps both203 // chunks. Giving their shrink-to-fit bubble a definite width keeps both
...@@ -1348,14 +1353,17 @@ function renderFeedEntry(...@@ -1348,14 +1353,17 @@ function renderFeedEntry(
1348 > & {1353 > & {
1349 readonly copiedRowId: string | null;1354 readonly copiedRowId: string | null;
1350 readonly expandedWorkRows: Record<string, boolean>;1355 readonly expandedWorkRows: Record<string, boolean>;
1356 readonly expandedReasoningMessageIds: ReadonlySet<string>;
1351 readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;1357 readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
1352 readonly workGroupScrollPositions: Map<string, ThreadWorkGroupScrollPosition>;1358 readonly workGroupScrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
1353 readonly terminalAssistantMessageIds: ReadonlySet<string>;1359 readonly terminalAssistantMessageIds: ReadonlySet<string>;
1354 readonly unsettledTurnId: TurnId | null;1360 readonly unsettledTurnId: TurnId | null;
1361 readonly isWorking: boolean;
1355 readonly onCopyWorkRow: (rowId: string, value: string) => void;1362 readonly onCopyWorkRow: (rowId: string, value: string) => void;
1356 readonly onToggleWorkGroup: (groupId: string, anchorKey: string) => void;1363 readonly onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
1357 readonly onToggleWorkRow: (rowId: string, anchorKey: string) => void;1364 readonly onToggleWorkRow: (rowId: string, anchorKey: string) => void;
1358 readonly onToggleTurnFold: (turnId: TurnId) => void;1365 readonly onToggleTurnFold: (turnId: TurnId) => void;
1366 readonly onToggleReasoning: (messageId: string) => void;
1359 readonly onPressPreview: (source: FilePreviewSource) => void;1367 readonly onPressPreview: (source: FilePreviewSource) => void;
1360 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;1368 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
1361 readonly markdownLinkHandlers: MarkdownLinkHandlers;1369 readonly markdownLinkHandlers: MarkdownLinkHandlers;
...@@ -1467,6 +1475,38 @@ function renderFeedEntry(...@@ -1467,6 +1475,38 @@ function renderFeedEntry(
14671475
1468 if (entry.type === "message") {1476 if (entry.type === "message") {
1469 const { message } = entry;1477 const { message } = entry;
1478 if (message.role === "reasoning") {
1479 // Only the live turn may claim to still be thinking, and only while the
1480 // thread is actually working: a block left open by a crashed provider
1481 // must not shimmer on a turn that settled long ago. Same test as web.
1482 const liveReasoning =
1483 Boolean(message.streaming) &&
1484 props.isWorking &&
1485 message.turnId !== null &&
1486 message.turnId === props.unsettledTurnId;
1487 return (
1488 <ThreadReasoningRow
1489 rowSizing={props.workRowSizing}
1490 iconSubtleColor={iconSubtleColor}
1491 expanded={props.expandedReasoningMessageIds.has(message.id)}
1492 label={liveReasoning ? "Thinking" : "Thought"}
1493 streaming={liveReasoning}
1494 onToggle={() => props.onToggleReasoning(message.id)}
1495 >
1496 <MarkdownImageAvailableWidthContext
1497 value={props.markdownContentWidth - REASONING_CONTENT_INSET}
1498 >
1499 <AssistantMarkdownContent
1500 markdown={message.text}
1501 markdownStyles={markdownStyles.assistant}
1502 linkHandlers={props.markdownLinkHandlers}
1503 renderImage={props.renderMarkdownImage}
1504 skills={props.skills}
1505 />
1506 </MarkdownImageAvailableWidthContext>
1507 </ThreadReasoningRow>
1508 );
1509 }
1470 const isUser = message.role === "user";1510 const isUser = message.role === "user";
1471 const renderedText = renderAssistantCitationsAsText(message.text);1511 const renderedText = renderAssistantCitationsAsText(message.text);
1472 const styles = isUser ? markdownStyles.user : markdownStyles.assistant;1512 const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
...@@ -1960,13 +2000,21 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1960,13 +2000,21 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1960 readonly expandedWorkGroups: Record<string, boolean>;2000 readonly expandedWorkGroups: Record<string, boolean>;
1961 readonly expandedWorkRows: Record<string, boolean>;2001 readonly expandedWorkRows: Record<string, boolean>;
1962 readonly expandedTurnIds: ReadonlySet<TurnId>;2002 readonly expandedTurnIds: ReadonlySet<TurnId>;
2003 readonly expandedReasoningMessageIds: ReadonlySet<string>;
1963 }>({2004 }>({
1964 copiedRowId: null,2005 copiedRowId: null,
1965 expandedWorkGroups: {},2006 expandedWorkGroups: {},
1966 expandedWorkRows: {},2007 expandedWorkRows: {},
1967 expandedTurnIds: new Set(),2008 expandedTurnIds: new Set(),
2009 expandedReasoningMessageIds: new Set(),
1968 });2010 });
1969 const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;2011 const {
2012 copiedRowId,
2013 expandedWorkGroups,
2014 expandedWorkRows,
2015 expandedTurnIds,
2016 expandedReasoningMessageIds,
2017 } = interactionState;
1970 const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null);2018 const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null);
1971 const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);2019 const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);
1972 const fileShareSourceIdentifier = useId();2020 const fileShareSourceIdentifier = useId();
...@@ -2218,11 +2266,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2218,11 +2266,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2218 );2266 );
2219 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage);2267 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage);
2220 const reviewCommentColors = useReviewCommentColors();2268 const reviewCommentColors = useReviewCommentColors();
2221 const unsettledTurnId =2269 // One definition of "still live", shared with the fold derivation: two
2222 props.latestTurn &&2270 // copies of this test are what let a row and the fold beside it disagree.
2223 (props.latestTurn.completedAt === null || props.latestTurn.state === "running")2271 const unsettledTurnId = deriveUnsettledTurnId(props.latestTurn ?? null);
2224 ? props.latestTurn.turnId
2225 : null;
2226 // LegendList does not invalidate visible rows when only the renderItem closure changes.2272 // LegendList does not invalidate visible rows when only the renderItem closure changes.
2227 // Include turn completion so unchanged message rows reveal their footer and spacing2273 // Include turn completion so unchanged message rows reveal their footer and spacing
2228 // even when the final message update arrives before the turn settles.2274 // even when the final message update arrives before the turn settles.
...@@ -2232,6 +2278,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2232,6 +2278,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2232 unsettledTurnId,2278 unsettledTurnId,
2233 copiedRowId,2279 copiedRowId,
2234 expandedWorkRows,2280 expandedWorkRows,
2281 expandedReasoningMessageIds,
2235 workRowSizing,2282 workRowSizing,
2236 iconSubtleColor,2283 iconSubtleColor,
2237 markdownStyles,2284 markdownStyles,
...@@ -2245,6 +2292,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2245,6 +2292,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2245 unsettledTurnId,2292 unsettledTurnId,
2246 copiedRowId,2293 copiedRowId,
2247 expandedWorkRows,2294 expandedWorkRows,
2295 expandedReasoningMessageIds,
2248 workRowSizing,2296 workRowSizing,
2249 iconSubtleColor,2297 iconSubtleColor,
2250 markdownStyles,2298 markdownStyles,
...@@ -2517,7 +2565,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2517,7 +2565,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2517 if (disclosureAnchorKeyRef.current !== null) {2565 if (disclosureAnchorKeyRef.current !== null) {
2518 settleDisclosureAfterLayout();2566 settleDisclosureAfterLayout();
2519 }2567 }
2520 }, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]);2568 }, [
2569 expandedTurnIds,
2570 expandedWorkGroups,
2571 expandedWorkRows,
2572 expandedReasoningMessageIds,
2573 settleDisclosureAfterLayout,
2574 ]);
25212575
2522 const handleItemSizeChanged = useCallback(() => {2576 const handleItemSizeChanged = useCallback(() => {
2523 if (disclosureAnchorKeyRef.current !== null) {2577 if (disclosureAnchorKeyRef.current !== null) {
...@@ -2600,6 +2654,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2600,6 +2654,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2600 [suspendEndScrollMaintenanceForDisclosure],2654 [suspendEndScrollMaintenanceForDisclosure],
2601 );2655 );
26022656
2657 const onToggleReasoning = useCallback(
2658 (messageId: string) => {
2659 // The anchor must be the feed row id, which for a message row is the
2660 // message id, or position restoration is skipped for every row.
2661 suspendEndScrollMaintenanceForDisclosure(messageId);
2662 setInteractionState((current) => {
2663 const next = new Set(current.expandedReasoningMessageIds);
2664 if (next.has(messageId)) {
2665 next.delete(messageId);
2666 } else {
2667 next.add(messageId);
2668 }
2669 return { ...current, expandedReasoningMessageIds: next };
2670 });
2671 },
2672 [suspendEndScrollMaintenanceForDisclosure],
2673 );
2674
2603 const onPressPreview = useCallback((source: FilePreviewSource) => {2675 const onPressPreview = useCallback((source: FilePreviewSource) => {
2604 setExpandedFile((current) => current ?? source);2676 setExpandedFile((current) => current ?? source);
2605 }, []);2677 }, []);
...@@ -2626,6 +2698,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2626,6 +2698,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2626 return undefined;2698 return undefined;
2627 }2699 }
2628 switch (entry.type) {2700 switch (entry.type) {
2701 case "message":
2702 // A collapsed reasoning row is the same chrome as a work toggle.
2703 return entry.message.role === "reasoning" &&
2704 !expandedReasoningMessageIds.has(entry.message.id)
2705 ? WORK_GROUP_TOGGLE_HEIGHT
2706 : undefined;
2629 case "turn-fold":2707 case "turn-fold":
2630 return TURN_FOLD_HEIGHT;2708 return TURN_FOLD_HEIGHT;
2631 case "work-toggle":2709 case "work-toggle":
...@@ -2644,7 +2722,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2644,7 +2722,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2644 return undefined;2722 return undefined;
2645 }2723 }
2646 },2724 },
2647 [expandedWorkRows, workRowSizing.fixedRowHeight],2725 [expandedReasoningMessageIds, expandedWorkRows, workRowSizing.fixedRowHeight],
2648 );2726 );
26492727
2650 // Disclosures can mount existing offscreen rows as well as new work rows.2728 // Disclosures can mount existing offscreen rows as well as new work rows.
...@@ -2662,14 +2740,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2662,14 +2740,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2662 onEditPendingMessage: props.onEditPendingMessage,2740 onEditPendingMessage: props.onEditPendingMessage,
2663 copiedRowId,2741 copiedRowId,
2664 expandedWorkRows,2742 expandedWorkRows,
2743 expandedReasoningMessageIds,
2665 workRowSizing,2744 workRowSizing,
2666 workGroupScrollPositions,2745 workGroupScrollPositions,
2667 terminalAssistantMessageIds,2746 terminalAssistantMessageIds,
2668 unsettledTurnId,2747 unsettledTurnId,
2748 isWorking: props.activeWorkStartedAt !== null,
2669 onCopyWorkRow,2749 onCopyWorkRow,
2670 onToggleWorkGroup,2750 onToggleWorkGroup,
2671 onToggleWorkRow,2751 onToggleWorkRow,
2672 onToggleTurnFold,2752 onToggleTurnFold,
2753 onToggleReasoning,
2673 onPressPreview,2754 onPressPreview,
2674 onPressVideo,2755 onPressVideo,
2675 markdownLinkHandlers,2756 markdownLinkHandlers,
...@@ -2696,10 +2777,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2696,10 +2777,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2696 copiedRowId,2777 copiedRowId,
2697 disclosureToggleSettling,2778 disclosureToggleSettling,
2698 expandedWorkRows,2779 expandedWorkRows,
2780 expandedReasoningMessageIds,
2699 workRowSizing,2781 workRowSizing,
2700 workGroupScrollPositions,2782 workGroupScrollPositions,
2701 terminalAssistantMessageIds,2783 terminalAssistantMessageIds,
2702 unsettledTurnId,2784 unsettledTurnId,
2785 props.activeWorkStartedAt,
2703 iconSubtleColor,2786 iconSubtleColor,
2704 screenColor,2787 screenColor,
2705 userBubbleColor,2788 userBubbleColor,
...@@ -2713,6 +2796,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2713,6 +2796,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2713 markdownLinkHandlers,2796 markdownLinkHandlers,
2714 onPressPreview,2797 onPressPreview,
2715 onPressVideo,2798 onPressVideo,
2799 onToggleReasoning,
2716 onToggleTurnFold,2800 onToggleTurnFold,
2717 onToggleWorkGroup,2801 onToggleWorkGroup,
2718 onToggleWorkRow,2802 onToggleWorkRow,
apps/mobile/src/features/threads/thread-work-log.tsx+79
...@@ -1150,6 +1150,85 @@ export function ThreadThinkingRow(props: {...@@ -1150,6 +1150,85 @@ export function ThreadThinkingRow(props: {
1150 );1150 );
1151}1151}
11521152
1153/**
1154 * A provider's thinking trace. Collapsed by default: reasoning is context for
1155 * the answer, not the answer. `expanded` lives on the feed so it survives row
1156 * recycling; `children` is the trace body and only mounts while open.
1157 */
1158export function ThreadReasoningRow(props: {
1159 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
1160 readonly iconSubtleColor: ColorValue;
1161 readonly expanded: boolean;
1162 readonly label: string;
1163 readonly streaming: boolean;
1164 readonly onToggle: () => void;
1165 readonly children: ReactNode;
1166}) {
1167 return (
1168 <View className={cn("-mx-1 px-1 py-0", props.expanded && "pb-1.5")}>
1169 <Pressable
1170 accessibilityRole="button"
1171 accessibilityState={{ expanded: props.expanded }}
1172 accessibilityLabel={props.label}
1173 accessibilityHint={`Double tap to ${props.expanded ? "hide" : "show"} the thinking trace.`}
1174 hitSlop={4}
1175 onPress={() => {
1176 void Haptics.selectionAsync();
1177 props.onToggle();
1178 }}
1179 className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle"
1180 style={{ minHeight: props.rowSizing.estimatedRowHeight }}
1181 >
1182 {props.streaming ? (
1183 <ShimmeringWorkContent
1184 key={props.rowSizing.textSizeKey}
1185 icon="brain"
1186 iconSubtleColor={props.iconSubtleColor}
1187 label={props.label}
1188 showIcon
1189 />
1190 ) : (
1191 <>
1192 <View className="h-6 w-6 shrink-0 items-center justify-center">
1193 <WorkLogIcon icon="brain" color={props.iconSubtleColor} />
1194 </View>
1195 <Text
1196 key={props.rowSizing.textSizeKey}
1197 className="min-w-0 flex-1 text-sm text-foreground-muted"
1198 numberOfLines={1}
1199 >
1200 {props.label}
1201 </Text>
1202 </>
1203 )}
1204 <ThreadDisclosureChevron
1205 expanded={props.expanded}
1206 collapsedDirection="right"
1207 size={11}
1208 tintColor={props.iconSubtleColor}
1209 />
1210 </Pressable>
1211 {props.expanded ? (
1212 <Animated.View
1213 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
1214 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
1215 layout={WORK_LOG_LAYOUT_TRANSITION}
1216 className="ml-7 mt-1 rounded-xl bg-subtle px-3 py-2"
1217 >
1218 <ScrollView
1219 nestedScrollEnabled
1220 directionalLockEnabled
1221 showsVerticalScrollIndicator
1222 className="max-h-80"
1223 >
1224 {props.children}
1225 </ScrollView>
1226 </Animated.View>
1227 ) : null}
1228 </View>
1229 );
1230}
1231
1153function ToolActivityIconView(props: {1232function ToolActivityIconView(props: {
1154 readonly environmentId: EnvironmentId;1233 readonly environmentId: EnvironmentId;
1155 readonly icon?: ToolActivityIcon;1234 readonly icon?: ToolActivityIcon;
apps/mobile/src/lib/threadActivity.ts+32-7
...@@ -1596,7 +1596,7 @@ function maxIsoTimestamp(a: string | null, b: string | null): string | null {...@@ -1596,7 +1596,7 @@ function maxIsoTimestamp(a: string | null, b: string | null): string | null {
1596 return bMs > aMs ? b : a;1596 return bMs > aMs ? b : a;
1597}1597}
15981598
1599function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null {1599export function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null {
1600 if (!latestTurn) {1600 if (!latestTurn) {
1601 return null;1601 return null;
1602 }1602 }
...@@ -1637,8 +1637,13 @@ function deriveThreadFeedTurnFolds(...@@ -1637,8 +1637,13 @@ function deriveThreadFeedTurnFolds(
1637 pendingUserBoundary = entry.message.createdAt;1637 pendingUserBoundary = entry.message.createdAt;
1638 continue;1638 continue;
1639 }1639 }
1640 // Thinking is work, so it folds with the rest of it. A provider that
1641 // interleaves a block with every tool call would otherwise leave dozens of
1642 // "Thought" rows standing beside the "Worked for ..." summary.
1643 // Nothing folds while the turn is live, which is when traces are watched.
1640 const turnId =1644 const turnId =
1641 entry.type === "message" && entry.message.role === "assistant"1645 entry.type === "message" &&
1646 (entry.message.role === "assistant" || entry.message.role === "reasoning")
1642 ? entry.message.turnId1647 ? entry.message.turnId
1643 : entry.type === "activity-group"1648 : entry.type === "activity-group"
1644 ? entry.turnId1649 ? entry.turnId
...@@ -1665,7 +1670,15 @@ function deriveThreadFeedTurnFolds(...@@ -1665,7 +1670,15 @@ function deriveThreadFeedTurnFolds(
1665 if (turnId === unsettledTurnId) {1670 if (turnId === unsettledTurnId) {
1666 continue;1671 continue;
1667 }1672 }
1668 if (entries.some((entry) => entry.type === "message" && entry.message.streaming)) {1673 // A live turn is already excluded above, so only an answer still being
1674 // written may hold a fold open. A thinking block stranded by a crashed
1675 // provider keeps its streaming flag forever and must not.
1676 if (
1677 entries.some(
1678 (entry) =>
1679 entry.type === "message" && entry.message.streaming && entry.message.role !== "reasoning",
1680 )
1681 ) {
1669 continue;1682 continue;
1670 }1683 }
16711684
...@@ -1685,13 +1698,16 @@ function deriveThreadFeedTurnFolds(...@@ -1685,13 +1698,16 @@ function deriveThreadFeedTurnFolds(
1685 continue;1698 continue;
1686 }1699 }
1687 // A lone compaction row stays visible on its own; it only folds away as1700 // A lone compaction row stays visible on its own; it only folds away as
1688 // part of a turn that already folds other work.1701 // part of a turn that already folds other work. Thinking is the same: a
1689 const hidesNonCompactionWork = entries.some(1702 // question answered by thought alone keeps its "Thought" row
1703 // rather than collapsing behind a "Worked for ..." that hides nothing else.
1704 const hidesFoldableWork = entries.some(
1690 (entry) =>1705 (entry) =>
1691 hiddenEntryIds.has(entry.id) &&1706 hiddenEntryIds.has(entry.id) &&
1692 !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)),1707 !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) &&
1708 !(entry.type === "message" && entry.message.role === "reasoning"),
1693 );1709 );
1694 if (!hidesNonCompactionWork) {1710 if (!hidesFoldableWork) {
1695 continue;1711 continue;
1696 }1712 }
16971713
...@@ -1819,6 +1835,15 @@ export function deriveThreadFeedPresentation(...@@ -1819,6 +1835,15 @@ export function deriveThreadFeedPresentation(
1819 !result.some(1835 !result.some(
1820 (row) =>1836 (row) =>
1821 (row.type === "work-toggle" && row.shimmer) ||1837 (row.type === "work-toggle" && row.shimmer) ||
1838 // A live thinking block is the real version of this row, so it takes
1839 // the slot instead of sitting under a second "Thinking". Scoped to the
1840 // live turn: a block stranded by a killed server must not silence this
1841 // row for every turn that follows.
1842 (row.type === "message" &&
1843 row.message.role === "reasoning" &&
1844 row.message.streaming &&
1845 row.message.turnId !== null &&
1846 row.message.turnId === unsettledTurnId) ||
1822 // A working spawn card is the live activity: its status line shows1847 // A working spawn card is the live activity: its status line shows
1823 // what the agents are doing, so a Thinking row under it would lie.1848 // what the agents are doing, so a Thinking row under it would lie.
1824 (row.type === "agent-spawn" &&1849 (row.type === "agent-spawn" &&
apps/server/src/orchestration/ActivityPayloadProjection.ts+19-1
...@@ -645,11 +645,17 @@ function dropSupersededToolUpdatedActivities(...@@ -645,11 +645,17 @@ function dropSupersededToolUpdatedActivities(
645645
646export function projectThreadDetailSnapshot(646export function projectThreadDetailSnapshot(
647 snapshot: OrchestrationThreadDetailSnapshot,647 snapshot: OrchestrationThreadDetailSnapshot,
648 reasoningMessages = true,
648): OrchestrationThreadDetailSnapshot {649): OrchestrationThreadDetailSnapshot {
649 return {650 return {
650 ...snapshot,651 ...snapshot,
651 thread: {652 thread: {
652 ...snapshot.thread,653 ...snapshot.thread,
654 messages: reasoningMessages
655 ? snapshot.thread.messages
656 : snapshot.thread.messages.map((message) =>
657 message.role === "reasoning" ? { ...message, role: "system" as const } : message,
658 ),
653 activities: dropSupersededToolUpdatedActivities(659 activities: dropSupersededToolUpdatedActivities(
654 dropStaleContextWindowActivities(snapshot.thread.activities),660 dropStaleContextWindowActivities(snapshot.thread.activities),
655 ).map(projectActivityPayload),661 ).map(projectActivityPayload),
...@@ -657,7 +663,19 @@ export function projectThreadDetailSnapshot(...@@ -657,7 +663,19 @@ export function projectThreadDetailSnapshot(
657 };663 };
658}664}
659665
660export function projectActivityEvent(event: OrchestrationEvent): OrchestrationEvent {666export function projectActivityEvent(
667 event: OrchestrationEvent,
668 reasoningMessages = true,
669): OrchestrationEvent {
670 // Preserve sequence watermarks and message identities for clients whose role
671 // decoder predates reasoning. Filtering would strand their history pages.
672 if (
673 !reasoningMessages &&
674 event.type === "thread.message-sent" &&
675 event.payload.role === "reasoning"
676 ) {
677 return { ...event, payload: { ...event.payload, role: "system" } };
678 }
661 if (event.type !== "thread.activity-appended") {679 if (event.type !== "thread.activity-appended") {
662 return event;680 return event;
663 }681 }
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+4
...@@ -1056,6 +1056,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1056,6 +1056,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1056 AND threads.archived_at IS NULL1056 AND threads.archived_at IS NULL
1057 AND projects.deleted_at IS NULL1057 AND projects.deleted_at IS NULL
1058 AND messages.is_streaming = 01058 AND messages.is_streaming = 0
1059 -- Only these two roles are searchable, and the CASE above depends
1060 -- on it: reasoning is deliberately excluded so a thinking trace
1061 -- cannot surface in the command palette, and widening this filter
1062 -- would label it 'assistant' rather than adding a source.
1059 AND (1063 AND (
1060 messages.role = 'user'1064 messages.role = 'user'
1061 OR (1065 OR (
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+327-1
...@@ -1345,7 +1345,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1345,7 +1345,7 @@ describe("ProviderRuntimeIngestion", () => {
1345 const harness = await createHarness();1345 const harness = await createHarness();
1346 const initial = await harness.readModel();1346 const initial = await harness.readModel();
13471347
1348 for (const streamKind of ["reasoning_text", "command_output", "file_change_output"] as const) {1348 for (const streamKind of ["command_output", "file_change_output"] as const) {
1349 harness.emit({1349 harness.emit({
1350 type: "content.delta",1350 type: "content.delta",
1351 eventId: asEventId(`evt-ignored-${streamKind}`),1351 eventId: asEventId(`evt-ignored-${streamKind}`),
...@@ -1421,6 +1421,332 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1421,6 +1421,332 @@ describe("ProviderRuntimeIngestion", () => {
1421 expect(message?.streaming).toBe(false);1421 expect(message?.streaming).toBe(false);
1422 });1422 });
14231423
1424 it("streams reasoning deltas into a finalized reasoning message", async () => {
1425 const harness = await createHarness();
1426 const now = "2026-01-01T00:00:00.000Z";
1427
1428 for (const delta of ["Weighing ", "the options"]) {
1429 harness.emit({
1430 type: "content.delta",
1431 eventId: asEventId(`evt-reasoning-${delta.trim()}`),
1432 provider: ProviderDriverKind.make("codex"),
1433 createdAt: now,
1434 threadId: asThreadId("thread-1"),
1435 turnId: asTurnId("turn-reasoning"),
1436 itemId: asItemId("item-r1"),
1437 payload: { streamKind: "reasoning_text", delta },
1438 });
1439 }
1440 harness.emit({
1441 type: "item.completed",
1442 eventId: asEventId("evt-reasoning-completed"),
1443 provider: ProviderDriverKind.make("codex"),
1444 createdAt: now,
1445 threadId: asThreadId("thread-1"),
1446 turnId: asTurnId("turn-reasoning"),
1447 itemId: asItemId("item-r1"),
1448 payload: { itemType: "reasoning", status: "completed" },
1449 });
1450
1451 const thread = await waitForThread(harness.readModel, (entry) =>
1452 entry.messages.some(
1453 (message: ProviderRuntimeTestMessage) =>
1454 message.role === "reasoning" &&
1455 !message.streaming &&
1456 message.text === "Weighing the options",
1457 ),
1458 );
1459 const message = thread.messages.find(
1460 (entry: ProviderRuntimeTestMessage) => entry.role === "reasoning",
1461 );
1462 expect(message?.text).toBe("Weighing the options");
1463 expect(message?.streaming).toBe(false);
1464 expect(thread.messages.some((entry) => entry.role === "assistant")).toBe(false);
1465 });
1466
1467 it("keeps a reasoning summary's parts apart", async () => {
1468 const harness = await createHarness();
1469 const now = "2026-01-01T00:00:00.000Z";
1470
1471 for (const [summaryIndex, delta] of [
1472 [0, "**First**"],
1473 [1, "**Second**"],
1474 ] as const) {
1475 harness.emit({
1476 type: "content.delta",
1477 eventId: asEventId(`evt-summary-${summaryIndex}`),
1478 provider: ProviderDriverKind.make("codex"),
1479 createdAt: now,
1480 threadId: asThreadId("thread-1"),
1481 turnId: asTurnId("turn-summary"),
1482 itemId: asItemId("item-s1"),
1483 payload: { streamKind: "reasoning_summary_text", delta, summaryIndex },
1484 });
1485 }
1486 harness.emit({
1487 type: "item.completed",
1488 eventId: asEventId("evt-summary-completed"),
1489 provider: ProviderDriverKind.make("codex"),
1490 createdAt: now,
1491 threadId: asThreadId("thread-1"),
1492 turnId: asTurnId("turn-summary"),
1493 itemId: asItemId("item-s1"),
1494 payload: { itemType: "reasoning", status: "completed" },
1495 });
1496
1497 const thread = await waitForThread(harness.readModel, (entry) =>
1498 entry.messages.some(
1499 (message: ProviderRuntimeTestMessage) =>
1500 message.role === "reasoning" &&
1501 !message.streaming &&
1502 message.text === "**First**\n\n**Second**",
1503 ),
1504 );
1505 const message = thread.messages.find(
1506 (entry: ProviderRuntimeTestMessage) => entry.role === "reasoning",
1507 );
1508 expect(message?.text).toBe("**First**\n\n**Second**");
1509 });
1510
1511 it("uses a reasoning item's detail when no reasoning deltas were streamed", async () => {
1512 const harness = await createHarness();
1513 const now = "2026-01-01T00:00:00.000Z";
1514
1515 harness.emit({
1516 type: "item.completed",
1517 eventId: asEventId("evt-reasoning-snapshot"),
1518 provider: ProviderDriverKind.make("codex"),
1519 createdAt: now,
1520 threadId: asThreadId("thread-1"),
1521 turnId: asTurnId("turn-snapshot"),
1522 itemId: asItemId("item-snapshot"),
1523 payload: {
1524 itemType: "reasoning",
1525 status: "completed",
1526 detail: "reasoning reported in one piece",
1527 },
1528 });
1529
1530 const thread = await waitForThread(harness.readModel, (entry) =>
1531 entry.messages.some(
1532 (message: ProviderRuntimeTestMessage) =>
1533 message.role === "reasoning" &&
1534 !message.streaming &&
1535 message.text === "reasoning reported in one piece",
1536 ),
1537 );
1538 const message = thread.messages.find(
1539 (entry: ProviderRuntimeTestMessage) => entry.role === "reasoning",
1540 );
1541 expect(message?.text).toBe("reasoning reported in one piece");
1542
1543 for (const [index, detail] of ["", " \n\t"].entries()) {
1544 harness.emit({
1545 type: "item.completed",
1546 eventId: asEventId(`evt-empty-reasoning-${index}`),
1547 provider: ProviderDriverKind.make("codex"),
1548 createdAt: now,
1549 threadId: asThreadId("thread-1"),
1550 turnId: asTurnId(`turn-empty-${index}`),
1551 itemId: asItemId(`item-empty-${index}`),
1552 payload: { itemType: "reasoning", status: "completed", detail },
1553 });
1554 }
1555
1556 // A repeated completion must rewrite that row, not add a second copy.
1557 harness.emit({
1558 type: "item.completed",
1559 eventId: asEventId("evt-reasoning-snapshot-repeat"),
1560 provider: ProviderDriverKind.make("codex"),
1561 createdAt: now,
1562 threadId: asThreadId("thread-1"),
1563 turnId: asTurnId("turn-snapshot"),
1564 itemId: asItemId("item-snapshot"),
1565 payload: {
1566 itemType: "reasoning",
1567 status: "completed",
1568 detail: "reasoning reported in one piece",
1569 },
1570 });
1571 await harness.drain();
1572 const after = await harness.readModel();
1573 const repeated = after.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1574 expect(
1575 repeated?.messages.filter((entry: ProviderRuntimeTestMessage) => entry.role === "reasoning")
1576 .length,
1577 ).toBe(1);
1578 });
1579
1580 it("keeps interleaved summary and raw reasoning in separate blocks", async () => {
1581 const harness = await createHarness();
1582 const now = "2026-01-01T00:00:00.000Z";
1583
1584 // Distinct timestamps: blocks are ordered by when the provider opened them.
1585 for (const [tag, at, streamKind, delta] of [
1586 ["a", "2026-01-01T00:00:01.000Z", "reasoning_summary_text", "summary one"],
1587 ["b", "2026-01-01T00:00:02.000Z", "reasoning_text", "raw one"],
1588 ["c", "2026-01-01T00:00:03.000Z", "reasoning_summary_text", "summary two"],
1589 ] as const) {
1590 harness.emit({
1591 type: "content.delta",
1592 eventId: asEventId(`evt-interleaved-${tag}`),
1593 provider: ProviderDriverKind.make("codex"),
1594 createdAt: at,
1595 threadId: asThreadId("thread-1"),
1596 turnId: asTurnId("turn-interleaved"),
1597 itemId: asItemId("item-interleaved"),
1598 payload: { streamKind, delta },
1599 });
1600 }
1601 harness.emit({
1602 type: "item.completed",
1603 eventId: asEventId("evt-interleaved-completed"),
1604 provider: ProviderDriverKind.make("codex"),
1605 createdAt: now,
1606 threadId: asThreadId("thread-1"),
1607 turnId: asTurnId("turn-interleaved"),
1608 itemId: asItemId("item-interleaved"),
1609 payload: { itemType: "reasoning", status: "completed" },
1610 });
1611
1612 const thread = await waitForThread(
1613 harness.readModel,
1614 (entry) =>
1615 entry.messages.filter(
1616 (message: ProviderRuntimeTestMessage) =>
1617 message.role === "reasoning" && !message.streaming,
1618 ).length === 3,
1619 );
1620 const reasoning = thread.messages.filter(
1621 (entry: ProviderRuntimeTestMessage) => entry.role === "reasoning",
1622 );
1623 expect(reasoning.map((entry) => entry.text)).toEqual(["summary one", "raw one", "summary two"]);
1624 expect(new Set(reasoning.map((entry) => entry.id)).size).toBe(3);
1625 });
1626
1627 it.each([true, false])(
1628 "closes reasoning when the assistant answers (deltas: %s)",
1629 async (withDeltas) => {
1630 const harness = await createHarness();
1631 const now = "2026-01-01T00:00:00.000Z";
1632
1633 harness.emit({
1634 type: "content.delta",
1635 eventId: asEventId("evt-think-before-answer"),
1636 provider: ProviderDriverKind.make("claude"),
1637 createdAt: now,
1638 threadId: asThreadId("thread-1"),
1639 turnId: asTurnId("turn-answer"),
1640 payload: { streamKind: "reasoning_text", delta: "thinking it through" },
1641 });
1642 if (withDeltas) {
1643 harness.emit({
1644 type: "content.delta",
1645 eventId: asEventId("evt-answer-after-think"),
1646 provider: ProviderDriverKind.make("claude"),
1647 createdAt: now,
1648 threadId: asThreadId("thread-1"),
1649 turnId: asTurnId("turn-answer"),
1650 itemId: asItemId("item-a1"),
1651 payload: { streamKind: "assistant_text", delta: "the answer" },
1652 });
1653 }
1654 harness.emit({
1655 type: "item.completed",
1656 eventId: asEventId("evt-answer-completed"),
1657 provider: ProviderDriverKind.make("claude"),
1658 createdAt: now,
1659 threadId: asThreadId("thread-1"),
1660 turnId: asTurnId("turn-answer"),
1661 itemId: asItemId("item-a1"),
1662 payload: {
1663 itemType: "assistant_message",
1664 status: "completed",
1665 ...(!withDeltas ? { detail: "the answer" } : {}),
1666 },
1667 });
1668
1669 const thread = await waitForThread(
1670 harness.readModel,
1671 (entry) =>
1672 entry.messages.some(
1673 (message: ProviderRuntimeTestMessage) =>
1674 message.role === "reasoning" && !message.streaming,
1675 ) &&
1676 entry.messages.some(
1677 (message: ProviderRuntimeTestMessage) =>
1678 message.role === "assistant" && !message.streaming,
1679 ),
1680 );
1681 const reasoning = thread.messages.find(
1682 (entry: ProviderRuntimeTestMessage) => entry.role === "reasoning",
1683 );
1684 expect(reasoning?.text).toBe("thinking it through");
1685 expect(reasoning?.streaming).toBe(false);
1686 const assistant = thread.messages.find(
1687 (entry: ProviderRuntimeTestMessage) => entry.role === "assistant",
1688 );
1689 expect(assistant?.text).toBe("the answer");
1690 },
1691 );
1692
1693 it("starts a new reasoning block after tool work", async () => {
1694 const harness = await createHarness();
1695 const now = "2026-01-01T00:00:00.000Z";
1696
1697 harness.emit({
1698 type: "content.delta",
1699 eventId: asEventId("evt-think-before-tool"),
1700 provider: ProviderDriverKind.make("claude"),
1701 createdAt: now,
1702 threadId: asThreadId("thread-1"),
1703 turnId: asTurnId("turn-tooled"),
1704 payload: { streamKind: "reasoning_text", delta: "before the tool" },
1705 });
1706 harness.emit({
1707 type: "item.started",
1708 eventId: asEventId("evt-tool-between-thoughts"),
1709 provider: ProviderDriverKind.make("claude"),
1710 createdAt: now,
1711 threadId: asThreadId("thread-1"),
1712 turnId: asTurnId("turn-tooled"),
1713 itemId: asItemId("item-tool-1"),
1714 payload: { itemType: "command_execution", status: "inProgress", title: "ls" },
1715 });
1716 harness.emit({
1717 type: "content.delta",
1718 eventId: asEventId("evt-think-after-tool"),
1719 provider: ProviderDriverKind.make("claude"),
1720 createdAt: now,
1721 threadId: asThreadId("thread-1"),
1722 turnId: asTurnId("turn-tooled"),
1723 payload: { streamKind: "reasoning_text", delta: "after the tool" },
1724 });
1725 harness.emit({
1726 type: "item.completed",
1727 eventId: asEventId("evt-second-thought-completed"),
1728 provider: ProviderDriverKind.make("claude"),
1729 createdAt: now,
1730 threadId: asThreadId("thread-1"),
1731 turnId: asTurnId("turn-tooled"),
1732 payload: { itemType: "reasoning", status: "completed" },
1733 });
1734
1735 const thread = await waitForThread(
1736 harness.readModel,
1737 (entry) =>
1738 entry.messages.filter(
1739 (message: ProviderRuntimeTestMessage) =>
1740 message.role === "reasoning" && !message.streaming,
1741 ).length === 2,
1742 );
1743 const reasoning = thread.messages.filter(
1744 (entry: ProviderRuntimeTestMessage) => entry.role === "reasoning",
1745 );
1746 expect(reasoning.map((entry) => entry.text)).toEqual(["before the tool", "after the tool"]);
1747 expect(reasoning[0]?.streaming).toBe(false);
1748 });
1749
1424 it("uses assistant item completion detail when no assistant deltas were streamed", async () => {1750 it("uses assistant item completion detail when no assistant deltas were streamed", async () => {
1425 const harness = await createHarness();1751 const harness = await createHarness();
1426 const now = "2026-01-01T00:00:00.000Z";1752 const now = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+391-33
...@@ -57,6 +57,11 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings";...@@ -57,6 +57,11 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
57import { canReplaceThreadTitle } from "../threadTitles.ts";57import { canReplaceThreadTitle } from "../threadTitles.ts";
5858
59const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;59const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;
60// Suffixed, not prefixed: `clearTurnStateForSession` sweeps by thread prefix.
61const segmentStateKey = (threadId: ThreadId, turnId: TurnId, role: MessageStreamRole) =>
62 role === "reasoning"
63 ? `${providerTurnKey(threadId, turnId)}:reasoning`
64 : providerTurnKey(threadId, turnId);
60const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`;65const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`;
6166
62// Fallback when the in-memory description cache no longer has the task name67// Fallback when the in-memory description cache no longer has the task name
...@@ -270,11 +275,41 @@ function assistantSegmentBaseKeyFromEvent(event: ProviderRuntimeEvent): string {...@@ -270,11 +275,41 @@ function assistantSegmentBaseKeyFromEvent(event: ProviderRuntimeEvent): string {
270 return String(event.itemId ?? event.turnId ?? event.eventId);275 return String(event.itemId ?? event.turnId ?? event.eventId);
271}276}
272277
273function assistantSegmentMessageId(baseKey: string, segmentIndex: number): MessageId {278/**
279 * Reasoning shares the assistant segmenting, buffering and finalization
280 * machinery; only the message id namespace differs. The prefix is what tells a
281 * buffered segment apart when it is flushed or finalized long after the delta
282 * that opened it, so the role never has to be threaded through those paths.
283 */
284type MessageStreamRole = "assistant" | "reasoning";
285
286const REASONING_MESSAGE_ID_PREFIX = "reasoning:";
287
288function messageStreamRoleOf(messageId: MessageId): MessageStreamRole {
289 return messageId.startsWith(REASONING_MESSAGE_ID_PREFIX) ? "reasoning" : "assistant";
290}
291
292function assistantSegmentMessageId(
293 baseKey: string,
294 segmentIndex: number,
295 role: MessageStreamRole = "assistant",
296): MessageId {
297 const prefix = role === "reasoning" ? REASONING_MESSAGE_ID_PREFIX : "assistant:";
274 return MessageId.make(298 return MessageId.make(
275 segmentIndex === 0 ? `assistant:${baseKey}` : `assistant:${baseKey}:segment:${segmentIndex}`,299 segmentIndex === 0 ? `${prefix}${baseKey}` : `${prefix}${baseKey}:segment:${segmentIndex}`,
276 );300 );
277}301}
302
303/** A provider may stream a reasoning summary and the raw chain of thought over
304 * the same item. They are different texts, so they get different segments. */
305function reasoningSegmentBaseKeyFromEvent(
306 event: ProviderRuntimeEvent,
307 streamKind: "reasoning_text" | "reasoning_summary_text",
308): string {
309 const stream = streamKind === "reasoning_summary_text" ? "summary" : "raw";
310 return `${stream}:${assistantSegmentBaseKeyFromEvent(event)}`;
311}
312
278function buildContextWindowActivityPayload(313function buildContextWindowActivityPayload(
279 event: ProviderRuntimeEvent,314 event: ProviderRuntimeEvent,
280): ThreadTokenUsageSnapshot | undefined {315): ThreadTokenUsageSnapshot | undefined {
...@@ -1005,6 +1040,23 @@ const make = Effect.gen(function* () {...@@ -1005,6 +1040,23 @@ const make = Effect.gen(function* () {
1005 lookup: () => Effect.succeed(0),1040 lookup: () => Effect.succeed(0),
1006 });1041 });
10071042
1043 // When a thinking block opened, so "Thought for ..." measures the model's
1044 // time and not the moment buffered text happened to be flushed.
1045 const reasoningStartedAtByMessageId = yield* Cache.make<MessageId, string>({
1046 capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY,
1047 timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL,
1048 lookup: () => Effect.succeed(""),
1049 });
1050
1051 // Codex splits a reasoning trace into indexed parts, summary and raw alike.
1052 // The index is the only signal that one part ended and the next began, so the
1053 // blank line that keeps them readable has to be inserted here.
1054 const reasoningPartIndexByMessageId = yield* Cache.make<MessageId, number>({
1055 capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY,
1056 timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL,
1057 lookup: () => Effect.succeed(-1),
1058 });
1059
1008 const assistantSegmentStateByTurnKey = yield* Cache.make<string, AssistantSegmentState>({1060 const assistantSegmentStateByTurnKey = yield* Cache.make<string, AssistantSegmentState>({
1009 capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY,1061 capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY,
1010 timeToLive: TURN_MESSAGE_IDS_BY_TURN_TTL,1062 timeToLive: TURN_MESSAGE_IDS_BY_TURN_TTL,
...@@ -1104,20 +1156,31 @@ const make = Effect.gen(function* () {...@@ -1104,20 +1156,31 @@ const make = Effect.gen(function* () {
1104 const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) =>1156 const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) =>
1105 Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId));1157 Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId));
11061158
1107 const getAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId) =>1159 const getAssistantSegmentStateForTurn = (
1108 Cache.getOption(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId));1160 threadId: ThreadId,
1161 turnId: TurnId,
1162 role: MessageStreamRole = "assistant",
1163 ) => Cache.getOption(assistantSegmentStateByTurnKey, segmentStateKey(threadId, turnId, role));
11091164
1110 const setAssistantSegmentStateForTurn = (1165 const setAssistantSegmentStateForTurn = (
1111 threadId: ThreadId,1166 threadId: ThreadId,
1112 turnId: TurnId,1167 turnId: TurnId,
1113 state: AssistantSegmentState,1168 state: AssistantSegmentState,
1114 ) => Cache.set(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId), state);1169 role: MessageStreamRole = "assistant",
1170 ) => Cache.set(assistantSegmentStateByTurnKey, segmentStateKey(threadId, turnId, role), state);
11151171
1116 const clearAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId) =>1172 const clearAssistantSegmentStateForTurn = (
1117 Cache.invalidate(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId));1173 threadId: ThreadId,
1174 turnId: TurnId,
1175 role: MessageStreamRole = "assistant",
1176 ) => Cache.invalidate(assistantSegmentStateByTurnKey, segmentStateKey(threadId, turnId, role));
11181177
1119 const getActiveAssistantMessageIdForTurn = (threadId: ThreadId, turnId: TurnId) =>1178 const getActiveAssistantMessageIdForTurn = (
1120 getAssistantSegmentStateForTurn(threadId, turnId).pipe(1179 threadId: ThreadId,
1180 turnId: TurnId,
1181 role: MessageStreamRole = "assistant",
1182 ) =>
1183 getAssistantSegmentStateForTurn(threadId, turnId, role).pipe(
1121 Effect.map((state) =>1184 Effect.map((state) =>
1122 Option.flatMap(state, (entry) =>1185 Option.flatMap(state, (entry) =>
1123 entry.activeMessageId ? Option.some(entry.activeMessageId) : Option.none(),1186 entry.activeMessageId ? Option.some(entry.activeMessageId) : Option.none(),
...@@ -1129,31 +1192,38 @@ const make = Effect.gen(function* () {...@@ -1129,31 +1192,38 @@ const make = Effect.gen(function* () {
1129 threadId: ThreadId;1192 threadId: ThreadId;
1130 turnId: TurnId;1193 turnId: TurnId;
1131 baseKey: string;1194 baseKey: string;
1132 }) =>1195 role?: MessageStreamRole;
1133 getAssistantSegmentStateForTurn(input.threadId, input.turnId).pipe(1196 }) => {
1197 const role = input.role ?? "assistant";
1198 return getAssistantSegmentStateForTurn(input.threadId, input.turnId, role).pipe(
1134 Effect.flatMap((existingState) =>1199 Effect.flatMap((existingState) =>
1135 Effect.gen(function* () {1200 Effect.gen(function* () {
1136 const nextState = Option.match(existingState, {1201 const nextState = Option.match(existingState, {
1137 onNone: () => ({1202 onNone: () => ({
1138 baseKey: input.baseKey,1203 baseKey: input.baseKey,
1139 nextSegmentIndex: 1,1204 nextSegmentIndex: 1,
1140 activeMessageId: assistantSegmentMessageId(input.baseKey, 0),1205 activeMessageId: assistantSegmentMessageId(input.baseKey, 0, role),
1141 }),1206 }),
1142 onSome: (state) => {1207 onSome: (state) => {
1143 const segmentIndex = state.baseKey === input.baseKey ? state.nextSegmentIndex : 0;1208 // Reasoning never resets the index on a new base key: one item can
1144 const messageId = assistantSegmentMessageId(input.baseKey, segmentIndex);1209 // stream a summary and a raw trace, and summary -> raw -> summary
1210 // would otherwise reuse the id of the first, finished block.
1211 const reuseIndex = state.baseKey === input.baseKey || role === "reasoning";
1212 const segmentIndex = reuseIndex ? state.nextSegmentIndex : 0;
1213 const messageId = assistantSegmentMessageId(input.baseKey, segmentIndex, role);
1145 return {1214 return {
1146 baseKey: input.baseKey,1215 baseKey: input.baseKey,
1147 nextSegmentIndex: state.baseKey === input.baseKey ? state.nextSegmentIndex + 1 : 1,1216 nextSegmentIndex: reuseIndex ? state.nextSegmentIndex + 1 : 1,
1148 activeMessageId: messageId,1217 activeMessageId: messageId,
1149 } satisfies AssistantSegmentState;1218 } satisfies AssistantSegmentState;
1150 },1219 },
1151 });1220 });
1152 yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, nextState);1221 yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, nextState, role);
1153 return nextState.activeMessageId!;1222 return nextState.activeMessageId!;
1154 }),1223 }),
1155 ),1224 ),
1156 );1225 );
1226 };
11571227
1158 const getOrCreateAssistantMessageId = (input: {1228 const getOrCreateAssistantMessageId = (input: {
1159 threadId: ThreadId;1229 threadId: ThreadId;
...@@ -1180,6 +1250,52 @@ const make = Effect.gen(function* () {...@@ -1180,6 +1250,52 @@ const make = Effect.gen(function* () {
1180 });1250 });
1181 });1251 });
11821252
1253 /**
1254 * Unlike assistant text, reasoning has no reliable per-block item id on every
1255 * provider, so a turn's blocks can share a base key. Switching base key (a new
1256 * reasoning item, or summary vs raw) closes the open block instead of
1257 * appending to it.
1258 */
1259 const getOrCreateReasoningMessageId = (input: {
1260 threadId: ThreadId;
1261 event: ProviderRuntimeEvent;
1262 baseKey: string;
1263 createdAt: string;
1264 turnId: TurnId;
1265 }) =>
1266 Effect.gen(function* () {
1267 const state = yield* getAssistantSegmentStateForTurn(
1268 input.threadId,
1269 input.turnId,
1270 "reasoning",
1271 );
1272 const activeMessageId = Option.flatMap(state, (entry) =>
1273 entry.activeMessageId ? Option.some(entry.activeMessageId) : Option.none(),
1274 );
1275 if (Option.isSome(activeMessageId)) {
1276 if (Option.getOrUndefined(state)?.baseKey === input.baseKey) {
1277 return activeMessageId.value;
1278 }
1279 yield* finalizeActiveSegmentForTurn({
1280 event: input.event,
1281 threadId: input.threadId,
1282 turnId: input.turnId,
1283 createdAt: input.createdAt,
1284 commandTag: "reasoning-complete-on-new-block",
1285 finalDeltaCommandTag: "reasoning-delta-finalize-on-new-block",
1286 hasProjectedMessage: false,
1287 role: "reasoning",
1288 });
1289 }
1290
1291 return yield* startAssistantSegmentForTurn({
1292 threadId: input.threadId,
1293 turnId: input.turnId,
1294 baseKey: input.baseKey,
1295 role: "reasoning",
1296 });
1297 });
1298
1183 const resolveResponseStreamingMode = (projectId: ProjectId) =>1299 const resolveResponseStreamingMode = (projectId: ProjectId) =>
1184 Effect.map(1300 Effect.map(
1185 serverSettingsService.getSettings,1301 serverSettingsService.getSettings,
...@@ -1270,7 +1386,15 @@ const make = Effect.gen(function* () {...@@ -1270,7 +1386,15 @@ const make = Effect.gen(function* () {
1270 Cache.invalidate(bufferedProposedPlanById, planId);1386 Cache.invalidate(bufferedProposedPlanById, planId);
12711387
1272 const clearAssistantMessageState = (messageId: MessageId) =>1388 const clearAssistantMessageState = (messageId: MessageId) =>
1273 clearBufferedAssistantText(messageId);1389 clearBufferedAssistantText(messageId).pipe(
1390 Effect.andThen(Cache.invalidate(reasoningPartIndexByMessageId, messageId)),
1391 Effect.andThen(Cache.invalidate(reasoningStartedAtByMessageId, messageId)),
1392 );
1393
1394 const reasoningStartedAt = (messageId: MessageId, fallback: string) =>
1395 Cache.getOption(reasoningStartedAtByMessageId, messageId).pipe(
1396 Effect.map((started) => Option.getOrElse(started, () => fallback) || fallback),
1397 );
12741398
1275 const flushBufferedAssistantMessage = (input: {1399 const flushBufferedAssistantMessage = (input: {
1276 event: ProviderRuntimeEvent;1400 event: ProviderRuntimeEvent;
...@@ -1286,14 +1410,17 @@ const make = Effect.gen(function* () {...@@ -1286,14 +1410,17 @@ const make = Effect.gen(function* () {
1286 return false;1410 return false;
1287 }1411 }
12881412
1413 const isReasoning = messageStreamRoleOf(input.messageId) === "reasoning";
1289 yield* orchestrationEngine.dispatch({1414 yield* orchestrationEngine.dispatch({
1290 type: "thread.message.assistant.delta",1415 type: isReasoning ? "thread.message.reasoning.delta" : "thread.message.assistant.delta",
1291 commandId: yield* providerCommandId(input.event, input.commandTag),1416 commandId: yield* providerCommandId(input.event, input.commandTag),
1292 threadId: input.threadId,1417 threadId: input.threadId,
1293 messageId: input.messageId,1418 messageId: input.messageId,
1294 delta: bufferedText,1419 delta: bufferedText,
1295 ...(input.turnId ? { turnId: input.turnId } : {}),1420 ...(input.turnId ? { turnId: input.turnId } : {}),
1296 createdAt: input.createdAt,1421 createdAt: isReasoning
1422 ? yield* reasoningStartedAt(input.messageId, input.createdAt)
1423 : input.createdAt,
1297 });1424 });
1298 return true;1425 return true;
1299 });1426 });
...@@ -1352,21 +1479,27 @@ const make = Effect.gen(function* () {...@@ -1352,21 +1479,27 @@ const make = Effect.gen(function* () {
1352 : "";1479 : "";
1353 const hasRenderableText = hasRenderableAssistantText(text);1480 const hasRenderableText = hasRenderableAssistantText(text);
13541481
1482 const isReasoning = messageStreamRoleOf(input.messageId) === "reasoning";
1483
1355 if (hasRenderableText) {1484 if (hasRenderableText) {
1356 yield* orchestrationEngine.dispatch({1485 yield* orchestrationEngine.dispatch({
1357 type: "thread.message.assistant.delta",1486 type: isReasoning ? "thread.message.reasoning.delta" : "thread.message.assistant.delta",
1358 commandId: yield* providerCommandId(input.event, input.finalDeltaCommandTag),1487 commandId: yield* providerCommandId(input.event, input.finalDeltaCommandTag),
1359 threadId: input.threadId,1488 threadId: input.threadId,
1360 messageId: input.messageId,1489 messageId: input.messageId,
1361 delta: text,1490 delta: text,
1362 ...(input.turnId ? { turnId: input.turnId } : {}),1491 ...(input.turnId ? { turnId: input.turnId } : {}),
1363 createdAt: input.createdAt,1492 createdAt: isReasoning
1493 ? yield* reasoningStartedAt(input.messageId, input.createdAt)
1494 : input.createdAt,
1364 });1495 });
1365 }1496 }
13661497
1367 if (input.hasProjectedMessage || hasRenderableText) {1498 if (input.hasProjectedMessage || hasRenderableText) {
1368 yield* orchestrationEngine.dispatch({1499 yield* orchestrationEngine.dispatch({
1369 type: "thread.message.assistant.complete",1500 type: isReasoning
1501 ? "thread.message.reasoning.complete"
1502 : "thread.message.assistant.complete",
1370 commandId: yield* providerCommandId(input.event, input.commandTag),1503 commandId: yield* providerCommandId(input.event, input.commandTag),
1371 threadId: input.threadId,1504 threadId: input.threadId,
1372 messageId: input.messageId,1505 messageId: input.messageId,
...@@ -1377,7 +1510,7 @@ const make = Effect.gen(function* () {...@@ -1377,7 +1510,7 @@ const make = Effect.gen(function* () {
1377 yield* clearAssistantMessageState(input.messageId);1510 yield* clearAssistantMessageState(input.messageId);
1378 });1511 });
13791512
1380 const finalizeActiveAssistantSegmentForTurn = (input: {1513 const finalizeActiveSegmentForTurn = (input: {
1381 event: ProviderRuntimeEvent;1514 event: ProviderRuntimeEvent;
1382 threadId: ThreadId;1515 threadId: ThreadId;
1383 turnId: TurnId;1516 turnId: TurnId;
...@@ -1386,16 +1519,29 @@ const make = Effect.gen(function* () {...@@ -1386,16 +1519,29 @@ const make = Effect.gen(function* () {
1386 finalDeltaCommandTag: string;1519 finalDeltaCommandTag: string;
1387 hasProjectedMessage: boolean;1520 hasProjectedMessage: boolean;
1388 flushedMessageIds?: ReadonlySet<MessageId>;1521 flushedMessageIds?: ReadonlySet<MessageId>;
1522 role?: MessageStreamRole;
1523 fallbackText?: string;
1389 }) =>1524 }) =>
1390 Effect.gen(function* () {1525 Effect.gen(function* () {
1526 const role = input.role ?? "assistant";
1391 const activeMessageId = yield* getActiveAssistantMessageIdForTurn(1527 const activeMessageId = yield* getActiveAssistantMessageIdForTurn(
1392 input.threadId,1528 input.threadId,
1393 input.turnId,1529 input.turnId,
1530 role,
1394 );1531 );
1395 if (Option.isNone(activeMessageId)) {1532 if (Option.isNone(activeMessageId)) {
1396 return;1533 return;
1397 }1534 }
13981535
1536 // A block whose deltas already reached the projection must still be
1537 // completed, or it stays flagged as streaming forever.
1538 const alreadyProjected =
1539 input.hasProjectedMessage ||
1540 (input.flushedMessageIds?.has(activeMessageId.value) ?? false) ||
1541 (role === "reasoning"
1542 ? (yield* getThreadMessageById(input.threadId, activeMessageId.value)) !== undefined
1543 : false);
1544
1399 yield* finalizeAssistantMessage({1545 yield* finalizeAssistantMessage({
1400 event: input.event,1546 event: input.event,
1401 threadId: input.threadId,1547 threadId: input.threadId,
...@@ -1404,18 +1550,21 @@ const make = Effect.gen(function* () {...@@ -1404,18 +1550,21 @@ const make = Effect.gen(function* () {
1404 createdAt: input.createdAt,1550 createdAt: input.createdAt,
1405 commandTag: input.commandTag,1551 commandTag: input.commandTag,
1406 finalDeltaCommandTag: input.finalDeltaCommandTag,1552 finalDeltaCommandTag: input.finalDeltaCommandTag,
1407 hasProjectedMessage:1553 hasProjectedMessage: alreadyProjected,
1408 input.hasProjectedMessage ||1554 ...(input.fallbackText !== undefined ? { fallbackText: input.fallbackText } : {}),
1409 (input.flushedMessageIds?.has(activeMessageId.value) ?? false),
1410 });1555 });
1411 yield* forgetAssistantMessageId(input.threadId, input.turnId, activeMessageId.value);1556 yield* forgetAssistantMessageId(input.threadId, input.turnId, activeMessageId.value);
14121557
1413 const state = yield* getAssistantSegmentStateForTurn(input.threadId, input.turnId);1558 // The segment index is deliberately preserved: reasoning blocks in one
1559 // turn can share a base key, so resetting it would reopen a closed block.
1560 const state = yield* getAssistantSegmentStateForTurn(input.threadId, input.turnId, role);
1414 if (Option.isSome(state)) {1561 if (Option.isSome(state)) {
1415 yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, {1562 yield* setAssistantSegmentStateForTurn(
1416 ...state.value,1563 input.threadId,
1417 activeMessageId: null,1564 input.turnId,
1418 });1565 { ...state.value, activeMessageId: null },
1566 role,
1567 );
1419 }1568 }
1420 });1569 });
14211570
...@@ -1596,7 +1745,12 @@ const make = Effect.gen(function* () {...@@ -1596,7 +1745,12 @@ const make = Effect.gen(function* () {
15961745
1597 const processRuntimeEvent = (event: ProviderRuntimeEvent) =>1746 const processRuntimeEvent = (event: ProviderRuntimeEvent) =>
1598 Effect.gen(function* () {1747 Effect.gen(function* () {
1599 if (event.type === "content.delta" && event.payload.streamKind !== "assistant_text") {1748 if (
1749 event.type === "content.delta" &&
1750 event.payload.streamKind !== "assistant_text" &&
1751 event.payload.streamKind !== "reasoning_text" &&
1752 event.payload.streamKind !== "reasoning_summary_text"
1753 ) {
1600 return;1754 return;
1601 }1755 }
16021756
...@@ -1772,11 +1926,98 @@ const make = Effect.gen(function* () {...@@ -1772,11 +1926,98 @@ const make = Effect.gen(function* () {
1772 event.type === "content.delta" && event.payload.streamKind === "assistant_text"1926 event.type === "content.delta" && event.payload.streamKind === "assistant_text"
1773 ? event.payload.delta1927 ? event.payload.delta
1774 : undefined;1928 : undefined;
1929 const reasoningDelta =
1930 event.type === "content.delta" &&
1931 (event.payload.streamKind === "reasoning_text" ||
1932 event.payload.streamKind === "reasoning_summary_text")
1933 ? {
1934 streamKind: event.payload.streamKind,
1935 delta: event.payload.delta,
1936 summaryIndex: event.payload.summaryIndex,
1937 contentIndex: event.payload.contentIndex,
1938 }
1939 : undefined;
1775 const proposedPlanDelta =1940 const proposedPlanDelta =
1776 event.type === "turn.proposed.delta" ? event.payload.delta : undefined;1941 event.type === "turn.proposed.delta" ? event.payload.delta : undefined;
17771942
1943 const reasoningTurnId = toTurnId(event.turnId);
1944 // Every close path for a thinking block is keyed by turn. Without one the
1945 // block could never be completed, and a row stuck mid-thought is worse
1946 // than no row at all.
1947 if (reasoningDelta && reasoningDelta.delta.length > 0 && reasoningTurnId) {
1948 const turnId = reasoningTurnId;
1949 const reasoningMessageId = yield* getOrCreateReasoningMessageId({
1950 threadId: thread.id,
1951 event,
1952 baseKey: reasoningSegmentBaseKeyFromEvent(event, reasoningDelta.streamKind),
1953 createdAt: now,
1954 turnId,
1955 });
1956 yield* rememberAssistantMessageId(thread.id, turnId, reasoningMessageId);
1957
1958 if (
1959 Option.getOrElse(
1960 yield* Cache.getOption(reasoningStartedAtByMessageId, reasoningMessageId),
1961 () => "",
1962 ) === ""
1963 ) {
1964 yield* Cache.set(reasoningStartedAtByMessageId, reasoningMessageId, now);
1965 }
1966
1967 let delta = reasoningDelta.delta;
1968 const partIndex = reasoningDelta.summaryIndex ?? reasoningDelta.contentIndex;
1969 if (partIndex !== undefined) {
1970 const lastIndex = Option.getOrElse(
1971 yield* Cache.getOption(reasoningPartIndexByMessageId, reasoningMessageId),
1972 () => -1,
1973 );
1974 if (lastIndex >= 0 && lastIndex !== partIndex) {
1975 delta = `\n\n${delta}`;
1976 }
1977 yield* Cache.set(reasoningPartIndexByMessageId, reasoningMessageId, partIndex);
1978 }
1979
1980 // Reasoning is never delivered token by token, even when the project
1981 // asks for it: the block is collapsed by default, so a command, an
1982 // event-store write and a fan-out per token would buy nothing. Traces
1983 // are longer than the answers they precede.
1984 const streamingMode = yield* resolveResponseStreamingMode(thread.projectId);
1985 const reasoningMode = streamingMode === "token" ? "paragraph" : streamingMode;
1986 const spillChunk = yield* appendBufferedAssistantText(
1987 reasoningMessageId,
1988 delta,
1989 reasoningMode,
1990 yield* Clock.currentTimeMillis,
1991 );
1992 if (spillChunk.length > 0) {
1993 yield* orchestrationEngine.dispatch({
1994 type: "thread.message.reasoning.delta",
1995 commandId: yield* providerCommandId(event, "reasoning-delta-buffer-spill"),
1996 threadId: thread.id,
1997 messageId: reasoningMessageId,
1998 delta: spillChunk,
1999 turnId,
2000 createdAt: yield* reasoningStartedAt(reasoningMessageId, now),
2001 });
2002 }
2003 }
2004
1778 if (assistantDelta && assistantDelta.length > 0) {2005 if (assistantDelta && assistantDelta.length > 0) {
1779 const turnId = toTurnId(event.turnId);2006 const turnId = toTurnId(event.turnId);
2007 // Visible text ends the thinking block that preceded it, so the next
2008 // block does not swallow this answer.
2009 if (turnId) {
2010 yield* finalizeActiveSegmentForTurn({
2011 event,
2012 threadId: thread.id,
2013 turnId,
2014 createdAt: now,
2015 commandTag: "reasoning-complete-on-assistant-text",
2016 finalDeltaCommandTag: "reasoning-delta-finalize-on-assistant-text",
2017 hasProjectedMessage: false,
2018 role: "reasoning",
2019 });
2020 }
1780 const assistantMessageId = yield* getOrCreateAssistantMessageId({2021 const assistantMessageId = yield* getOrCreateAssistantMessageId({
1781 threadId: thread.id,2022 threadId: thread.id,
1782 event,2023 event,
...@@ -1845,7 +2086,18 @@ const make = Effect.gen(function* () {...@@ -1845,7 +2086,18 @@ const make = Effect.gen(function* () {
1845 : "assistant-delta-flush-on-user-input-requested",2086 : "assistant-delta-flush-on-user-input-requested",
1846 })2087 })
1847 : new Set<MessageId>();2088 : new Set<MessageId>();
1848 yield* finalizeActiveAssistantSegmentForTurn({2089 yield* finalizeActiveSegmentForTurn({
2090 event,
2091 threadId: thread.id,
2092 turnId: pauseForUserTurnId,
2093 createdAt: now,
2094 commandTag: "reasoning-complete-on-pause",
2095 finalDeltaCommandTag: "reasoning-delta-finalize-on-pause",
2096 hasProjectedMessage: false,
2097 role: "reasoning",
2098 flushedMessageIds,
2099 });
2100 yield* finalizeActiveSegmentForTurn({
1849 event,2101 event,
1850 threadId: thread.id,2102 threadId: thread.id,
1851 turnId: pauseForUserTurnId,2103 turnId: pauseForUserTurnId,
...@@ -1868,6 +2120,99 @@ const make = Effect.gen(function* () {...@@ -1868,6 +2120,99 @@ const make = Effect.gen(function* () {
1868 yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now);2120 yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now);
1869 }2121 }
18702122
2123 // Tool work ends the thinking block that led to it. Without this a
2124 // provider that reuses one reasoning stream across a turn (Claude has no
2125 // per-block id) would append post-tool thinking to a block that already
2126 // sits above the tool row.
2127 if (event.type === "item.started" && isToolLifecycleItemType(event.payload.itemType)) {
2128 const toolTurnId = toTurnId(event.turnId);
2129 if (toolTurnId) {
2130 yield* finalizeActiveSegmentForTurn({
2131 event,
2132 threadId: thread.id,
2133 turnId: toolTurnId,
2134 createdAt: now,
2135 commandTag: "reasoning-complete-on-tool-start",
2136 finalDeltaCommandTag: "reasoning-delta-finalize-on-tool-start",
2137 hasProjectedMessage: false,
2138 role: "reasoning",
2139 });
2140 }
2141 }
2142
2143 if (event.type === "item.completed" && event.payload.itemType === "reasoning") {
2144 const turnId = toTurnId(event.turnId);
2145 if (turnId) {
2146 const activeReasoningMessageId = yield* getActiveAssistantMessageIdForTurn(
2147 thread.id,
2148 turnId,
2149 "reasoning",
2150 );
2151 // The item detail is a whole-block snapshot, so it may only stand in
2152 // for deltas that never arrived. Appending it to a streamed block
2153 // would print the reasoning twice.
2154 const existingReasoningMessage = Option.isSome(activeReasoningMessageId)
2155 ? yield* getThreadMessageById(thread.id, activeReasoningMessageId.value)
2156 : undefined;
2157 const fallbackText =
2158 event.payload.detail !== undefined &&
2159 event.payload.detail.trim().length > 0 &&
2160 (existingReasoningMessage === undefined || existingReasoningMessage.text.length === 0)
2161 ? event.payload.detail
2162 : undefined;
2163
2164 if (Option.isNone(activeReasoningMessageId)) {
2165 // Segment state outlives a closed block, so its presence means this
2166 // turn already streamed a trace and the snapshot would duplicate it.
2167 const turnAlreadyStreamedReasoning = Option.isSome(
2168 yield* getAssistantSegmentStateForTurn(thread.id, turnId, "reasoning"),
2169 );
2170 // A provider can report a whole block at once without streaming it.
2171 // The id is derived from the item rather than the segment counter so
2172 // a repeated completion rewrites that row instead of adding a copy.
2173 if (fallbackText !== undefined && !turnAlreadyStreamedReasoning) {
2174 const snapshotMessageId = assistantSegmentMessageId(
2175 `snapshot:${event.itemId ?? event.eventId}`,
2176 0,
2177 "reasoning",
2178 );
2179 const existingSnapshot = yield* getThreadMessageById(thread.id, snapshotMessageId);
2180 if (existingSnapshot === undefined) {
2181 yield* orchestrationEngine.dispatch({
2182 type: "thread.message.reasoning.delta",
2183 commandId: yield* providerCommandId(event, "reasoning-delta-snapshot"),
2184 threadId: thread.id,
2185 messageId: snapshotMessageId,
2186 delta: fallbackText,
2187 turnId,
2188 createdAt: now,
2189 });
2190 yield* orchestrationEngine.dispatch({
2191 type: "thread.message.reasoning.complete",
2192 commandId: yield* providerCommandId(event, "reasoning-complete-snapshot"),
2193 threadId: thread.id,
2194 messageId: snapshotMessageId,
2195 turnId,
2196 createdAt: now,
2197 });
2198 }
2199 }
2200 } else {
2201 yield* finalizeActiveSegmentForTurn({
2202 event,
2203 threadId: thread.id,
2204 turnId,
2205 createdAt: now,
2206 commandTag: "reasoning-complete",
2207 finalDeltaCommandTag: "reasoning-delta-finalize",
2208 hasProjectedMessage: existingReasoningMessage !== undefined,
2209 role: "reasoning",
2210 ...(fallbackText !== undefined ? { fallbackText } : {}),
2211 });
2212 }
2213 }
2214 }
2215
1871 const assistantCompletion =2216 const assistantCompletion =
1872 event.type === "item.completed" && event.payload.itemType === "assistant_message"2217 event.type === "item.completed" && event.payload.itemType === "assistant_message"
1873 ? {2218 ? {
...@@ -1888,6 +2233,18 @@ const make = Effect.gen(function* () {...@@ -1888,6 +2233,18 @@ const make = Effect.gen(function* () {
18882233
1889 if (assistantCompletion) {2234 if (assistantCompletion) {
1890 const turnId = toTurnId(event.turnId);2235 const turnId = toTurnId(event.turnId);
2236 if (turnId) {
2237 yield* finalizeActiveSegmentForTurn({
2238 event,
2239 threadId: thread.id,
2240 turnId,
2241 createdAt: now,
2242 commandTag: "reasoning-complete-on-assistant-completion",
2243 finalDeltaCommandTag: "reasoning-delta-finalize-on-assistant-completion",
2244 hasProjectedMessage: false,
2245 role: "reasoning",
2246 });
2247 }
1891 const activeAssistantMessageId = turnId2248 const activeAssistantMessageId = turnId
1892 ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId)2249 ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId)
1893 : Option.none<MessageId>();2250 : Option.none<MessageId>();
...@@ -2020,6 +2377,7 @@ const make = Effect.gen(function* () {...@@ -2020,6 +2377,7 @@ const make = Effect.gen(function* () {
2020 ).pipe(Effect.asVoid);2377 ).pipe(Effect.asVoid);
2021 yield* clearAssistantMessageIdsForTurn(thread.id, turnId);2378 yield* clearAssistantMessageIdsForTurn(thread.id, turnId);
2022 yield* clearAssistantSegmentStateForTurn(thread.id, turnId);2379 yield* clearAssistantSegmentStateForTurn(thread.id, turnId);
2380 yield* clearAssistantSegmentStateForTurn(thread.id, turnId, "reasoning");
20232381
2024 yield* finalizeBufferedProposedPlan({2382 yield* finalizeBufferedProposedPlan({
2025 event,2383 event,
apps/server/src/orchestration/decider.ts+6-4
...@@ -1912,7 +1912,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -1912,7 +1912,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1912 return [unsettledEvent, sessionSetEvent];1912 return [unsettledEvent, sessionSetEvent];
1913 }1913 }
19141914
1915 case "thread.message.assistant.delta": {1915 case "thread.message.assistant.delta":
1916 case "thread.message.reasoning.delta": {
1916 if (isImportedAgentSessionMessageId(command.messageId)) {1917 if (isImportedAgentSessionMessageId(command.messageId)) {
1917 return yield* new OrchestrationCommandInvariantError({1918 return yield* new OrchestrationCommandInvariantError({
1918 commandType: command.type,1919 commandType: command.type,
...@@ -1935,7 +1936,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -1935,7 +1936,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1935 payload: {1936 payload: {
1936 threadId: command.threadId,1937 threadId: command.threadId,
1937 messageId: command.messageId,1938 messageId: command.messageId,
1938 role: "assistant",1939 role: command.type === "thread.message.reasoning.delta" ? "reasoning" : "assistant",
1939 text: command.delta,1940 text: command.delta,
1940 turnId: command.turnId ?? null,1941 turnId: command.turnId ?? null,
1941 streaming: true,1942 streaming: true,
...@@ -1945,7 +1946,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -1945,7 +1946,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1945 };1946 };
1946 }1947 }
19471948
1948 case "thread.message.assistant.complete": {1949 case "thread.message.assistant.complete":
1950 case "thread.message.reasoning.complete": {
1949 if (isImportedAgentSessionMessageId(command.messageId)) {1951 if (isImportedAgentSessionMessageId(command.messageId)) {
1950 return yield* new OrchestrationCommandInvariantError({1952 return yield* new OrchestrationCommandInvariantError({
1951 commandType: command.type,1953 commandType: command.type,
...@@ -1968,7 +1970,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -1968,7 +1970,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1968 payload: {1970 payload: {
1969 threadId: command.threadId,1971 threadId: command.threadId,
1970 messageId: command.messageId,1972 messageId: command.messageId,
1971 role: "assistant",1973 role: command.type === "thread.message.reasoning.complete" ? "reasoning" : "assistant",
1972 text: "",1974 text: "",
1973 turnId: command.turnId ?? null,1975 turnId: command.turnId ?? null,
1974 streaming: false,1976 streaming: false,
apps/server/src/orchestration/http.ts+4-1
...@@ -87,7 +87,10 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(...@@ -87,7 +87,10 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
87 if (Option.isNone(snapshot)) {87 if (Option.isNone(snapshot)) {
88 return yield* failEnvironmentNotFound("thread_not_found");88 return yield* failEnvironmentNotFound("thread_not_found");
89 }89 }
90 return projectThreadDetailSnapshot(snapshot.value);90 return projectThreadDetailSnapshot(
91 snapshot.value,
92 args.payload.reasoningMessages === "true",
93 );
91 }),94 }),
92 )95 )
93 .handle(96 .handle(
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+162-7
...@@ -496,7 +496,10 @@ describe("ClaudeAdapterLive", () => {...@@ -496,7 +496,10 @@ describe("ClaudeAdapterLive", () => {
496 assert.equal(createInput?.options.permissionMode, "bypassPermissions");496 assert.equal(createInput?.options.permissionMode, "bypassPermissions");
497 assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);497 assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);
498 // The honored flag is dropped from extraArgs so the CLI sees it once.498 // The honored flag is dropped from extraArgs so the CLI sees it once.
499 assert.deepEqual(createInput?.options.extraArgs, { verbose: null });499 assert.deepEqual(createInput?.options.extraArgs, {
500 verbose: null,
501 "thinking-display": "summarized",
502 });
500 }).pipe(503 }).pipe(
501 Effect.provideService(Random.Random, makeDeterministicRandomService()),504 Effect.provideService(Random.Random, makeDeterministicRandomService()),
502 Effect.provide(harness.layer),505 Effect.provide(harness.layer),
...@@ -553,7 +556,10 @@ describe("ClaudeAdapterLive", () => {...@@ -553,7 +556,10 @@ describe("ClaudeAdapterLive", () => {
553 });556 });
554557
555 const options = harness.getLastCreateQueryInput()?.options;558 const options = harness.getLastCreateQueryInput()?.options;
556 assert.deepEqual(options?.settings, { autoCompactWindow: 300000 });559 assert.deepEqual(options?.settings, {
560 showThinkingSummaries: true,
561 autoCompactWindow: 300000,
562 });
557 assert.deepEqual(options?.supportedDialogKinds, ["resume_return"]);563 assert.deepEqual(options?.supportedDialogKinds, ["resume_return"]);
558 }).pipe(564 }).pipe(
559 Effect.provideService(Random.Random, makeDeterministicRandomService()),565 Effect.provideService(Random.Random, makeDeterministicRandomService()),
...@@ -628,6 +634,61 @@ describe("ClaudeAdapterLive", () => {...@@ -628,6 +634,61 @@ describe("ClaudeAdapterLive", () => {
628 assert.deepEqual(createInput?.options.settings, {634 assert.deepEqual(createInput?.options.settings, {
629 alwaysThinkingEnabled: false,635 alwaysThinkingEnabled: false,
630 });636 });
637 assert.equal(createInput?.options.thinking, undefined);
638 assert.equal(createInput?.options.extraArgs?.["thinking-display"], undefined);
639 }).pipe(
640 Effect.provideService(Random.Random, makeDeterministicRandomService()),
641 Effect.provide(harness.layer),
642 );
643 });
644
645 it.effect("requests Claude thinking summaries unless thinking is off", () => {
646 const harness = makeHarness();
647 return Effect.gen(function* () {
648 const adapter = yield* ClaudeAdapter;
649 yield* adapter.startSession({
650 threadId: THREAD_ID,
651 provider: ProviderDriverKind.make("claudeAgent"),
652 modelSelection: createModelSelection(
653 ProviderInstanceId.make("claudeAgent"),
654 SYNTHETIC_CLAUDE_THINKING_MODEL,
655 [{ id: "thinking", value: true }],
656 ),
657 runtimeMode: "full-access",
658 });
659
660 const createInput = harness.getLastCreateQueryInput();
661 assert.deepEqual(createInput?.options.settings, {
662 alwaysThinkingEnabled: true,
663 showThinkingSummaries: true,
664 });
665 assert.deepEqual(createInput?.options.thinking, {
666 type: "adaptive",
667 display: "summarized",
668 });
669 assert.equal(createInput?.options.extraArgs?.["thinking-display"], "summarized");
670 }).pipe(
671 Effect.provideService(Random.Random, makeDeterministicRandomService()),
672 Effect.provide(harness.layer),
673 );
674 });
675
676 it.effect("honors a launch-arg that omits Claude thinking display", () => {
677 const harness = makeHarness({
678 claudeConfig: { launchArgs: "--thinking-display omitted" },
679 });
680 return Effect.gen(function* () {
681 const adapter = yield* ClaudeAdapter;
682 yield* adapter.startSession({
683 threadId: THREAD_ID,
684 provider: ProviderDriverKind.make("claudeAgent"),
685 runtimeMode: "full-access",
686 });
687
688 const createInput = harness.getLastCreateQueryInput();
689 assert.equal(createInput?.options.settings, undefined);
690 assert.equal(createInput?.options.thinking, undefined);
691 assert.equal(createInput?.options.extraArgs?.["thinking-display"], "omitted");
631 }).pipe(692 }).pipe(
632 Effect.provideService(Random.Random, makeDeterministicRandomService()),693 Effect.provideService(Random.Random, makeDeterministicRandomService()),
633 Effect.provide(harness.layer),694 Effect.provide(harness.layer),
...@@ -650,7 +711,13 @@ describe("ClaudeAdapterLive", () => {...@@ -650,7 +711,13 @@ describe("ClaudeAdapterLive", () => {
650 });711 });
651712
652 const createInput = harness.getLastCreateQueryInput();713 const createInput = harness.getLastCreateQueryInput();
653 assert.equal(createInput?.options.settings, undefined);714 assert.deepEqual(createInput?.options.settings, {
715 showThinkingSummaries: true,
716 });
717 assert.deepEqual(createInput?.options.thinking, {
718 type: "adaptive",
719 display: "summarized",
720 });
654 }).pipe(721 }).pipe(
655 Effect.provideService(Random.Random, makeDeterministicRandomService()),722 Effect.provideService(Random.Random, makeDeterministicRandomService()),
656 Effect.provide(harness.layer),723 Effect.provide(harness.layer),
...@@ -674,6 +741,7 @@ describe("ClaudeAdapterLive", () => {...@@ -674,6 +741,7 @@ describe("ClaudeAdapterLive", () => {
674741
675 const createInput = harness.getLastCreateQueryInput();742 const createInput = harness.getLastCreateQueryInput();
676 assert.deepEqual(createInput?.options.settings, {743 assert.deepEqual(createInput?.options.settings, {
744 showThinkingSummaries: true,
677 fastMode: true,745 fastMode: true,
678 });746 });
679 }).pipe(747 }).pipe(
...@@ -698,7 +766,9 @@ describe("ClaudeAdapterLive", () => {...@@ -698,7 +766,9 @@ describe("ClaudeAdapterLive", () => {
698 });766 });
699767
700 const createInput = harness.getLastCreateQueryInput();768 const createInput = harness.getLastCreateQueryInput();
701 assert.equal(createInput?.options.settings, undefined);769 assert.deepEqual(createInput?.options.settings, {
770 showThinkingSummaries: true,
771 });
702 }).pipe(772 }).pipe(
703 Effect.provideService(Random.Random, makeDeterministicRandomService()),773 Effect.provideService(Random.Random, makeDeterministicRandomService()),
704 Effect.provide(harness.layer),774 Effect.provide(harness.layer),
...@@ -781,7 +851,7 @@ describe("ClaudeAdapterLive", () => {...@@ -781,7 +851,7 @@ describe("ClaudeAdapterLive", () => {
781 const { options: customOptions, prompt: customPrompt } = yield* runCustomFlow;851 const { options: customOptions, prompt: customPrompt } = yield* runCustomFlow;
782 assert.equal(customOptions.model, SYNTHETIC_CLAUDE_COLLIDING_ALIAS);852 assert.equal(customOptions.model, SYNTHETIC_CLAUDE_COLLIDING_ALIAS);
783 assert.equal(customOptions.effort, undefined);853 assert.equal(customOptions.effort, undefined);
784 assert.equal(customOptions.settings, undefined);854 assert.deepEqual(customOptions.settings, { showThinkingSummaries: true });
785 assert.deepEqual(customHarness.query.setModelCalls, [855 assert.deepEqual(customHarness.query.setModelCalls, [
786 `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`,856 `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`,
787 SYNTHETIC_CLAUDE_COLLIDING_ALIAS,857 SYNTHETIC_CLAUDE_COLLIDING_ALIAS,
...@@ -791,7 +861,10 @@ describe("ClaudeAdapterLive", () => {...@@ -791,7 +861,10 @@ describe("ClaudeAdapterLive", () => {
791 const builtInOptions = yield* start(builtInHarness, SYNTHETIC_CLAUDE_CAPABLE_MODEL);861 const builtInOptions = yield* start(builtInHarness, SYNTHETIC_CLAUDE_CAPABLE_MODEL);
792 assert.equal(builtInOptions.model, `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`);862 assert.equal(builtInOptions.model, `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`);
793 assert.equal(builtInOptions.effort, "max");863 assert.equal(builtInOptions.effort, "max");
794 assert.deepEqual(builtInOptions.settings, { fastMode: true });864 assert.deepEqual(builtInOptions.settings, {
865 showThinkingSummaries: true,
866 fastMode: true,
867 });
795 });868 });
796 },869 },
797 );870 );
...@@ -1673,7 +1746,8 @@ describe("ClaudeAdapterLive", () => {...@@ -1673,7 +1746,8 @@ describe("ClaudeAdapterLive", () => {
1673 );1746 );
16741747
1675 const reasoningDelta = runtimeEvents.find(1748 const reasoningDelta = runtimeEvents.find(
1676 (event) => event.type === "content.delta" && event.payload.streamKind === "reasoning_text",1749 (event) =>
1750 event.type === "content.delta" && event.payload.streamKind === "reasoning_summary_text",
1677 );1751 );
1678 assert.equal(reasoningDelta?.type, "content.delta");1752 assert.equal(reasoningDelta?.type, "content.delta");
1679 if (reasoningDelta?.type === "content.delta") {1753 if (reasoningDelta?.type === "content.delta") {
...@@ -1727,6 +1801,87 @@ describe("ClaudeAdapterLive", () => {...@@ -1727,6 +1801,87 @@ describe("ClaudeAdapterLive", () => {
1727 );1801 );
1728 });1802 });
17291803
1804 it.effect("backfills Claude thinking summaries from assistant snapshots", () => {
1805 const harness = makeHarness();
1806 return Effect.gen(function* () {
1807 const adapter = yield* ClaudeAdapter;
1808
1809 const runtimeEventsFiber = yield* Stream.takeUntil(
1810 adapter.streamEvents,
1811 (event) => event.type === "turn.completed",
1812 ).pipe(Stream.runCollect, Effect.forkChild);
1813
1814 const session = yield* adapter.startSession({
1815 threadId: THREAD_ID,
1816 provider: ProviderDriverKind.make("claudeAgent"),
1817 runtimeMode: "full-access",
1818 });
1819
1820 const turn = yield* adapter.sendTurn({
1821 threadId: session.threadId,
1822 input: "hello",
1823 attachments: [],
1824 });
1825
1826 const thinkingSnapshot = {
1827 type: "assistant",
1828 session_id: "sdk-session-thinking-snapshot",
1829 uuid: "assistant-thinking-snapshot",
1830 parent_tool_use_id: null,
1831 message: {
1832 id: "assistant-message-thinking",
1833 content: [
1834 { type: "thinking", thinking: "Use Euclidean algorithm." },
1835 { type: "text", text: "The gcd is 21." },
1836 ],
1837 },
1838 } as unknown as SDKMessage;
1839 harness.query.emit(thinkingSnapshot);
1840 harness.query.emit(thinkingSnapshot);
1841 harness.query.emit({
1842 type: "assistant",
1843 session_id: "sdk-session-thinking-snapshot",
1844 uuid: "assistant-thinking-snapshot-2",
1845 parent_tool_use_id: null,
1846 message: {
1847 id: "assistant-message-thinking-2",
1848 content: [{ type: "thinking", thinking: "Verify the result." }],
1849 },
1850 } as unknown as SDKMessage);
1851
1852 harness.query.emit({
1853 type: "result",
1854 subtype: "success",
1855 is_error: false,
1856 errors: [],
1857 session_id: "sdk-session-thinking-snapshot",
1858 uuid: "result-thinking-snapshot",
1859 } as unknown as SDKMessage);
1860
1861 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1862 const reasoningDeltas = runtimeEvents.filter(
1863 (event) =>
1864 event.type === "content.delta" && event.payload.streamKind === "reasoning_summary_text",
1865 );
1866 assert.equal(reasoningDeltas.length, 2);
1867 const reasoningDelta = reasoningDeltas[0];
1868 assert.equal(reasoningDelta?.type, "content.delta");
1869 if (reasoningDelta?.type === "content.delta") {
1870 assert.equal(reasoningDelta.payload.delta, "Use Euclidean algorithm.");
1871 assert.equal(String(reasoningDelta.turnId), String(turn.turnId));
1872 }
1873 assert.deepEqual(
1874 runtimeEvents.flatMap((event) =>
1875 event.type === "content.delta" ? [event.payload.delta] : [],
1876 ),
1877 ["Use Euclidean algorithm.", "The gcd is 21.", "Verify the result."],
1878 );
1879 }).pipe(
1880 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1881 Effect.provide(harness.layer),
1882 );
1883 });
1884
1730 it.effect("classifies only streamed Read image inputs as image views", () => {1885 it.effect("classifies only streamed Read image inputs as image views", () => {
1731 const harness = makeHarness();1886 const harness = makeHarness();
1732 return Effect.gen(function* () {1887 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+136-22
...@@ -225,7 +225,10 @@ const remapClaudeForkTurnBoundaries = (...@@ -225,7 +225,10 @@ const remapClaudeForkTurnBoundaries = (
225};225};
226226
227const PROVIDER = ProviderDriverKind.make("claudeAgent");227const PROVIDER = ProviderDriverKind.make("claudeAgent");
228type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">;228type ClaudeTextStreamKind = Extract<
229 RuntimeContentStreamKind,
230 "assistant_text" | "reasoning_text" | "reasoning_summary_text"
231>;
229type ClaudeToolResultStreamKind = Extract<232type ClaudeToolResultStreamKind = Extract<
230 RuntimeContentStreamKind,233 RuntimeContentStreamKind,
231 "command_output" | "file_change_output"234 "command_output" | "file_change_output"
...@@ -275,6 +278,8 @@ interface ClaudeTurnState {...@@ -275,6 +278,8 @@ interface ClaudeTurnState {
275 authenticationFailureMessage: string | undefined;278 authenticationFailureMessage: string | undefined;
276 rejectedRateLimitTypes: Set<string>;279 rejectedRateLimitTypes: Set<string>;
277 latestAssistantRateLimited: boolean;280 latestAssistantRateLimited: boolean;
281 emittedThinkingText: boolean;
282 readonly thinkingSnapshotIds: Set<string>;
278}283}
279284
280interface AssistantTextBlockState {285interface AssistantTextBlockState {
...@@ -1718,7 +1723,18 @@ function resultOutcome(...@@ -1718,7 +1723,18 @@ function resultOutcome(
1718}1723}
17191724
1720function streamKindFromDeltaType(deltaType: string): ClaudeTextStreamKind {1725function streamKindFromDeltaType(deltaType: string): ClaudeTextStreamKind {
1721 return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text";1726 // Claude never returns the raw chain of thought. A thinking delta is the
1727 // API-side summary (or a progress-update sentence) when display is
1728 // summarized; map it onto the summary stream so it shares the Codex/Grok
1729 // reasoning-summary path rather than looking like a raw trace we do not have.
1730 return deltaType.includes("thinking") ? "reasoning_summary_text" : "assistant_text";
1731}
1732
1733function shouldRequestClaudeThinkingSummaries(input: {
1734 readonly thinking: boolean | undefined;
1735 readonly thinkingDisplay: string | null | undefined;
1736}): boolean {
1737 return input.thinking !== false && input.thinkingDisplay !== "omitted";
1722}1738}
17231739
1724function nativeProviderRefs(1740function nativeProviderRefs(
...@@ -1735,7 +1751,11 @@ function nativeProviderRefs(...@@ -1735,7 +1751,11 @@ function nativeProviderRefs(
1735 return {};1751 return {};
1736}1752}
17371753
1738function extractAssistantTextBlocks(message: SDKMessage): Array<string> {1754function extractAssistantContentBlocks(
1755 message: SDKMessage,
1756 blockType: "text" | "thinking",
1757 field: "text" | "thinking",
1758): Array<string> {
1739 if (message.type !== "assistant") {1759 if (message.type !== "assistant") {
1740 return [];1760 return [];
1741 }1761 }
...@@ -1750,19 +1770,24 @@ function extractAssistantTextBlocks(message: SDKMessage): Array<string> {...@@ -1750,19 +1770,24 @@ function extractAssistantTextBlocks(message: SDKMessage): Array<string> {
1750 if (!block || typeof block !== "object") {1770 if (!block || typeof block !== "object") {
1751 continue;1771 continue;
1752 }1772 }
1753 const candidate = block as { type?: unknown; text?: unknown };1773 const candidate = block as { type?: unknown; text?: unknown; thinking?: unknown };
1754 if (1774 const value = field === "thinking" ? candidate.thinking : candidate.text;
1755 candidate.type === "text" &&1775 if (candidate.type === blockType && typeof value === "string" && value.length > 0) {
1756 typeof candidate.text === "string" &&1776 fragments.push(value);
1757 candidate.text.length > 0
1758 ) {
1759 fragments.push(candidate.text);
1760 }1777 }
1761 }1778 }
17621779
1763 return fragments;1780 return fragments;
1764}1781}
17651782
1783function extractAssistantTextBlocks(message: SDKMessage): Array<string> {
1784 return extractAssistantContentBlocks(message, "text", "text");
1785}
1786
1787function extractAssistantThinkingBlocks(message: SDKMessage): Array<string> {
1788 return extractAssistantContentBlocks(message, "thinking", "thinking");
1789}
1790
1766function extractContentBlockText(block: unknown): string {1791function extractContentBlockText(block: unknown): string {
1767 if (!block || typeof block !== "object") {1792 if (!block || typeof block !== "object") {
1768 return "";1793 return "";
...@@ -2358,6 +2383,70 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2358,6 +2383,70 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2358 }2383 }
2359 });2384 });
23602385
2386 const emitReasoningSummaryDelta = Effect.fn("emitReasoningSummaryDelta")(function* (
2387 context: ClaudeSessionContext,
2388 input: {
2389 readonly delta: string;
2390 readonly contentIndex?: number;
2391 readonly rawMethod: string;
2392 readonly rawPayload: SDKMessage;
2393 },
2394 ) {
2395 const turnState = context.turnState;
2396 if (!turnState || input.delta.length === 0) {
2397 return;
2398 }
2399 turnState.emittedThinkingText = true;
2400 const stamp = yield* makeEventStamp();
2401 yield* offerRuntimeEvent({
2402 type: "content.delta",
2403 eventId: stamp.eventId,
2404 provider: PROVIDER,
2405 createdAt: stamp.createdAt,
2406 threadId: context.session.threadId,
2407 turnId: turnState.turnId,
2408 payload: {
2409 streamKind: "reasoning_summary_text",
2410 delta: input.delta,
2411 ...(input.contentIndex !== undefined ? { contentIndex: input.contentIndex } : {}),
2412 },
2413 providerRefs: nativeProviderRefs(context),
2414 raw: {
2415 source: "claude.sdk.message",
2416 method: input.rawMethod,
2417 payload: input.rawPayload,
2418 },
2419 });
2420 });
2421
2422 const backfillThinkingFromSnapshot = Effect.fn("backfillThinkingFromSnapshot")(function* (
2423 context: ClaudeSessionContext,
2424 message: SDKMessage,
2425 ) {
2426 const turnState = context.turnState;
2427 if (!turnState || message.type !== "assistant") {
2428 return;
2429 }
2430 const snapshotId = message.uuid;
2431 const alreadyEmitted =
2432 turnState.emittedThinkingText || turnState.thinkingSnapshotIds.has(snapshotId);
2433 turnState.thinkingSnapshotIds.add(snapshotId);
2434 turnState.emittedThinkingText = false;
2435 if (alreadyEmitted) {
2436 return;
2437 }
2438
2439 for (const [index, delta] of extractAssistantThinkingBlocks(message).entries()) {
2440 yield* emitReasoningSummaryDelta(context, {
2441 delta,
2442 contentIndex: index,
2443 rawMethod: "claude/assistant/thinking",
2444 rawPayload: message,
2445 });
2446 }
2447 turnState.emittedThinkingText = false;
2448 });
2449
2361 const ensureThreadId = Effect.fn("ensureThreadId")(function* (2450 const ensureThreadId = Effect.fn("ensureThreadId")(function* (
2362 context: ClaudeSessionContext,2451 context: ClaudeSessionContext,
2363 message: SDKMessage,2452 message: SDKMessage,
...@@ -2799,6 +2888,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2799,6 +2888,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2799 }2888 }
2800 }2889 }
28012890
2891 if (event.type === "message_start" && context.turnState && !streamParentToolUseId) {
2892 context.turnState.emittedThinkingText = false;
2893 }
2894
2802 if (event.type === "message_delta") {2895 if (event.type === "message_delta") {
2803 if (message.parent_tool_use_id !== null && message.parent_tool_use_id !== undefined) {2896 if (message.parent_tool_use_id !== null && message.parent_tool_use_id !== undefined) {
2804 return;2897 return;
...@@ -2831,18 +2924,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2831,18 +2924,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2831 return;2924 return;
2832 }2925 }
2833 const streamKind = streamKindFromDeltaType(event.delta.type);2926 const streamKind = streamKindFromDeltaType(event.delta.type);
2834 const assistantBlockEntry =2927 if (streamKind === "reasoning_summary_text") {
2835 event.delta.type === "text_delta"2928 yield* emitReasoningSummaryDelta(context, {
2836 ? yield* ensureAssistantTextBlock(context, event.index)2929 delta: deltaText,
2837 : context.turnState.assistantTextBlocks.get(event.index)2930 contentIndex: event.index,
2838 ? {2931 rawMethod: "claude/stream_event/content_block_delta",
2839 blockIndex: event.index,2932 rawPayload: message,
2840 block: context.turnState.assistantTextBlocks.get(2933 });
2841 event.index,2934 return;
2842 ) as AssistantTextBlockState,2935 }
2843 }2936 const assistantBlockEntry = yield* ensureAssistantTextBlock(context, event.index);
2844 : undefined;2937 if (assistantBlockEntry?.block) {
2845 if (assistantBlockEntry?.block && event.delta.type === "text_delta") {
2846 assistantBlockEntry.block.emittedTextDelta = true;2938 assistantBlockEntry.block.emittedTextDelta = true;
2847 }2939 }
2848 const stamp = yield* makeEventStamp();2940 const stamp = yield* makeEventStamp();
...@@ -3297,6 +3389,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3297,6 +3389,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3297 authenticationFailureMessage: undefined,3389 authenticationFailureMessage: undefined,
3298 rejectedRateLimitTypes: new Set(),3390 rejectedRateLimitTypes: new Set(),
3299 latestAssistantRateLimited: false,3391 latestAssistantRateLimited: false,
3392 emittedThinkingText: false,
3393 thinkingSnapshotIds: new Set(),
3300 };3394 };
3301 context.session = {3395 context.session = {
3302 ...context.session,3396 ...context.session,
...@@ -3378,6 +3472,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3378,6 +3472,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3378 context.turnState.latestAssistantUsage = message.message.usage;3472 context.turnState.latestAssistantUsage = message.message.usage;
3379 context.turnState.compactedSinceLatestAssistantUsage = false;3473 context.turnState.compactedSinceLatestAssistantUsage = false;
3380 }3474 }
3475 yield* backfillThinkingFromSnapshot(context, message);
3381 yield* backfillAssistantTextBlocksFromSnapshot(context, message);3476 yield* backfillAssistantTextBlocksFromSnapshot(context, message);
3382 }3477 }
33833478
...@@ -4770,6 +4865,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4770,6 +4865,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4770 const thinking = thinkingSupported4865 const thinking = thinkingSupported
4771 ? getModelSelectionBooleanOptionValue(modelSelection, "thinking")4866 ? getModelSelectionBooleanOptionValue(modelSelection, "thinking")
4772 : undefined;4867 : undefined;
4868 const thinkingDisplayArg = extraArgs["thinking-display"];
4869 const requestThinkingSummaries = shouldRequestClaudeThinkingSummaries({
4870 thinking,
4871 thinkingDisplay: typeof thinkingDisplayArg === "string" ? thinkingDisplayArg : undefined,
4872 });
4773 const ultracode = isClaudeCatalogUltracodeEffort(effort);4873 const ultracode = isClaudeCatalogUltracodeEffort(effort);
4774 const effectiveEffort = getEffectiveClaudeAgentEffort(4874 const effectiveEffort = getEffectiveClaudeAgentEffort(
4775 modelCatalog,4875 modelCatalog,
...@@ -4791,12 +4891,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4791,12 +4891,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4791 : runtimeModeToPermission[input.runtimeMode]);4891 : runtimeModeToPermission[input.runtimeMode]);
4792 const settings = {4892 const settings = {
4793 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),4893 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
4894 ...(requestThinkingSummaries ? { showThinkingSummaries: true } : {}),
4794 ...(fastMode ? { fastMode: true } : {}),4895 ...(fastMode ? { fastMode: true } : {}),
4795 ...(ultracode ? { ultracode: true } : {}),4896 ...(ultracode ? { ultracode: true } : {}),
4796 ...(claudeSettings.autoCompactWindow4897 ...(claudeSettings.autoCompactWindow
4797 ? { autoCompactWindow: Number(claudeSettings.autoCompactWindow) }4898 ? { autoCompactWindow: Number(claudeSettings.autoCompactWindow) }
4798 : {}),4899 : {}),
4799 };4900 };
4901 if (requestThinkingSummaries && extraArgs["thinking-display"] === undefined) {
4902 extraArgs["thinking-display"] = "summarized";
4903 }
4800 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);4904 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
4801 // The attachments dir grant lets the agent Read/copy pasted images at4905 // The attachments dir grant lets the agent Read/copy pasted images at
4802 // the paths ProviderService injects into the turn text, without an4906 // the paths ProviderService injects into the turn text, without an
...@@ -4824,6 +4928,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4824,6 +4928,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4824 effort: effectiveEffort as unknown as NonNullable<ClaudeQueryOptions["effort"]>,4928 effort: effectiveEffort as unknown as NonNullable<ClaudeQueryOptions["effort"]>,
4825 }4929 }
4826 : {}),4930 : {}),
4931 ...(extraArgs["thinking-display"] === "summarized"
4932 ? {
4933 thinking: {
4934 type: "adaptive" as const,
4935 display: "summarized" as const,
4936 },
4937 }
4938 : {}),
4827 ...(permissionMode ? { permissionMode } : {}),4939 ...(permissionMode ? { permissionMode } : {}),
4828 ...(permissionMode === "bypassPermissions"4940 ...(permissionMode === "bypassPermissions"
4829 ? { allowDangerouslySkipPermissions: true }4941 ? { allowDangerouslySkipPermissions: true }
...@@ -5104,6 +5216,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -5104,6 +5216,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
5104 authenticationFailureMessage: undefined,5216 authenticationFailureMessage: undefined,
5105 rejectedRateLimitTypes: new Set(),5217 rejectedRateLimitTypes: new Set(),
5106 latestAssistantRateLimited: false,5218 latestAssistantRateLimited: false,
5219 emittedThinkingText: false,
5220 thinkingSnapshotIds: new Set(),
5107 };5221 };
51085222
5109 const updatedAt = yield* nowIso;5223 const updatedAt = yield* nowIso;
apps/server/src/provider/Layers/CursorAdapter.ts+21
...@@ -877,6 +877,27 @@ export function makeCursorAdapter(...@@ -877,6 +877,27 @@ export function makeCursorAdapter(
877 }),877 }),
878 );878 );
879 return;879 return;
880 case "ThoughtDelta":
881 // Thoughts are narration, not the reply: they stay out of
882 // `assistantReply` so a resumed turn replays only answers.
883 yield* logNative(
884 ctx.threadId,
885 "session/update",
886 event.rawPayload,
887 "acp.jsonrpc",
888 );
889 yield* offerRuntimeEvent(
890 makeAcpContentDeltaEvent({
891 stamp: yield* makeEventStamp(),
892 provider: PROVIDER,
893 threadId: ctx.threadId,
894 turnId: ctx.activeTurnId,
895 streamKind: "reasoning_text",
896 text: event.text,
897 rawPayload: event.rawPayload,
898 }),
899 );
900 return;
880 case "ContentDelta":901 case "ContentDelta":
881 ctx.assistantReply.push(event.text);902 ctx.assistantReply.push(event.text);
882 yield* logNative(903 yield* logNative(
apps/server/src/provider/Layers/GrokAdapter.ts+19-3
...@@ -467,13 +467,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -467,13 +467,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
467 | "AssistantItemCompleted"467 | "AssistantItemCompleted"
468 | "PlanUpdated"468 | "PlanUpdated"
469 | "ToolCallUpdated"469 | "ToolCallUpdated"
470 | "ContentDelta";470 | "ContentDelta"
471 | "ThoughtDelta";
471 }472 }
472 >,473 >,
473 ) {474 ) {
474 if (475 if (
475 ctx.livenessTurnId !== turnId ||476 ctx.livenessTurnId !== turnId ||
476 (event._tag === "ContentDelta" && event.text.length === 0)477 ((event._tag === "ContentDelta" || event._tag === "ThoughtDelta") &&
478 event.text.length === 0)
477 ) {479 ) {
478 return;480 return;
479 }481 }
...@@ -1367,7 +1369,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1367,7 +1369,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1367 event._tag === "AssistantItemCompleted" ||1369 event._tag === "AssistantItemCompleted" ||
1368 event._tag === "PlanUpdated" ||1370 event._tag === "PlanUpdated" ||
1369 event._tag === "ToolCallUpdated" ||1371 event._tag === "ToolCallUpdated" ||
1370 event._tag === "ContentDelta"1372 event._tag === "ContentDelta" ||
1373 event._tag === "ThoughtDelta"
1371 ) {1374 ) {
1372 yield* recordTurnActivity(ctx, notificationTurnId, event);1375 yield* recordTurnActivity(ctx, notificationTurnId, event);
1373 }1376 }
...@@ -1443,6 +1446,19 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1443,6 +1446,19 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1443 }1446 }
1444 return;1447 return;
1445 }1448 }
1449 case "ThoughtDelta":
1450 yield* offerRuntimeEvent(
1451 makeAcpContentDeltaEvent({
1452 stamp,
1453 provider: PROVIDER,
1454 threadId: ctx.threadId,
1455 turnId: notificationTurnId,
1456 streamKind: "reasoning_text",
1457 text: event.text,
1458 rawPayload: event.rawPayload,
1459 }),
1460 );
1461 return;
1446 case "ContentDelta":1462 case "ContentDelta":
1447 yield* offerRuntimeEvent(1463 yield* offerRuntimeEvent(
1448 makeAcpContentDeltaEvent({1464 makeAcpContentDeltaEvent({
apps/server/src/server.test.ts+118
...@@ -8525,6 +8525,124 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -8525,6 +8525,124 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
8525 }).pipe(Effect.provide(NodeHttpServer.layerTest)),8525 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8526 );8526 );
85278527
8528 for (const reasoningMessages of [undefined, true] as const) {
8529 it.effect(`preserves reasoning wire compatibility with opt-in ${reasoningMessages}`, () =>
8530 Effect.gen(function* () {
8531 const message = {
8532 id: MessageId.make("thinking-compatibility"),
8533 role: "reasoning" as const,
8534 text: "Checking the available evidence.",
8535 turnId: null,
8536 streaming: false,
8537 createdAt: "2026-01-01T00:00:01.000Z",
8538 updatedAt: "2026-01-01T00:00:01.000Z",
8539 };
8540 const thread = { ...makeDefaultOrchestrationReadModel().threads[0]!, messages: [message] };
8541 const event = {
8542 sequence: 2,
8543 eventId: EventId.make("thinking-compatibility-event"),
8544 aggregateKind: "thread" as const,
8545 aggregateId: defaultThreadId,
8546 occurredAt: message.createdAt,
8547 commandId: null,
8548 causationEventId: null,
8549 correlationId: null,
8550 metadata: {},
8551 type: "thread.message-sent" as const,
8552 payload: {
8553 messageId: message.id,
8554 threadId: defaultThreadId,
8555 role: message.role,
8556 text: message.text,
8557 turnId: message.turnId,
8558 streaming: message.streaming,
8559 createdAt: message.createdAt,
8560 updatedAt: message.updatedAt,
8561 },
8562 } satisfies OrchestrationEvent;
8563 const answer = {
8564 ...event,
8565 sequence: 3,
8566 eventId: EventId.make("thinking-answer-event"),
8567 payload: {
8568 ...event.payload,
8569 messageId: MessageId.make("thinking-answer"),
8570 role: "assistant" as const,
8571 text: "Here is the answer.",
8572 },
8573 };
8574 const liveEvents = yield* PubSub.unbounded<OrchestrationEvent>();
8575 yield* buildAppUnderTest({
8576 layers: {
8577 orchestrationEngine: {
8578 streamDomainEvents: Stream.fromPubSub(liveEvents),
8579 latestSequence: Effect.succeed(3),
8580 getThreadReplayStats: () =>
8581 Effect.succeed({ eventCount: 2, payloadBytes: 200, hasCreateEvent: false }),
8582 readThreadEvents: () => Stream.make(event, answer),
8583 },
8584 projectionSnapshotQuery: {
8585 getThreadDetailSnapshot: () =>
8586 Effect.gen(function* () {
8587 yield* PubSub.publishAll(liveEvents, [event, answer]);
8588 return Option.some({
8589 snapshotSequence: 1,
8590 thread,
8591 page: {
8592 beforeCursor: null,
8593 hasMore: false,
8594 snapshotSequence: 1,
8595 threadSequence: 2,
8596 },
8597 });
8598 }),
8599 },
8600 },
8601 });
8602 const role = reasoningMessages ? "reasoning" : "system";
8603 const response = yield* fetchEffect(
8604 yield* getHttpServerUrl(
8605 `/api/orchestration/threads/${defaultThreadId}?turnLimit=1${reasoningMessages ? "&reasoningMessages=true" : ""}`,
8606 ),
8607 { headers: { cookie: yield* getAuthenticatedSessionCookieHeader() } },
8608 );
8609 const httpSnapshot = yield* responseJsonEffect<OrchestrationThreadDetailSnapshot>(response);
8610 assert.equal(response.status, 200);
8611 assert.deepEqual(httpSnapshot.thread.messages, [{ ...message, role }]);
8612 assert.equal(httpSnapshot.page?.threadSequence, 2);
8613 const wsUrl = yield* getWsServerUrl("/ws");
8614 for (const afterSequence of [undefined, 1]) {
8615 const items = yield* Effect.scoped(
8616 withWsRpcClient(wsUrl, (client) =>
8617 client[ORCHESTRATION_WS_METHODS.subscribeThread]({
8618 threadId: defaultThreadId,
8619 requestCompletionMarker: true,
8620 ...(reasoningMessages ? { reasoningMessages } : {}),
8621 ...(afterSequence !== undefined ? { afterSequence } : {}),
8622 }).pipe(
8623 Stream.takeUntil((item) => item.kind === "synchronized"),
8624 Stream.runCollect,
8625 ),
8626 ),
8627 );
8628 if (afterSequence === undefined) {
8629 const first = items[0];
8630 assertTrue(first?.kind === "snapshot");
8631 assert.deepEqual(first.snapshot.thread.messages, [{ ...message, role }]);
8632 assert.equal(first.snapshot.page?.threadSequence, 2);
8633 }
8634 const events = items.filter((item) => item.kind === "event");
8635 assert.equal(events.length, 2);
8636 assert.deepEqual(events[0]?.event, { ...event, payload: { ...event.payload, role } });
8637 assert.deepEqual(events[1]?.event, answer);
8638 assert.deepEqual(items.at(-1), { kind: "synchronized" });
8639 }
8640 assert.equal(message.role, "reasoning");
8641 assert.equal(event.payload.role, "reasoning");
8642 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8643 );
8644 }
8645
8528 it.effect("marks a socket thread snapshot as synchronized when requested", () =>8646 it.effect("marks a socket thread snapshot as synchronized when requested", () =>
8529 Effect.gen(function* () {8647 Effect.gen(function* () {
8530 const thread = makeDefaultOrchestrationReadModel().threads[0]!;8648 const thread = makeDefaultOrchestrationReadModel().threads[0]!;
apps/server/src/textGeneration/ThreadTitleContext.test.ts+10
...@@ -45,4 +45,14 @@ describe("thread title context", () => {...@@ -45,4 +45,14 @@ describe("thread title context", () => {
45 }45 }
46 expect(formatThreadTitleContext([])).toEqual({ message: "", attachments: [] });46 expect(formatThreadTitleContext([])).toEqual({ message: "", attachments: [] });
47 });47 });
48
49 it("omits reasoning traces from generated titles", () => {
50 expect(
51 formatThreadTitleContext([
52 { role: "user", text: "Fix pairing" },
53 { role: "reasoning", text: "Consider token expiry, then the QR payload." },
54 { role: "assistant", text: "The QR token expired." },
55 ]).message,
56 ).toBe("USER:\nFix pairing\n\nASSISTANT:\nThe QR token expired.");
57 });
48});58});
apps/server/src/textGeneration/ThreadTitleContext.ts+8-2
...@@ -2,7 +2,7 @@ import type { ChatAttachment } from "@t3tools/contracts";...@@ -2,7 +2,7 @@ import type { ChatAttachment } from "@t3tools/contracts";
2import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";2import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
33
4export type ThreadTitleMessage = {4export type ThreadTitleMessage = {
5 readonly role: "user" | "assistant" | "system";5 readonly role: "user" | "assistant" | "system" | "reasoning";
6 readonly text: string;6 readonly text: string;
7 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;7 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
8};8};
...@@ -25,7 +25,13 @@ export function limitTitleMessage(text: string, budget: number): string {...@@ -25,7 +25,13 @@ export function limitTitleMessage(text: string, budget: number): string {
25/** Reserve space for user intent before adding assistant findings, in conversation order. */25/** Reserve space for user intent before adding assistant findings, in conversation order. */
26export function formatThreadTitleContext(messages: ReadonlyArray<ThreadTitleMessage>) {26export function formatThreadTitleContext(messages: ReadonlyArray<ThreadTitleMessage>) {
27 const sections = messages.flatMap((message, index) => {27 const sections = messages.flatMap((message, index) => {
28 if (message.role === "system" || (!message.text.trim() && !message.attachments?.length))28 // Thinking traces are working notes, not what the thread is about, and they
29 // dwarf the answer they precede. Titling on them would be worse and costlier.
30 if (
31 message.role === "system" ||
32 message.role === "reasoning" ||
33 (!message.text.trim() && !message.attachments?.length)
34 )
29 return [];35 return [];
30 return [{ index, message, prefix: `${message.role.toUpperCase()}:\n` }];36 return [{ index, message, prefix: `${message.role.toUpperCase()}:\n` }];
31 });37 });
apps/server/src/ws.ts+7-3
...@@ -1807,6 +1807,7 @@ const makeWsRpcLayer = (...@@ -1807,6 +1807,7 @@ const makeWsRpcLayer = (
1807 }),1807 }),
1808 threadResumeCompletionMarker: true,1808 threadResumeCompletionMarker: true,
1809 threadSnapshotPagination: true,1809 threadSnapshotPagination: true,
1810 reasoningMessages: true,
1810 };1811 };
1811 });1812 });
18121813
...@@ -2137,7 +2138,7 @@ const makeWsRpcLayer = (...@@ -2137,7 +2138,7 @@ const makeWsRpcLayer = (
2137 Stream.filter(isThisThreadDetailEvent),2138 Stream.filter(isThisThreadDetailEvent),
2138 Stream.map((event) => ({2139 Stream.map((event) => ({
2139 kind: "event" as const,2140 kind: "event" as const,
2140 event,2141 event: projectActivityEvent(event, input.reasoningMessages === true),
2141 })),2142 })),
2142 );2143 );
21432144
...@@ -2207,7 +2208,7 @@ const makeWsRpcLayer = (...@@ -2207,7 +2208,7 @@ const makeWsRpcLayer = (
2207 Stream.filter(isThisThreadDetailEvent),2208 Stream.filter(isThisThreadDetailEvent),
2208 Stream.map((event) => ({2209 Stream.map((event) => ({
2209 kind: "event" as const,2210 kind: "event" as const,
2210 event: projectActivityEvent(event),2211 event: projectActivityEvent(event, input.reasoningMessages === true),
2211 })),2212 })),
2212 Stream.mapError(2213 Stream.mapError(
2213 (cause) =>2214 (cause) =>
...@@ -2278,7 +2279,10 @@ const makeWsRpcLayer = (...@@ -2278,7 +2279,10 @@ const makeWsRpcLayer = (
2278 return Stream.concat(2279 return Stream.concat(
2279 Stream.make({2280 Stream.make({
2280 kind: "snapshot" as const,2281 kind: "snapshot" as const,
2281 snapshot: projectThreadDetailSnapshot(snapshot.value),2282 snapshot: projectThreadDetailSnapshot(
2283 snapshot.value,
2284 input.reasoningMessages === true,
2285 ),
2282 }),2286 }),
2283 afterSnapshot,2287 afterSnapshot,
2284 );2288 );
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+112
...@@ -1907,6 +1907,118 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1907,6 +1907,118 @@ describe("deriveMessagesTimelineRows", () => {
1907 expect(rows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "assistant-final-entry"]);1907 expect(rows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "assistant-final-entry"]);
1908 });1908 });
19091909
1910 const reasoningEntry = (id: string, at: string, turnId: string | null) => ({
1911 id,
1912 kind: "message" as const,
1913 createdAt: at,
1914 message: {
1915 id: id as never,
1916 role: "reasoning" as const,
1917 text: "weighing it up",
1918 turnId: turnId as never,
1919 createdAt: at,
1920 updatedAt: at,
1921 streaming: false,
1922 },
1923 });
1924
1925 const answerEntry = (id: string, at: string, turnId: string) => ({
1926 id,
1927 kind: "message" as const,
1928 createdAt: at,
1929 message: {
1930 id: id as never,
1931 role: "assistant" as const,
1932 text: "the answer",
1933 turnId: turnId as never,
1934 createdAt: at,
1935 updatedAt: at,
1936 streaming: false,
1937 },
1938 });
1939
1940 const toolEntry = (id: string, at: string, turnId: string) => ({
1941 id,
1942 kind: "work" as const,
1943 createdAt: at,
1944 entry: {
1945 id,
1946 createdAt: at,
1947 turnId: turnId as never,
1948 label: "Ran a command",
1949 tone: "tool" as const,
1950 },
1951 });
1952
1953 it("keeps a thought-only turn out of the work fold", () => {
1954 const rows = deriveMessagesTimelineRows({
1955 timelineEntries: [
1956 reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"),
1957 answerEntry("assistant-entry", "2026-01-01T00:00:02Z", "turn-1"),
1958 ],
1959 isWorking: false,
1960 activeTurnStartedAt: null,
1961 turnDiffSummaries: [],
1962 supportsConversationRollback: false,
1963 });
1964
1965 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
1966 expect(rows.map((row) => row.id)).toContain("reasoning-entry");
1967 });
1968
1969 it("folds a thinking block away with the tool work beside it", () => {
1970 const rows = deriveMessagesTimelineRows({
1971 timelineEntries: [
1972 reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"),
1973 toolEntry("tool-entry", "2026-01-01T00:00:02Z", "turn-1"),
1974 answerEntry("assistant-entry", "2026-01-01T00:00:03Z", "turn-1"),
1975 ],
1976 isWorking: false,
1977 activeTurnStartedAt: null,
1978 turnDiffSummaries: [],
1979 supportsConversationRollback: false,
1980 });
1981
1982 expect(rows.some((row) => row.kind === "turn-fold")).toBe(true);
1983 expect(rows.map((row) => row.id)).not.toContain("reasoning-entry");
1984 });
1985
1986 it("still folds a lone trailing tool call when a thought follows the answer", () => {
1987 const rows = deriveMessagesTimelineRows({
1988 timelineEntries: [
1989 toolEntry("tool-before", "2026-01-01T00:00:01Z", "turn-1"),
1990 answerEntry("assistant-entry", "2026-01-01T00:00:02Z", "turn-1"),
1991 reasoningEntry("reasoning-after", "2026-01-01T00:00:03Z", "turn-1"),
1992 toolEntry("tool-after", "2026-01-01T00:00:04Z", "turn-1"),
1993 ],
1994 isWorking: false,
1995 activeTurnStartedAt: null,
1996 turnDiffSummaries: [],
1997 supportsConversationRollback: false,
1998 });
1999
2000 const ids = rows.map((row) => row.id);
2001 expect(ids).not.toContain("tool-after");
2002 expect(ids).not.toContain("reasoning-after");
2003 });
2004
2005 it("does not let a stranded streaming thought hold a settled turn open", () => {
2006 const stranded = reasoningEntry("reasoning-stranded", "2026-01-01T00:00:01Z", "turn-1");
2007 const rows = deriveMessagesTimelineRows({
2008 timelineEntries: [
2009 { ...stranded, message: { ...stranded.message, streaming: true } },
2010 toolEntry("tool-entry", "2026-01-01T00:00:02Z", "turn-1"),
2011 answerEntry("assistant-entry", "2026-01-01T00:00:03Z", "turn-1"),
2012 ],
2013 isWorking: false,
2014 activeTurnStartedAt: null,
2015 turnDiffSummaries: [],
2016 supportsConversationRollback: false,
2017 });
2018
2019 expect(rows.some((row) => row.kind === "turn-fold")).toBe(true);
2020 });
2021
1910 it("derives a sane duration for a steer-superseded turn with one instant commentary message", () => {2022 it("derives a sane duration for a steer-superseded turn with one instant commentary message", () => {
1911 // A steer ends the previous turn early: its only message completes the2023 // A steer ends the previous turn early: its only message completes the
1912 // instant it is created, and trailing work entries land after it. The2024 // instant it is created, and trailing work entries land after it. The
apps/web/src/components/chat/MessagesTimeline.logic.ts+66-13
...@@ -299,7 +299,7 @@ function maxIsoTimestamp(a: string | null, b: string | null): string | null {...@@ -299,7 +299,7 @@ function maxIsoTimestamp(a: string | null, b: string | null): string | null {
299299
300export interface TimelineDurationMessage {300export interface TimelineDurationMessage {
301 id: string;301 id: string;
302 role: "user" | "assistant" | "system";302 role: ChatMessage["role"];
303 createdAt: string;303 createdAt: string;
304 updatedAt: string;304 updatedAt: string;
305 streaming: boolean;305 streaming: boolean;
...@@ -524,7 +524,7 @@ interface TurnFold {...@@ -524,7 +524,7 @@ interface TurnFold {
524 * user sends a message, the previous turn is still the "active" one until the524 * user sends a message, the previous turn is still the "active" one until the
525 * server creates the new turn, and folding must not flicker through that window.525 * server creates the new turn, and folding must not flicker through that window.
526 */526 */
527function deriveUnsettledTurnId(527export function deriveUnsettledTurnId(
528 latestTurn: TimelineLatestTurn | null,528 latestTurn: TimelineLatestTurn | null,
529 runningTurnId: TurnId | null,529 runningTurnId: TurnId | null,
530): TurnId | null {530): TurnId | null {
...@@ -546,7 +546,9 @@ function lastUserMessageIndex(timelineEntries: ReadonlyArray<TimelineEntry>): nu...@@ -546,7 +546,9 @@ function lastUserMessageIndex(timelineEntries: ReadonlyArray<TimelineEntry>): nu
546546
547function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {547function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
548 if (entry.kind === "message") {548 if (entry.kind === "message") {
549 return entry.message.role === "assistant" ? (entry.message.turnId ?? null) : null;549 return entry.message.role === "assistant" || entry.message.role === "reasoning"
550 ? (entry.message.turnId ?? null)
551 : null;
550 }552 }
551 if (entry.kind === "proposed-plan") {553 if (entry.kind === "proposed-plan") {
552 return entry.proposedPlan.turnId;554 return entry.proposedPlan.turnId;
...@@ -624,8 +626,13 @@ function deriveTurnFolds(input: {...@@ -624,8 +626,13 @@ function deriveTurnFolds(input: {
624 pendingUserBoundary = entry.message.createdAt;626 pendingUserBoundary = entry.message.createdAt;
625 continue;627 continue;
626 }628 }
629 // Thinking is work, so it folds with the rest of it. A provider that
630 // interleaves a block with every tool call would otherwise leave dozens of
631 // "Thought" rows standing beside the "Worked for ..." summary.
632 // Nothing folds while the turn is live, which is when traces are watched.
627 const turnId =633 const turnId =
628 entry.kind === "message" && entry.message.role === "assistant"634 entry.kind === "message" &&
635 (entry.message.role === "assistant" || entry.message.role === "reasoning")
629 ? (entry.message.turnId ?? null)636 ? (entry.message.turnId ?? null)
630 : entry.kind === "work"637 : entry.kind === "work"
631 ? (entry.entry.turnId ?? null)638 ? (entry.entry.turnId ?? null)
...@@ -652,7 +659,10 @@ function deriveTurnFolds(input: {...@@ -652,7 +659,10 @@ function deriveTurnFolds(input: {
652 if (input.terminalAssistantMessageIds.has(entry.message.id)) {659 if (input.terminalAssistantMessageIds.has(entry.message.id)) {
653 group.terminalEntry = entry;660 group.terminalEntry = entry;
654 }661 }
655 if (entry.message.streaming) {662 // A live turn is already excluded above, so only an answer still being
663 // written may hold a fold open. A thinking block stranded by a crashed
664 // provider keeps its streaming flag forever and must not.
665 if (entry.message.streaming && entry.message.role !== "reasoning") {
656 group.hasStreamingMessage = true;666 group.hasStreamingMessage = true;
657 }667 }
658 }668 }
...@@ -670,6 +680,15 @@ function deriveTurnFolds(input: {...@@ -670,6 +680,15 @@ function deriveTurnFolds(input: {
670 const terminalEntryIndex = group.terminalEntry680 const terminalEntryIndex = group.terminalEntry
671 ? group.entries.findIndex((entry) => entry.id === group.terminalEntry?.id)681 ? group.entries.findIndex((entry) => entry.id === group.terminalEntry?.id)
672 : group.entries.length;682 : group.entries.length;
683 // Thinking blocks do not count toward "one trailing activity": a block can
684 // follow the answer, and it must not stop that lone tool call from folding
685 // the way it did before traces existed. Loop-invariant, so it is counted
686 // once: a long turn re-derives these rows on every work-log change.
687 const trailingEntryCount = group.entries.filter(
688 (candidate, candidateIndex) =>
689 candidateIndex > terminalEntryIndex &&
690 !(candidate.kind === "message" && candidate.message.role === "reasoning"),
691 ).length;
673 for (const [index, entry] of group.entries.entries()) {692 for (const [index, entry] of group.entries.entries()) {
674 if (entry.id === group.terminalEntry?.id) {693 if (entry.id === group.terminalEntry?.id) {
675 continue;694 continue;
...@@ -677,10 +696,18 @@ function deriveTurnFolds(input: {...@@ -677,10 +696,18 @@ function deriveTurnFolds(input: {
677 const isCompaction =696 const isCompaction =
678 entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction";697 entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction";
679 const isSingleTrailingActivity =698 const isSingleTrailingActivity =
680 group.entries.length === terminalEntryIndex + 2 &&699 trailingEntryCount === 1 &&
681 entry.kind === "work" &&700 entry.kind === "work" &&
682 !workEntryDisplayIndicatesToolFailure(entry.entry);701 !workEntryDisplayIndicatesToolFailure(entry.entry);
683 if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) {702 // A thinking block after the answer folds with its turn rather than
703 // trailing under it, which is what mobile already does.
704 const isReasoning = entry.kind === "message" && entry.message.role === "reasoning";
705 if (
706 !isCompaction &&
707 !isReasoning &&
708 index > terminalEntryIndex &&
709 !isSingleTrailingActivity
710 ) {
684 continue;711 continue;
685 }712 }
686 // User input and subagent batches stay visible after their turn settles.713 // User input and subagent batches stay visible after their turn settles.
...@@ -696,13 +723,16 @@ function deriveTurnFolds(input: {...@@ -696,13 +723,16 @@ function deriveTurnFolds(input: {
696 continue;723 continue;
697 }724 }
698 // A lone compaction row stays visible on its own; it only folds away as725 // A lone compaction row stays visible on its own; it only folds away as
699 // part of a turn that already folds other work.726 // part of a turn that already folds other work. Thinking is the same: a
700 const hidesNonCompactionWork = group.entries.some(727 // question answered by thought alone keeps its "Thought" row
728 // rather than collapsing behind a "Worked for ..." that hides nothing else.
729 const hidesFoldableWork = group.entries.some(
701 (entry) =>730 (entry) =>
702 hiddenEntryIds.has(entry.id) &&731 hiddenEntryIds.has(entry.id) &&
703 !(entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"),732 !(entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction") &&
733 !(entry.kind === "message" && entry.message.role === "reasoning"),
704 );734 );
705 if (!hidesNonCompactionWork) {735 if (!hidesFoldableWork) {
706 continue;736 continue;
707 }737 }
708738
...@@ -778,7 +808,15 @@ function attachTrailingToolGroupsToAssistant(...@@ -778,7 +808,15 @@ function attachTrailingToolGroupsToAssistant(
778 let hasTrailingToolGroup = false;808 let hasTrailingToolGroup = false;
779 for (let index = messageIndex + 1; index < rows.length; index += 1) {809 for (let index = messageIndex + 1; index < rows.length; index += 1) {
780 const candidate = rows[index];810 const candidate = rows[index];
781 if (!candidate || candidate.kind === "message") {811 if (!candidate) {
812 break;
813 }
814 // A thinking block can follow the answer (the next one starts before its
815 // tool call); it is not another message in the conversation.
816 if (candidate.kind === "message" && candidate.message.role === "reasoning") {
817 continue;
818 }
819 if (candidate.kind === "message") {
782 break;820 break;
783 }821 }
784 if (candidate.kind === "work-toggle" && candidate.turnId === turnId) {822 if (candidate.kind === "work-toggle" && candidate.turnId === turnId) {
...@@ -1326,6 +1364,16 @@ export function deriveMessagesTimelineRows(input: {...@@ -1326,6 +1364,16 @@ export function deriveMessagesTimelineRows(input: {
1326 );1364 );
1327 }1365 }
1328 }1366 }
1367 // A live thinking block is the real version of the placeholder below, so it
1368 // suppresses it rather than sitting under a second "Thinking" row.
1369 const hasStreamingReasoningRow = nextRows.some(
1370 (row) =>
1371 row.kind === "message" &&
1372 row.message.role === "reasoning" &&
1373 row.message.streaming &&
1374 row.message.turnId !== null &&
1375 row.message.turnId === unsettledTurnId,
1376 );
13291377
1330 // A running setup owns the working slot above its card and shows no1378 // A running setup owns the working slot above its card and shows no
1331 // activity row of its own; every other state gets the usual tail.1379 // activity row of its own; every other state gets the usual tail.
...@@ -1333,7 +1381,12 @@ export function deriveMessagesTimelineRows(input: {...@@ -1333,7 +1381,12 @@ export function deriveMessagesTimelineRows(input: {
1333 if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) {1381 if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) {
1334 appendWorkingRow();1382 appendWorkingRow();
1335 }1383 }
1336 if (input.isWorking && !setupRunning && (!hasActivityRow || latestToolFailed)) {1384 if (
1385 input.isWorking &&
1386 !setupRunning &&
1387 !hasStreamingReasoningRow &&
1388 (!hasActivityRow || latestToolFailed)
1389 ) {
1337 nextRows.push({1390 nextRows.push({
1338 kind: "thinking",1391 kind: "thinking",
1339 id: LIVE_ACTIVITY_ROW_ID,1392 id: LIVE_ACTIVITY_ROW_ID,
apps/web/src/components/chat/MessagesTimeline.tsx+121
...@@ -167,6 +167,7 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis...@@ -167,6 +167,7 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis
167import {167import {
168 computeStableMessagesTimelineRows,168 computeStableMessagesTimelineRows,
169 deriveMessagesTimelineRowsWithState,169 deriveMessagesTimelineRowsWithState,
170 deriveUnsettledTurnId,
170 type MessagesTimelineRowsProjection,171 type MessagesTimelineRowsProjection,
171 liveWorkEntryLabel,172 liveWorkEntryLabel,
172 resolveAssistantMessageCopyState,173 resolveAssistantMessageCopyState,
...@@ -280,6 +281,8 @@ interface TimelineRowSharedState {...@@ -280,6 +281,8 @@ interface TimelineRowSharedState {
280 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;281 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
281 onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void;282 onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void;
282 onToggleSpawnRow: (entryId: string, expanded: boolean) => void;283 onToggleSpawnRow: (entryId: string, expanded: boolean) => void;
284 onToggleReasoning: (messageId: string, expanded: boolean) => void;
285 expandedReasoningMessageIds: ReadonlySet<string>;
283 workGroupViewState: WorkGroupViewState;286 workGroupViewState: WorkGroupViewState;
284 agentPanelModel: AgentPanelModel;287 agentPanelModel: AgentPanelModel;
285 expandedSpawnEntryIds: ReadonlySet<string>;288 expandedSpawnEntryIds: ReadonlySet<string>;
...@@ -298,6 +301,7 @@ interface TimelineRowActivityState {...@@ -298,6 +301,7 @@ interface TimelineRowActivityState {
298 isCompacting: boolean;301 isCompacting: boolean;
299 isRevertingCheckpoint: boolean;302 isRevertingCheckpoint: boolean;
300 latestTurnId: TurnId | null;303 latestTurnId: TurnId | null;
304 unsettledTurnId: TurnId | null;
301 /**305 /**
302 * A worktree setup whose script is still running after the agent took306 * A worktree setup whose script is still running after the agent took
303 * over. The working header shows it as a chip with a popover; the stage307 * over. The working header shows it as a chip with a popover; the stage
...@@ -516,6 +520,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -516,6 +520,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
516 const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState<ReadonlySet<string>>(520 const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState<ReadonlySet<string>>(
517 new Set(),521 new Set(),
518 );522 );
523 const [expandedReasoningMessageIds, setExpandedReasoningMessageIds] = useState<
524 ReadonlySet<string>
525 >(new Set());
519 const listIdentityKey = displayThreadKey ?? routeThreadKey;526 const listIdentityKey = displayThreadKey ?? routeThreadKey;
520 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");527 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
521 const listIdentityRef = useRef(listIdentityKey);528 const listIdentityRef = useRef(listIdentityKey);
...@@ -526,6 +533,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -526,6 +533,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
526 let paintedExpandedTurnIds = expandedTurnIds;533 let paintedExpandedTurnIds = expandedTurnIds;
527 let paintedExpandedWorkGroupIds = expandedWorkGroupIds;534 let paintedExpandedWorkGroupIds = expandedWorkGroupIds;
528 let paintedExpandedSpawnEntryIds = expandedSpawnEntryIds;535 let paintedExpandedSpawnEntryIds = expandedSpawnEntryIds;
536 let paintedExpandedReasoningMessageIds = expandedReasoningMessageIds;
529 if (listIdentityRef.current !== listIdentityKey) {537 if (listIdentityRef.current !== listIdentityKey) {
530 listIdentityRef.current = listIdentityKey;538 listIdentityRef.current = listIdentityKey;
531 previousLatestTurnRef.current = latestTurn;539 previousLatestTurnRef.current = latestTurn;
...@@ -533,9 +541,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -533,9 +541,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
533 paintedExpandedTurnIds = new Set();541 paintedExpandedTurnIds = new Set();
534 paintedExpandedWorkGroupIds = new Set();542 paintedExpandedWorkGroupIds = new Set();
535 paintedExpandedSpawnEntryIds = new Set();543 paintedExpandedSpawnEntryIds = new Set();
544 paintedExpandedReasoningMessageIds = new Set();
536 setExpandedTurnIds(paintedExpandedTurnIds);545 setExpandedTurnIds(paintedExpandedTurnIds);
537 setExpandedWorkGroupIds(paintedExpandedWorkGroupIds);546 setExpandedWorkGroupIds(paintedExpandedWorkGroupIds);
538 setExpandedSpawnEntryIds(paintedExpandedSpawnEntryIds);547 setExpandedSpawnEntryIds(paintedExpandedSpawnEntryIds);
548 setExpandedReasoningMessageIds(paintedExpandedReasoningMessageIds);
539 }549 }
540 const onToggleSpawnRow = useCallback((entryId: string, expanded: boolean) => {550 const onToggleSpawnRow = useCallback((entryId: string, expanded: boolean) => {
541 setExpandedSpawnEntryIds((current) => {551 setExpandedSpawnEntryIds((current) => {
...@@ -660,6 +670,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -660,6 +670,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({
660 },670 },
661 [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure],671 [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure],
662 );672 );
673 const onToggleReasoning = useCallback(
674 (messageId: string, expanded: boolean) => {
675 // The anchor must be the timeline row id, which for a message row is the
676 // message id, or position restoration is skipped for every row.
677 suspendEndScrollMaintenanceForDisclosure(messageId, !expanded);
678 setExpandedReasoningMessageIds((current) => {
679 if (current.has(messageId) === expanded) return current;
680 const next = new Set(current);
681 if (expanded) next.add(messageId);
682 else next.delete(messageId);
683 return next;
684 });
685 },
686 [suspendEndScrollMaintenanceForDisclosure],
687 );
663688
664 // An in-session interrupt leaves its turn expanded so the user keeps their689 // An in-session interrupt leaves its turn expanded so the user keeps their
665 // place; the next turn (or a reload, since this is local state) folds it.690 // place; the next turn (or a reload, since this is local state) folds it.
...@@ -944,6 +969,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -944,6 +969,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
944 onToggleWorkGroup,969 onToggleWorkGroup,
945 onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure,970 onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure,
946 onToggleSpawnRow,971 onToggleSpawnRow,
972 onToggleReasoning,
973 expandedReasoningMessageIds: paintedExpandedReasoningMessageIds,
947 workGroupViewState,974 workGroupViewState,
948 agentPanelModel: agentPanelModel ?? EMPTY_AGENT_PANEL_MODEL,975 agentPanelModel: agentPanelModel ?? EMPTY_AGENT_PANEL_MODEL,
949 expandedSpawnEntryIds: paintedExpandedSpawnEntryIds,976 expandedSpawnEntryIds: paintedExpandedSpawnEntryIds,
...@@ -977,6 +1004,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -977,6 +1004,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
977 onToggleWorkGroup,1004 onToggleWorkGroup,
978 suspendEndScrollMaintenanceForDisclosure,1005 suspendEndScrollMaintenanceForDisclosure,
979 onToggleSpawnRow,1006 onToggleSpawnRow,
1007 onToggleReasoning,
1008 paintedExpandedReasoningMessageIds,
980 workGroupViewState,1009 workGroupViewState,
981 agentPanelModel,1010 agentPanelModel,
982 paintedExpandedSpawnEntryIds,1011 paintedExpandedSpawnEntryIds,
...@@ -1003,6 +1032,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1003,6 +1032,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1003 isCompacting,1032 isCompacting,
1004 isRevertingCheckpoint,1033 isRevertingCheckpoint,
1005 latestTurnId: latestTurn?.turnId ?? null,1034 latestTurnId: latestTurn?.turnId ?? null,
1035 // The same value the row-derivation uses, so a block and the placeholder
1036 // beside it can never disagree about whether a turn is still live.
1037 unsettledTurnId: deriveUnsettledTurnId(latestTurn ?? null, runningTurnId),
1006 backgroundWorktreeSetup,1038 backgroundWorktreeSetup,
1007 }),1039 }),
1008 [1040 [
...@@ -1011,7 +1043,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1011,7 +1043,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1011 isRevertingCheckpoint,1043 isRevertingCheckpoint,
1012 isWorking,1044 isWorking,
1013 isPreparingWorktree,1045 isPreparingWorktree,
1046 // Deliberately the fields `deriveUnsettledTurnId` reads, not the object:
1047 // its identity changes on every thread-shell patch.
1014 latestTurn?.turnId,1048 latestTurn?.turnId,
1049 latestTurn?.state,
1050 latestTurn?.completedAt,
1051 runningTurnId,
1015 ],1052 ],
1016 );1053 );
10171054
...@@ -1453,6 +1490,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -1453,6 +1490,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
1453 : (row.kind === "message" &&1490 : (row.kind === "message" &&
1454 row.message.role === "assistant" &&1491 row.message.role === "assistant" &&
1455 !row.showAssistantMeta) ||1492 !row.showAssistantMeta) ||
1493 (row.kind === "message" && row.message.role === "reasoning") ||
1456 row.kind === "work" ||1494 row.kind === "work" ||
1457 row.kind === "work-live" ||1495 row.kind === "work-live" ||
1458 row.kind === "work-toggle" ||1496 row.kind === "work-toggle" ||
...@@ -1488,6 +1526,9 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -1488,6 +1526,9 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
1488 {row.kind === "message" && row.message.role === "assistant" ? (1526 {row.kind === "message" && row.message.role === "assistant" ? (
1489 <AssistantTimelineRow row={row} />1527 <AssistantTimelineRow row={row} />
1490 ) : null}1528 ) : null}
1529 {row.kind === "message" && row.message.role === "reasoning" ? (
1530 <ReasoningTimelineRow row={row} />
1531 ) : null}
1491 {row.kind === "assistant-meta" ? <AssistantMetaTimelineRow row={row} /> : null}1532 {row.kind === "assistant-meta" ? <AssistantMetaTimelineRow row={row} /> : null}
1492 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}1533 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
1493 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}1534 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
...@@ -2363,6 +2404,86 @@ function ThinkingTimelineRow() {...@@ -2363,6 +2404,86 @@ function ThinkingTimelineRow() {
2363 );2404 );
2364}2405}
23652406
2407/**
2408 * A provider's thinking trace. Collapsed by default: reasoning is context for
2409 * the answer, not the answer. The open/closed flag lives on the list so it
2410 * survives row recycling in the virtualizer.
2411 */
2412const ReasoningTimelineRow = memo(function ReasoningTimelineRow({
2413 row,
2414}: {
2415 row: Extract<TimelineRow, { kind: "message" }>;
2416}) {
2417 const ctx = use(TimelineRowCtx);
2418 const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx);
2419 const { message } = row;
2420 // A block left open by a crashed provider or a restarted server never gets
2421 // its completion. Only the live turn may claim to still be thinking, so a
2422 // settled turn cannot shimmer "Thinking" at the user forever.
2423 const streaming =
2424 Boolean(message.streaming) &&
2425 isWorking &&
2426 message.turnId !== null &&
2427 message.turnId === unsettledTurnId;
2428 const expanded = ctx.expandedReasoningMessageIds.has(message.id);
2429 const { onToggleReasoning } = ctx;
2430 const toggle = useCallback(() => {
2431 onToggleReasoning(message.id, !expanded);
2432 }, [expanded, message.id, onToggleReasoning]);
2433 const label = streaming ? "Thinking" : "Thought";
2434
2435 if (message.text.trim().length === 0 && !streaming) {
2436 return null;
2437 }
2438
2439 return (
2440 <div className={cn("flex flex-col", expanded && "mb-1")}>
2441 <button
2442 type="button"
2443 aria-expanded={expanded}
2444 onClick={toggle}
2445 className="flex cursor-pointer select-none items-center gap-1.5 rounded-md px-0.5 py-0.5 text-start transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
2446 >
2447 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
2448 <BrainIcon aria-hidden className="block size-4 shrink-0 stroke-[1.8] opacity-70" />
2449 </span>
2450 <span className="flex min-w-0 flex-1 items-center gap-1.5">
2451 <span
2452 ref={streaming ? observeVisibleAnimation : undefined}
2453 className="relative min-w-0 flex-1 truncate text-secondary-label text-sm leading-relaxed"
2454 >
2455 {label}
2456 {streaming ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null}
2457 </span>
2458 <span className="flex size-4 shrink-0 items-center justify-center" aria-hidden>
2459 <ChevronRightIcon
2460 className={cn(
2461 "size-3 shrink-0 text-icon-muted opacity-70 transition-transform duration-200",
2462 expanded && "rotate-90",
2463 )}
2464 />
2465 </span>
2466 </span>
2467 </button>
2468 {expanded ? (
2469 <div className="mt-1 ms-7 max-h-96 overflow-auto rounded-md bg-muted/40 px-3 py-2 text-secondary-label select-text">
2470 <ChatMarkdown
2471 text={message.text}
2472 cwd={ctx.markdownCwd}
2473 threadRef={ctx.threadRef ?? undefined}
2474 isStreaming={streaming}
2475 lineBreaks
2476 skills={ctx.skills}
2477 headingLevelOffset={MESSAGE_HEADING_LEVEL}
2478 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
2479 onImageExpand={ctx.onImageExpand}
2480 />
2481 </div>
2482 ) : null}
2483 </div>
2484 );
2485});
2486
2366function CompactingLabel() {2487function CompactingLabel() {
2367 return (2488 return (
2368 <span className="inline-flex items-center gap-1.5">2489 <span className="inline-flex items-center gap-1.5">
apps/web/src/components/settings/SettingsPanels.tsx+2-1
...@@ -187,7 +187,8 @@ const RESPONSE_STREAMING_MODE_LABELS: Record<ResponseStreamingMode, string> = {...@@ -187,7 +187,8 @@ const RESPONSE_STREAMING_MODE_LABELS: Record<ResponseStreamingMode, string> = {
187const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record<ResponseStreamingMode, string> = {187const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record<ResponseStreamingMode, string> = {
188 turn: "Text appears once the agent finishes its turn.",188 turn: "Text appears once the agent finishes its turn.",
189 paragraph: "Each paragraph or code block appears as soon as it is complete.",189 paragraph: "Each paragraph or code block appears as soon as it is complete.",
190 token: "Every token repaints the message as it arrives. Slower and harder to read.",190 token:
191 "Every token repaints the answer as it arrives. Slower and harder to read. Thinking traces still arrive a paragraph at a time.",
191};192};
192193
193const TIMESTAMP_FORMAT_LABELS = {194const TIMESTAMP_FORMAT_LABELS = {
apps/web/src/connection/storage.ts+4-2
...@@ -65,8 +65,10 @@ const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);...@@ -65,8 +65,10 @@ const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);
65// exists for rollback safety: a pre-pagination client would decode a windowed65// exists for rollback safety: a pre-pagination client would decode a windowed
66// v2 record, silently drop the unknown `page` field, and treat the partial66// v2 record, silently drop the unknown `page` field, and treat the partial
67// thread as complete forever. Older entries fail to decode → cold cache.67// thread as complete forever. Older entries fail to decode → cold cache.
68// v4 reloads pre-thinking caches: their fallback system roles cannot recover
69// settled reasoning messages by resuming afterSequence.
68const StoredThreadSnapshot = Schema.Struct({70const StoredThreadSnapshot = Schema.Struct({
69 schemaVersion: Schema.Literal(3),71 schemaVersion: Schema.Literal(4),
70 environmentId: EnvironmentId,72 environmentId: EnvironmentId,
71 threadId: ThreadId,73 threadId: ThreadId,
72 snapshot: OrchestrationThreadDetailSnapshot,74 snapshot: OrchestrationThreadDetailSnapshot,
...@@ -679,7 +681,7 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -679,7 +681,7 @@ export const connectionStorageLayer = Layer.effectContext(
679 saveThread: (environmentId, snapshot) =>681 saveThread: (environmentId, snapshot) =>
680 Effect.gen(function* () {682 Effect.gen(function* () {
681 const encoded = yield* encodeStoredThreadSnapshot({683 const encoded = yield* encodeStoredThreadSnapshot({
682 schemaVersion: 3,684 schemaVersion: 4,
683 environmentId,685 environmentId,
684 threadId: snapshot.thread.id,686 threadId: snapshot.thread.id,
685 snapshot,687 snapshot,
apps/web/src/session-logic.ts+5-3
...@@ -1613,11 +1613,13 @@ export function createMessageAttachmentPreviewProjector() {...@@ -1613,11 +1613,13 @@ export function createMessageAttachmentPreviewProjector() {
1613 };1613 };
1614}1614}
16151615
1616/** Text and update time do not change a streaming assistant message's timeline structure. */1616const streamsText = (role: ChatMessage["role"]) => role === "assistant" || role === "reasoning";
1617
1618/** Text and update time do not change a streaming message's timeline structure. */
1617export function isStreamingMessageTextUpdate(previous: ChatMessage, next: ChatMessage): boolean {1619export function isStreamingMessageTextUpdate(previous: ChatMessage, next: ChatMessage): boolean {
1618 if (1620 if (
1619 previous.role !== "assistant" ||1621 !streamsText(previous.role) ||
1620 next.role !== "assistant" ||1622 previous.role !== next.role ||
1621 !previous.streaming ||1623 !previous.streaming ||
1622 !next.streaming1624 !next.streaming
1623 ) {1625 ) {
packages/client-runtime/src/state/environmentHttpAuth.test.ts+2
...@@ -210,6 +210,7 @@ const LOADERS: ReadonlyArray<{...@@ -210,6 +210,7 @@ const LOADERS: ReadonlyArray<{
210 ...input,210 ...input,
211 threadId: THREAD.thread.id,211 threadId: THREAD.thread.id,
212 window: { turnLimit: 20, beforeCursor: "older-page" },212 window: { turnLimit: 20, beforeCursor: "older-page" },
213 reasoningMessages: true,
213 }),214 }),
214 },215 },
215];216];
...@@ -252,6 +253,7 @@ describe("authenticated environment HTTP requests", () => {...@@ -252,6 +253,7 @@ describe("authenticated environment HTTP requests", () => {
252 if (loader.name === "older thread history") {253 if (loader.name === "older thread history") {
253 expect(url.searchParams.get("turnLimit")).toBe("20");254 expect(url.searchParams.get("turnLimit")).toBe("20");
254 expect(url.searchParams.get("beforeCursor")).toBe("older-page");255 expect(url.searchParams.get("beforeCursor")).toBe("older-page");
256 expect(url.searchParams.get("reasoningMessages")).toBe("true");
255 }257 }
256 expect(PREPARED.httpAuthorization).toMatchObject({ accessToken: "expired-token" });258 expect(PREPARED.httpAuthorization).toMatchObject({ accessToken: "expired-token" });
257 }),259 }),
packages/client-runtime/src/state/threadSnapshotHttp.ts+10-1
...@@ -42,6 +42,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(...@@ -42,6 +42,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(
42 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;42 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
43 readonly timeoutMs?: number;43 readonly timeoutMs?: number;
44 readonly window?: ThreadSnapshotWindow;44 readonly window?: ThreadSnapshotWindow;
45 readonly reasoningMessages?: boolean;
45}) {46}) {
46 return yield* executeAuthenticatedEnvironmentHttpRequest({47 return yield* executeAuthenticatedEnvironmentHttpRequest({
47 ...input,48 ...input,
...@@ -54,6 +55,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(...@@ -54,6 +55,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(
54 client.threadSnapshot({55 client.threadSnapshot({
55 params: { threadId: input.threadId },56 params: { threadId: input.threadId },
56 payload: {57 payload: {
58 ...(input.reasoningMessages === true ? { reasoningMessages: "true" as const } : {}),
57 ...(input.window !== undefined ? { turnLimit: input.window.turnLimit } : {}),59 ...(input.window !== undefined ? { turnLimit: input.window.turnLimit } : {}),
58 ...(input.window?.beforeCursor !== undefined60 ...(input.window?.beforeCursor !== undefined
59 ? { beforeCursor: input.window.beforeCursor }61 ? { beforeCursor: input.window.beforeCursor }
...@@ -79,6 +81,7 @@ export class ThreadSnapshotLoader extends Context.Service<...@@ -79,6 +81,7 @@ export class ThreadSnapshotLoader extends Context.Service<
79 prepared: PreparedConnection,81 prepared: PreparedConnection,
80 threadId: ThreadId,82 threadId: ThreadId,
81 window?: ThreadSnapshotWindow,83 window?: ThreadSnapshotWindow,
84 reasoningMessages?: boolean,
82 ) => Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>>;85 ) => Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>>;
83 }86 }
84>()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {}87>()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {}
...@@ -97,12 +100,18 @@ export const threadSnapshotLoaderLayer: Layer.Layer<...@@ -97,12 +100,18 @@ export const threadSnapshotLoaderLayer: Layer.Layer<
97 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);100 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
98 const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization);101 const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization);
99 return ThreadSnapshotLoader.of({102 return ThreadSnapshotLoader.of({
100 load: (prepared: PreparedConnection, threadId: ThreadId, window?: ThreadSnapshotWindow) =>103 load: (
104 prepared: PreparedConnection,
105 threadId: ThreadId,
106 window?: ThreadSnapshotWindow,
107 reasoningMessages?: boolean,
108 ) =>
101 fetchEnvironmentThreadSnapshot({109 fetchEnvironmentThreadSnapshot({
102 prepared,110 prepared,
103 threadId,111 threadId,
104 signer,112 signer,
105 remoteAuthorization,113 remoteAuthorization,
114 ...(reasoningMessages === true ? { reasoningMessages: true } : {}),
106 ...(window !== undefined ? { window } : {}),115 ...(window !== undefined ? { window } : {}),
107 }).pipe(116 }).pipe(
108 Effect.map(Option.some<OrchestrationThreadDetailSnapshot>),117 Effect.map(Option.some<OrchestrationThreadDetailSnapshot>),
packages/client-runtime/src/state/threads-pagination.test.ts+32-1
...@@ -133,6 +133,7 @@ type LoaderResponse = Option.Option<OrchestrationThreadDetailSnapshot>;...@@ -133,6 +133,7 @@ type LoaderResponse = Option.Option<OrchestrationThreadDetailSnapshot>;
133133
134const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (options?: {134const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (options?: {
135 readonly paginationCapability?: boolean;135 readonly paginationCapability?: boolean;
136 readonly reasoningCapability?: boolean;
136 readonly initialResponse?: LoaderResponse;137 readonly initialResponse?: LoaderResponse;
137 /** Cached snapshot returned by the cache store (simulates a warm cache). */138 /** Cached snapshot returned by the cache store (simulates a warm cache). */
138 readonly cached?: OrchestrationThreadDetailSnapshot;139 readonly cached?: OrchestrationThreadDetailSnapshot;
...@@ -140,6 +141,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt...@@ -140,6 +141,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt
140 const inputs = yield* Queue.unbounded<OrchestrationThreadStreamItem>();141 const inputs = yield* Queue.unbounded<OrchestrationThreadStreamItem>();
141 const observed = yield* Queue.unbounded<EnvironmentThreadState>();142 const observed = yield* Queue.unbounded<EnvironmentThreadState>();
142 const loaderWindows = yield* Ref.make<ReadonlyArray<ThreadSnapshotWindow | undefined>>([]);143 const loaderWindows = yield* Ref.make<ReadonlyArray<ThreadSnapshotWindow | undefined>>([]);
144 const loaderReasoning = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]);
143 const lastSubscribeInput = yield* Ref.make<Record<string, unknown> | undefined>(undefined);145 const lastSubscribeInput = yield* Ref.make<Record<string, unknown> | undefined>(undefined);
144 const savedThreads = yield* Ref.make<ReadonlyArray<OrchestrationThreadDetailSnapshot>>([]);146 const savedThreads = yield* Ref.make<ReadonlyArray<OrchestrationThreadDetailSnapshot>>([]);
145 // Older-page responses resolve through deferreds so tests can interleave147 // Older-page responses resolve through deferreds so tests can interleave
...@@ -156,6 +158,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt...@@ -156,6 +158,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt
156 client,158 client,
157 initialConfig: Effect.succeed({159 initialConfig: Effect.succeed({
158 threadSnapshotPagination: options?.paginationCapability !== false,160 threadSnapshotPagination: options?.paginationCapability !== false,
161 reasoningMessages: options?.reasoningCapability === true,
159 } as never),162 } as never),
160 subscribeServerConfig: (input) => client.subscribeServerConfig(input),163 subscribeServerConfig: (input) => client.subscribeServerConfig(input),
161 ready: Effect.void,164 ready: Effect.void,
...@@ -169,8 +172,9 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt...@@ -169,8 +172,9 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt
169 Option.some(PREPARED),172 Option.some(PREPARED),
170 );173 );
171 const snapshotLoader = ThreadSnapshotLoader.of({174 const snapshotLoader = ThreadSnapshotLoader.of({
172 load: (_prepared, _threadId, window) =>175 load: (_prepared, _threadId, window, reasoningMessages) =>
173 Ref.update(loaderWindows, (current) => [...current, window]).pipe(176 Ref.update(loaderWindows, (current) => [...current, window]).pipe(
177 Effect.andThen(Ref.update(loaderReasoning, (current) => [...current, reasoningMessages])),
174 Effect.andThen(178 Effect.andThen(
175 window?.beforeCursor === undefined179 window?.beforeCursor === undefined
176 ? Effect.succeed(180 ? Effect.succeed(
...@@ -231,6 +235,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt...@@ -231,6 +235,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt
231 awaitState,235 awaitState,
232 resolveNextPage,236 resolveNextPage,
233 loaderWindows,237 loaderWindows,
238 loaderReasoning,
234 lastSubscribeInput,239 lastSubscribeInput,
235 savedThreads,240 savedThreads,
236 threadState,241 threadState,
...@@ -287,6 +292,32 @@ const revertEvent = (sequence: number): OrchestrationThreadStreamItem => ({...@@ -287,6 +292,32 @@ const revertEvent = (sequence: number): OrchestrationThreadStreamItem => ({
287});292});
288293
289describe("thread pagination state", () => {294describe("thread pagination state", () => {
295 for (const reasoningCapability of [false, true]) {
296 it.effect(
297 `negotiates reasoning for initial, older and socket reads: ${reasoningCapability}`,
298 () =>
299 Effect.gen(function* () {
300 const harness = yield* makeHarness({
301 reasoningCapability,
302 initialResponse: Option.some(WINDOWED_SNAPSHOT),
303 });
304 yield* harness.awaitState((value) => Option.isSome(value.page));
305 const input = yield* Ref.get(harness.lastSubscribeInput);
306 expect(input?.reasoningMessages).toBe(reasoningCapability ? true : undefined);
307 expect(yield* Ref.get(harness.loaderReasoning)).toEqual([reasoningCapability]);
308 expect(requestOlderThreadTurns(TARGET.environmentId, THREAD_ID)).toBe(true);
309 yield* harness.resolveNextPage(Option.some(OLDER_PAGE));
310 yield* harness.awaitState((value) =>
311 Option.match(value.page, { onNone: () => false, onSome: (page) => !page.hasMore }),
312 );
313 expect(yield* Ref.get(harness.loaderReasoning)).toEqual([
314 reasoningCapability,
315 reasoningCapability,
316 ]);
317 }),
318 );
319 }
320
290 it.effect("windows the initial load when the server advertises pagination", () =>321 it.effect("windows the initial load when the server advertises pagination", () =>
291 Effect.gen(function* () {322 Effect.gen(function* () {
292 const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) });323 const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) });
packages/client-runtime/src/state/threads.ts+13-1
...@@ -258,6 +258,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -258,6 +258,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
258 // from the session config. Gates loadOlderTurns so a reconnect to a258 // from the session config. Gates loadOlderTurns so a reconnect to a
259 // pre-pagination server never sends unsupported window parameters.259 // pre-pagination server never sends unsupported window parameters.
260 const paginationSupported = yield* Ref.make(false);260 const paginationSupported = yield* Ref.make(false);
261 const reasoningMessagesSupported = yield* Ref.make(false);
261 // An older page whose thread watermark is ahead of the live state, parked262 // An older page whose thread watermark is ahead of the live state, parked
262 // until the subscription catches up (see mergeOlderPage's caller). At most263 // until the subscription catches up (see mergeOlderPage's caller). At most
263 // one can exist because loadOlderTurns no-ops while loadingOlder is true.264 // one can exist because loadOlderTurns no-ops while loadingOlder is true.
...@@ -336,6 +337,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -336,6 +337,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
336 // window parameters to a server that may not accept them (review337 // window parameters to a server that may not accept them (review
337 // finding). makeSubscribeInput re-sets it from the next session's config.338 // finding). makeSubscribeInput re-sets it from the next session's config.
338 yield* Ref.set(paginationSupported, false);339 yield* Ref.set(paginationSupported, false);
340 yield* Ref.set(reasoningMessagesSupported, false);
339 yield* SubscriptionRef.update(state, (current) => ({341 yield* SubscriptionRef.update(state, (current) => ({
340 ...current,342 ...current,
341 status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data),343 status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data),
...@@ -655,7 +657,12 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -655,7 +657,12 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
655 turnLimit: OLDER_THREAD_PAGE_USER_TURN_LIMIT,657 turnLimit: OLDER_THREAD_PAGE_USER_TURN_LIMIT,
656 beforeCursor: page.beforeCursor,658 beforeCursor: page.beforeCursor,
657 };659 };
658 const response = yield* snapshotLoader.load(prepared, threadId, window);660 const response = yield* snapshotLoader.load(
661 prepared,
662 threadId,
663 window,
664 yield* Ref.get(reasoningMessagesSupported),
665 );
659 // Staleness check and merge run under the same lock as stream-item666 // Staleness check and merge run under the same lock as stream-item
660 // application, so a revert/snapshot cannot land between them (TOCTOU667 // application, so a revert/snapshot cannot land between them (TOCTOU
661 // review finding) — anything that rewrites history bumps the epoch668 // review finding) — anything that rewrites history bumps the epoch
...@@ -747,6 +754,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -747,6 +754,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
747 ({}) as {754 ({}) as {
748 threadResumeCompletionMarker?: boolean;755 threadResumeCompletionMarker?: boolean;
749 threadSnapshotPagination?: boolean;756 threadSnapshotPagination?: boolean;
757 reasoningMessages?: boolean;
750 },758 },
751 ),759 ),
752 );760 );
...@@ -755,6 +763,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -755,6 +763,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
755 // servers reject unknown query params, and a windowed WS fallback to763 // servers reject unknown query params, and a windowed WS fallback to
756 // such a server would silently hide history.764 // such a server would silently hide history.
757 const supportsPagination = config.threadSnapshotPagination === true;765 const supportsPagination = config.threadSnapshotPagination === true;
766 const supportsReasoningMessages = config.reasoningMessages === true;
767 yield* Ref.set(reasoningMessagesSupported, supportsReasoningMessages);
758 yield* Ref.set(paginationSupported, supportsPagination);768 yield* Ref.set(paginationSupported, supportsPagination);
759 yield* Ref.set(awaitingCompletion, supportsCompletionMarker);769 yield* Ref.set(awaitingCompletion, supportsCompletionMarker);
760 yield* markSynchronizing;770 yield* markSynchronizing;
...@@ -801,6 +811,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -801,6 +811,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
801 prepared,811 prepared,
802 threadId,812 threadId,
803 supportsPagination ? { turnLimit: INITIAL_THREAD_USER_TURN_LIMIT } : undefined,813 supportsPagination ? { turnLimit: INITIAL_THREAD_USER_TURN_LIMIT } : undefined,
814 supportsReasoningMessages,
804 );815 );
805 if (Option.isSome(httpSnapshot)) {816 if (Option.isSome(httpSnapshot)) {
806 yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value });817 yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value });
...@@ -822,6 +833,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -822,6 +833,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
822 threadId,833 threadId,
823 ...(canResume ? { afterSequence: sequence } : {}),834 ...(canResume ? { afterSequence: sequence } : {}),
824 ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),835 ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
836 ...(supportsReasoningMessages ? { reasoningMessages: true as const } : {}),
825 // The WS fallback snapshot (sent when afterSequence is missing or837 // The WS fallback snapshot (sent when afterSequence is missing or
826 // the gap is too large) should be windowed the same as the HTTP838 // the gap is too large) should be windowed the same as the HTTP
827 // path; without this a resume failure re-downloads the full thread.839 // path; without this a resume failure re-downloads the full thread.
packages/contracts/src/environmentHttp.ts+1
...@@ -498,6 +498,7 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({...@@ -498,6 +498,7 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({
498// to strings). Both fields optional: omitting them keeps the full-snapshot498// to strings). Both fields optional: omitting them keeps the full-snapshot
499// behavior, so pagination stays opt-in per request.499// behavior, so pagination stays opt-in per request.
500const EnvironmentOrchestrationThreadSnapshotQuery = {500const EnvironmentOrchestrationThreadSnapshotQuery = {
501 reasoningMessages: Schema.optional(Schema.Literal("true")),
501 turnLimit: Schema.optional(502 turnLimit: Schema.optional(
502 Schema.FiniteFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)),503 Schema.FiniteFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)),
503 ),504 ),
packages/contracts/src/orchestration.ts+32-1
...@@ -539,7 +539,15 @@ export const OrchestrationProject = Schema.Struct({...@@ -539,7 +539,15 @@ export const OrchestrationProject = Schema.Struct({
539});539});
540export type OrchestrationProject = typeof OrchestrationProject.Type;540export type OrchestrationProject = typeof OrchestrationProject.Type;
541541
542export const OrchestrationMessageRole = Schema.Literals(["user", "assistant", "system"]);542/** `reasoning` carries a provider's thinking trace: a reasoning summary, or
543 * the raw chain of thought when the model exposes one. It is a sibling of the
544 * assistant text it precedes, not a replacement for it. */
545export const OrchestrationMessageRole = Schema.Literals([
546 "user",
547 "assistant",
548 "system",
549 "reasoning",
550]);
543export type OrchestrationMessageRole = typeof OrchestrationMessageRole.Type;551export type OrchestrationMessageRole = typeof OrchestrationMessageRole.Type;
544552
545export const OrchestrationMessage = Schema.Struct({553export const OrchestrationMessage = Schema.Struct({
...@@ -973,6 +981,8 @@ export type OrchestrationSubscribeShellInput = typeof OrchestrationSubscribeShel...@@ -973,6 +981,8 @@ export type OrchestrationSubscribeShellInput = typeof OrchestrationSubscribeShel
973981
974export const OrchestrationSubscribeThreadInput = Schema.Struct({982export const OrchestrationSubscribeThreadInput = Schema.Struct({
975 threadId: ThreadId,983 threadId: ThreadId,
984 /** Opt in to reasoning roles; older clients receive system messages instead. */
985 reasoningMessages: Schema.optionalKey(Schema.Boolean),
976 /**986 /**
977 * When provided, the server skips the initial snapshot frame and instead987 * When provided, the server skips the initial snapshot frame and instead
978 * replays events after this sequence before streaming live events. Clients988 * replays events after this sequence before streaming live events. Clients
...@@ -1471,6 +1481,25 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({...@@ -1471,6 +1481,25 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({
1471 createdAt: IsoDateTime,1481 createdAt: IsoDateTime,
1472});1482});
14731483
1484const ThreadMessageReasoningDeltaCommand = Schema.Struct({
1485 type: Schema.Literal("thread.message.reasoning.delta"),
1486 commandId: CommandId,
1487 threadId: ThreadId,
1488 messageId: MessageId,
1489 delta: Schema.String,
1490 turnId: Schema.optional(TurnId),
1491 createdAt: IsoDateTime,
1492});
1493
1494const ThreadMessageReasoningCompleteCommand = Schema.Struct({
1495 type: Schema.Literal("thread.message.reasoning.complete"),
1496 commandId: CommandId,
1497 threadId: ThreadId,
1498 messageId: MessageId,
1499 turnId: Schema.optional(TurnId),
1500 createdAt: IsoDateTime,
1501});
1502
1474const ThreadHistoryImportCommand = Schema.Struct({1503const ThreadHistoryImportCommand = Schema.Struct({
1475 type: Schema.Literal("thread.history.import"),1504 type: Schema.Literal("thread.history.import"),
1476 commandId: CommandId,1505 commandId: CommandId,
...@@ -1599,6 +1628,8 @@ const InternalOrchestrationCommand = Schema.Union([...@@ -1599,6 +1628,8 @@ const InternalOrchestrationCommand = Schema.Union([
1599 ThreadSessionSetCommand,1628 ThreadSessionSetCommand,
1600 ThreadMessageAssistantDeltaCommand,1629 ThreadMessageAssistantDeltaCommand,
1601 ThreadMessageAssistantCompleteCommand,1630 ThreadMessageAssistantCompleteCommand,
1631 ThreadMessageReasoningDeltaCommand,
1632 ThreadMessageReasoningCompleteCommand,
1602 ThreadHistoryImportCommand,1633 ThreadHistoryImportCommand,
1603 ThreadMessageUserAppendCommand,1634 ThreadMessageUserAppendCommand,
1604 ThreadProposedPlanUpsertCommand,1635 ThreadProposedPlanUpsertCommand,
packages/contracts/src/server.ts+2
...@@ -579,6 +579,8 @@ export const ServerConfig = Schema.Struct({...@@ -579,6 +579,8 @@ export const ServerConfig = Schema.Struct({
579 * fields to servers that don't advertise this.579 * fields to servers that don't advertise this.
580 */580 */
581 threadSnapshotPagination: Schema.optionalKey(Schema.Boolean),581 threadSnapshotPagination: Schema.optionalKey(Schema.Boolean),
582 /** Whether thread reads accept the reasoningMessages opt-in. */
583 reasoningMessages: Schema.optionalKey(Schema.Boolean),
582 /**584 /**
583 * Palettes published by this environment's machine. Never sent in a config585 * Palettes published by this environment's machine. Never sent in a config
584 * snapshot: the theme stream emits the current set before any change, so a586 * snapshot: the theme stream emits the current set before any change, so a