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 @@
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";
210import { describe, expect, it } from "@effect/vitest";
311import * as Effect from "effect/Effect";
412import * as Option from "effect/Option";
......@@ -69,6 +77,80 @@ function makeDatabase() {
6977}
7078
7179describe("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
72154 it.effect("round-trips schema-validated VCS refs", () =>
73155 Effect.gen(function* () {
74156 const memory = makeDatabase();
apps/mobile/src/connection/environment-cache-store.ts+3-1
......@@ -21,7 +21,9 @@ const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
2121// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump
2222// makes pre-pagination clients discard the record instead of decoding a
2323// 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;
2527const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1;
2628const VCS_REFS_CACHE_SCHEMA_VERSION = 1;
2729
apps/mobile/src/features/threads/ThreadFeed.tsx+92-8
......@@ -144,6 +144,7 @@ import {
144144} from "@t3tools/mobile-markdown-text/links";
145145import {
146146 deriveThreadFeedPresentation,
147 deriveUnsettledTurnId,
147148 isContextCompactionActivityGroup,
148149 type ThreadFeedEntry,
149150 type ThreadFeedLatestTurn,
......@@ -158,6 +159,7 @@ import {
158159 collapsedWorkLogHeight,
159160 ThreadAgentSpawnCard,
160161 ThreadDisclosureChevron,
162 ThreadReasoningRow,
161163 ThreadWorkGroupToggle,
162164 ThreadThinkingRow,
163165 ThreadWorkLog,
......@@ -193,6 +195,9 @@ import {
193195 ThreadMarkdownImageView,
194196} from "./ThreadMarkdownImage";
195197
198/** `ml-7` gutter plus the `px-3` padding of the expanded reasoning container. */
199const REASONING_CONTENT_INSET = 52;
200
196201const WIDE_MARKDOWN_BLOCK_OPTIONS = {
197202 // Native iOS blockquotes and adjacent selectable text are separate layout
198203 // chunks. Giving their shrink-to-fit bubble a definite width keeps both
......@@ -1348,14 +1353,17 @@ function renderFeedEntry(
13481353 > & {
13491354 readonly copiedRowId: string | null;
13501355 readonly expandedWorkRows: Record<string, boolean>;
1356 readonly expandedReasoningMessageIds: ReadonlySet<string>;
13511357 readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
13521358 readonly workGroupScrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
13531359 readonly terminalAssistantMessageIds: ReadonlySet<string>;
13541360 readonly unsettledTurnId: TurnId | null;
1361 readonly isWorking: boolean;
13551362 readonly onCopyWorkRow: (rowId: string, value: string) => void;
13561363 readonly onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
13571364 readonly onToggleWorkRow: (rowId: string, anchorKey: string) => void;
13581365 readonly onToggleTurnFold: (turnId: TurnId) => void;
1366 readonly onToggleReasoning: (messageId: string) => void;
13591367 readonly onPressPreview: (source: FilePreviewSource) => void;
13601368 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
13611369 readonly markdownLinkHandlers: MarkdownLinkHandlers;
......@@ -1467,6 +1475,38 @@ function renderFeedEntry(
14671475
14681476 if (entry.type === "message") {
14691477 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 }
14701510 const isUser = message.role === "user";
14711511 const renderedText = renderAssistantCitationsAsText(message.text);
14721512 const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
......@@ -1960,13 +2000,21 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19602000 readonly expandedWorkGroups: Record<string, boolean>;
19612001 readonly expandedWorkRows: Record<string, boolean>;
19622002 readonly expandedTurnIds: ReadonlySet<TurnId>;
2003 readonly expandedReasoningMessageIds: ReadonlySet<string>;
19632004 }>({
19642005 copiedRowId: null,
19652006 expandedWorkGroups: {},
19662007 expandedWorkRows: {},
19672008 expandedTurnIds: new Set(),
2009 expandedReasoningMessageIds: new Set(),
19682010 });
1969 const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;
2011 const {
2012 copiedRowId,
2013 expandedWorkGroups,
2014 expandedWorkRows,
2015 expandedTurnIds,
2016 expandedReasoningMessageIds,
2017 } = interactionState;
19702018 const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null);
19712019 const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);
19722020 const fileShareSourceIdentifier = useId();
......@@ -2218,11 +2266,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22182266 );
22192267 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage);
22202268 const reviewCommentColors = useReviewCommentColors();
2221 const unsettledTurnId =
2222 props.latestTurn &&
2223 (props.latestTurn.completedAt === null || props.latestTurn.state === "running")
2224 ? props.latestTurn.turnId
2225 : null;
2269 // One definition of "still live", shared with the fold derivation: two
2270 // copies of this test are what let a row and the fold beside it disagree.
2271 const unsettledTurnId = deriveUnsettledTurnId(props.latestTurn ?? null);
22262272 // LegendList does not invalidate visible rows when only the renderItem closure changes.
22272273 // Include turn completion so unchanged message rows reveal their footer and spacing
22282274 // even when the final message update arrives before the turn settles.
......@@ -2232,6 +2278,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22322278 unsettledTurnId,
22332279 copiedRowId,
22342280 expandedWorkRows,
2281 expandedReasoningMessageIds,
22352282 workRowSizing,
22362283 iconSubtleColor,
22372284 markdownStyles,
......@@ -2245,6 +2292,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22452292 unsettledTurnId,
22462293 copiedRowId,
22472294 expandedWorkRows,
2295 expandedReasoningMessageIds,
22482296 workRowSizing,
22492297 iconSubtleColor,
22502298 markdownStyles,
......@@ -2517,7 +2565,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
25172565 if (disclosureAnchorKeyRef.current !== null) {
25182566 settleDisclosureAfterLayout();
25192567 }
2520 }, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]);
2568 }, [
2569 expandedTurnIds,
2570 expandedWorkGroups,
2571 expandedWorkRows,
2572 expandedReasoningMessageIds,
2573 settleDisclosureAfterLayout,
2574 ]);
25212575
25222576 const handleItemSizeChanged = useCallback(() => {
25232577 if (disclosureAnchorKeyRef.current !== null) {
......@@ -2600,6 +2654,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26002654 [suspendEndScrollMaintenanceForDisclosure],
26012655 );
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
26032675 const onPressPreview = useCallback((source: FilePreviewSource) => {
26042676 setExpandedFile((current) => current ?? source);
26052677 }, []);
......@@ -2626,6 +2698,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26262698 return undefined;
26272699 }
26282700 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;
26292707 case "turn-fold":
26302708 return TURN_FOLD_HEIGHT;
26312709 case "work-toggle":
......@@ -2644,7 +2722,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26442722 return undefined;
26452723 }
26462724 },
2647 [expandedWorkRows, workRowSizing.fixedRowHeight],
2725 [expandedReasoningMessageIds, expandedWorkRows, workRowSizing.fixedRowHeight],
26482726 );
26492727
26502728 // Disclosures can mount existing offscreen rows as well as new work rows.
......@@ -2662,14 +2740,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26622740 onEditPendingMessage: props.onEditPendingMessage,
26632741 copiedRowId,
26642742 expandedWorkRows,
2743 expandedReasoningMessageIds,
26652744 workRowSizing,
26662745 workGroupScrollPositions,
26672746 terminalAssistantMessageIds,
26682747 unsettledTurnId,
2748 isWorking: props.activeWorkStartedAt !== null,
26692749 onCopyWorkRow,
26702750 onToggleWorkGroup,
26712751 onToggleWorkRow,
26722752 onToggleTurnFold,
2753 onToggleReasoning,
26732754 onPressPreview,
26742755 onPressVideo,
26752756 markdownLinkHandlers,
......@@ -2696,10 +2777,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26962777 copiedRowId,
26972778 disclosureToggleSettling,
26982779 expandedWorkRows,
2780 expandedReasoningMessageIds,
26992781 workRowSizing,
27002782 workGroupScrollPositions,
27012783 terminalAssistantMessageIds,
27022784 unsettledTurnId,
2785 props.activeWorkStartedAt,
27032786 iconSubtleColor,
27042787 screenColor,
27052788 userBubbleColor,
......@@ -2713,6 +2796,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
27132796 markdownLinkHandlers,
27142797 onPressPreview,
27152798 onPressVideo,
2799 onToggleReasoning,
27162800 onToggleTurnFold,
27172801 onToggleWorkGroup,
27182802 onToggleWorkRow,
apps/mobile/src/features/threads/thread-work-log.tsx+79
......@@ -1150,6 +1150,85 @@ export function ThreadThinkingRow(props: {
11501150 );
11511151}
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
11531232function ToolActivityIconView(props: {
11541233 readonly environmentId: EnvironmentId;
11551234 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 {
15961596 return bMs > aMs ? b : a;
15971597}
15981598
1599function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null {
1599export function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null {
16001600 if (!latestTurn) {
16011601 return null;
16021602 }
......@@ -1637,8 +1637,13 @@ function deriveThreadFeedTurnFolds(
16371637 pendingUserBoundary = entry.message.createdAt;
16381638 continue;
16391639 }
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.
16401644 const turnId =
1641 entry.type === "message" && entry.message.role === "assistant"
1645 entry.type === "message" &&
1646 (entry.message.role === "assistant" || entry.message.role === "reasoning")
16421647 ? entry.message.turnId
16431648 : entry.type === "activity-group"
16441649 ? entry.turnId
......@@ -1665,7 +1670,15 @@ function deriveThreadFeedTurnFolds(
16651670 if (turnId === unsettledTurnId) {
16661671 continue;
16671672 }
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 ) {
16691682 continue;
16701683 }
16711684
......@@ -1685,13 +1698,16 @@ function deriveThreadFeedTurnFolds(
16851698 continue;
16861699 }
16871700 // A lone compaction row stays visible on its own; it only folds away as
1688 // part of a turn that already folds other work.
1689 const hidesNonCompactionWork = entries.some(
1701 // part of a turn that already folds other work. Thinking is the same: a
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(
16901705 (entry) =>
16911706 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"),
16931709 );
1694 if (!hidesNonCompactionWork) {
1710 if (!hidesFoldableWork) {
16951711 continue;
16961712 }
16971713
......@@ -1819,6 +1835,15 @@ export function deriveThreadFeedPresentation(
18191835 !result.some(
18201836 (row) =>
18211837 (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) ||
18221847 // A working spawn card is the live activity: its status line shows
18231848 // what the agents are doing, so a Thinking row under it would lie.
18241849 (row.type === "agent-spawn" &&
apps/server/src/orchestration/ActivityPayloadProjection.ts+19-1
......@@ -645,11 +645,17 @@ function dropSupersededToolUpdatedActivities(
645645
646646export function projectThreadDetailSnapshot(
647647 snapshot: OrchestrationThreadDetailSnapshot,
648 reasoningMessages = true,
648649): OrchestrationThreadDetailSnapshot {
649650 return {
650651 ...snapshot,
651652 thread: {
652653 ...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 ),
653659 activities: dropSupersededToolUpdatedActivities(
654660 dropStaleContextWindowActivities(snapshot.thread.activities),
655661 ).map(projectActivityPayload),
......@@ -657,7 +663,19 @@ export function projectThreadDetailSnapshot(
657663 };
658664}
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 }
661679 if (event.type !== "thread.activity-appended") {
662680 return event;
663681 }
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+4
......@@ -1056,6 +1056,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
10561056 AND threads.archived_at IS NULL
10571057 AND projects.deleted_at IS NULL
10581058 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.
10591063 AND (
10601064 messages.role = 'user'
10611065 OR (
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+327-1
......@@ -1345,7 +1345,7 @@ describe("ProviderRuntimeIngestion", () => {
13451345 const harness = await createHarness();
13461346 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) {
13491349 harness.emit({
13501350 type: "content.delta",
13511351 eventId: asEventId(`evt-ignored-${streamKind}`),
......@@ -1421,6 +1421,332 @@ describe("ProviderRuntimeIngestion", () => {
14211421 expect(message?.streaming).toBe(false);
14221422 });
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
14241750 it("uses assistant item completion detail when no assistant deltas were streamed", async () => {
14251751 const harness = await createHarness();
14261752 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";
5757import { canReplaceThreadTitle } from "../threadTitles.ts";
5858
5959const 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);
6065const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`;
6166
6267// Fallback when the in-memory description cache no longer has the task name
......@@ -270,11 +275,41 @@ function assistantSegmentBaseKeyFromEvent(event: ProviderRuntimeEvent): string {
270275 return String(event.itemId ?? event.turnId ?? event.eventId);
271276}
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:";
274298 return MessageId.make(
275 segmentIndex === 0 ? `assistant:${baseKey}` : `assistant:${baseKey}:segment:${segmentIndex}`,
299 segmentIndex === 0 ? `${prefix}${baseKey}` : `${prefix}${baseKey}:segment:${segmentIndex}`,
276300 );
277301}
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
278313function buildContextWindowActivityPayload(
279314 event: ProviderRuntimeEvent,
280315): ThreadTokenUsageSnapshot | undefined {
......@@ -1005,6 +1040,23 @@ const make = Effect.gen(function* () {
10051040 lookup: () => Effect.succeed(0),
10061041 });
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
10081060 const assistantSegmentStateByTurnKey = yield* Cache.make<string, AssistantSegmentState>({
10091061 capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY,
10101062 timeToLive: TURN_MESSAGE_IDS_BY_TURN_TTL,
......@@ -1104,20 +1156,31 @@ const make = Effect.gen(function* () {
11041156 const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) =>
11051157 Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId));
11061158
1107 const getAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId) =>
1108 Cache.getOption(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId));
1159 const getAssistantSegmentStateForTurn = (
1160 threadId: ThreadId,
1161 turnId: TurnId,
1162 role: MessageStreamRole = "assistant",
1163 ) => Cache.getOption(assistantSegmentStateByTurnKey, segmentStateKey(threadId, turnId, role));
11091164
11101165 const setAssistantSegmentStateForTurn = (
11111166 threadId: ThreadId,
11121167 turnId: TurnId,
11131168 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) =>
1117 Cache.invalidate(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId));
1172 const clearAssistantSegmentStateForTurn = (
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) =>
1120 getAssistantSegmentStateForTurn(threadId, turnId).pipe(
1178 const getActiveAssistantMessageIdForTurn = (
1179 threadId: ThreadId,
1180 turnId: TurnId,
1181 role: MessageStreamRole = "assistant",
1182 ) =>
1183 getAssistantSegmentStateForTurn(threadId, turnId, role).pipe(
11211184 Effect.map((state) =>
11221185 Option.flatMap(state, (entry) =>
11231186 entry.activeMessageId ? Option.some(entry.activeMessageId) : Option.none(),
......@@ -1129,31 +1192,38 @@ const make = Effect.gen(function* () {
11291192 threadId: ThreadId;
11301193 turnId: TurnId;
11311194 baseKey: string;
1132 }) =>
1133 getAssistantSegmentStateForTurn(input.threadId, input.turnId).pipe(
1195 role?: MessageStreamRole;
1196 }) => {
1197 const role = input.role ?? "assistant";
1198 return getAssistantSegmentStateForTurn(input.threadId, input.turnId, role).pipe(
11341199 Effect.flatMap((existingState) =>
11351200 Effect.gen(function* () {
11361201 const nextState = Option.match(existingState, {
11371202 onNone: () => ({
11381203 baseKey: input.baseKey,
11391204 nextSegmentIndex: 1,
1140 activeMessageId: assistantSegmentMessageId(input.baseKey, 0),
1205 activeMessageId: assistantSegmentMessageId(input.baseKey, 0, role),
11411206 }),
11421207 onSome: (state) => {
1143 const segmentIndex = state.baseKey === input.baseKey ? state.nextSegmentIndex : 0;
1144 const messageId = assistantSegmentMessageId(input.baseKey, segmentIndex);
1208 // Reasoning never resets the index on a new base key: one item can
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);
11451214 return {
11461215 baseKey: input.baseKey,
1147 nextSegmentIndex: state.baseKey === input.baseKey ? state.nextSegmentIndex + 1 : 1,
1216 nextSegmentIndex: reuseIndex ? state.nextSegmentIndex + 1 : 1,
11481217 activeMessageId: messageId,
11491218 } satisfies AssistantSegmentState;
11501219 },
11511220 });
1152 yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, nextState);
1221 yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, nextState, role);
11531222 return nextState.activeMessageId!;
11541223 }),
11551224 ),
11561225 );
1226 };
11571227
11581228 const getOrCreateAssistantMessageId = (input: {
11591229 threadId: ThreadId;
......@@ -1180,6 +1250,52 @@ const make = Effect.gen(function* () {
11801250 });
11811251 });
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
11831299 const resolveResponseStreamingMode = (projectId: ProjectId) =>
11841300 Effect.map(
11851301 serverSettingsService.getSettings,
......@@ -1270,7 +1386,15 @@ const make = Effect.gen(function* () {
12701386 Cache.invalidate(bufferedProposedPlanById, planId);
12711387
12721388 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
12751399 const flushBufferedAssistantMessage = (input: {
12761400 event: ProviderRuntimeEvent;
......@@ -1286,14 +1410,17 @@ const make = Effect.gen(function* () {
12861410 return false;
12871411 }
12881412
1413 const isReasoning = messageStreamRoleOf(input.messageId) === "reasoning";
12891414 yield* orchestrationEngine.dispatch({
1290 type: "thread.message.assistant.delta",
1415 type: isReasoning ? "thread.message.reasoning.delta" : "thread.message.assistant.delta",
12911416 commandId: yield* providerCommandId(input.event, input.commandTag),
12921417 threadId: input.threadId,
12931418 messageId: input.messageId,
12941419 delta: bufferedText,
12951420 ...(input.turnId ? { turnId: input.turnId } : {}),
1296 createdAt: input.createdAt,
1421 createdAt: isReasoning
1422 ? yield* reasoningStartedAt(input.messageId, input.createdAt)
1423 : input.createdAt,
12971424 });
12981425 return true;
12991426 });
......@@ -1352,21 +1479,27 @@ const make = Effect.gen(function* () {
13521479 : "";
13531480 const hasRenderableText = hasRenderableAssistantText(text);
13541481
1482 const isReasoning = messageStreamRoleOf(input.messageId) === "reasoning";
1483
13551484 if (hasRenderableText) {
13561485 yield* orchestrationEngine.dispatch({
1357 type: "thread.message.assistant.delta",
1486 type: isReasoning ? "thread.message.reasoning.delta" : "thread.message.assistant.delta",
13581487 commandId: yield* providerCommandId(input.event, input.finalDeltaCommandTag),
13591488 threadId: input.threadId,
13601489 messageId: input.messageId,
13611490 delta: text,
13621491 ...(input.turnId ? { turnId: input.turnId } : {}),
1363 createdAt: input.createdAt,
1492 createdAt: isReasoning
1493 ? yield* reasoningStartedAt(input.messageId, input.createdAt)
1494 : input.createdAt,
13641495 });
13651496 }
13661497
13671498 if (input.hasProjectedMessage || hasRenderableText) {
13681499 yield* orchestrationEngine.dispatch({
1369 type: "thread.message.assistant.complete",
1500 type: isReasoning
1501 ? "thread.message.reasoning.complete"
1502 : "thread.message.assistant.complete",
13701503 commandId: yield* providerCommandId(input.event, input.commandTag),
13711504 threadId: input.threadId,
13721505 messageId: input.messageId,
......@@ -1377,7 +1510,7 @@ const make = Effect.gen(function* () {
13771510 yield* clearAssistantMessageState(input.messageId);
13781511 });
13791512
1380 const finalizeActiveAssistantSegmentForTurn = (input: {
1513 const finalizeActiveSegmentForTurn = (input: {
13811514 event: ProviderRuntimeEvent;
13821515 threadId: ThreadId;
13831516 turnId: TurnId;
......@@ -1386,16 +1519,29 @@ const make = Effect.gen(function* () {
13861519 finalDeltaCommandTag: string;
13871520 hasProjectedMessage: boolean;
13881521 flushedMessageIds?: ReadonlySet<MessageId>;
1522 role?: MessageStreamRole;
1523 fallbackText?: string;
13891524 }) =>
13901525 Effect.gen(function* () {
1526 const role = input.role ?? "assistant";
13911527 const activeMessageId = yield* getActiveAssistantMessageIdForTurn(
13921528 input.threadId,
13931529 input.turnId,
1530 role,
13941531 );
13951532 if (Option.isNone(activeMessageId)) {
13961533 return;
13971534 }
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
13991545 yield* finalizeAssistantMessage({
14001546 event: input.event,
14011547 threadId: input.threadId,
......@@ -1404,18 +1550,21 @@ const make = Effect.gen(function* () {
14041550 createdAt: input.createdAt,
14051551 commandTag: input.commandTag,
14061552 finalDeltaCommandTag: input.finalDeltaCommandTag,
1407 hasProjectedMessage:
1408 input.hasProjectedMessage ||
1409 (input.flushedMessageIds?.has(activeMessageId.value) ?? false),
1553 hasProjectedMessage: alreadyProjected,
1554 ...(input.fallbackText !== undefined ? { fallbackText: input.fallbackText } : {}),
14101555 });
14111556 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);
14141561 if (Option.isSome(state)) {
1415 yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, {
1416 ...state.value,
1417 activeMessageId: null,
1418 });
1562 yield* setAssistantSegmentStateForTurn(
1563 input.threadId,
1564 input.turnId,
1565 { ...state.value, activeMessageId: null },
1566 role,
1567 );
14191568 }
14201569 });
14211570
......@@ -1596,7 +1745,12 @@ const make = Effect.gen(function* () {
15961745
15971746 const processRuntimeEvent = (event: ProviderRuntimeEvent) =>
15981747 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 ) {
16001754 return;
16011755 }
16021756
......@@ -1772,11 +1926,98 @@ const make = Effect.gen(function* () {
17721926 event.type === "content.delta" && event.payload.streamKind === "assistant_text"
17731927 ? event.payload.delta
17741928 : 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;
17751940 const proposedPlanDelta =
17761941 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
17782005 if (assistantDelta && assistantDelta.length > 0) {
17792006 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 }
17802021 const assistantMessageId = yield* getOrCreateAssistantMessageId({
17812022 threadId: thread.id,
17822023 event,
......@@ -1845,7 +2086,18 @@ const make = Effect.gen(function* () {
18452086 : "assistant-delta-flush-on-user-input-requested",
18462087 })
18472088 : 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({
18492101 event,
18502102 threadId: thread.id,
18512103 turnId: pauseForUserTurnId,
......@@ -1868,6 +2120,99 @@ const make = Effect.gen(function* () {
18682120 yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now);
18692121 }
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
18712216 const assistantCompletion =
18722217 event.type === "item.completed" && event.payload.itemType === "assistant_message"
18732218 ? {
......@@ -1888,6 +2233,18 @@ const make = Effect.gen(function* () {
18882233
18892234 if (assistantCompletion) {
18902235 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 }
18912248 const activeAssistantMessageId = turnId
18922249 ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId)
18932250 : Option.none<MessageId>();
......@@ -2020,6 +2377,7 @@ const make = Effect.gen(function* () {
20202377 ).pipe(Effect.asVoid);
20212378 yield* clearAssistantMessageIdsForTurn(thread.id, turnId);
20222379 yield* clearAssistantSegmentStateForTurn(thread.id, turnId);
2380 yield* clearAssistantSegmentStateForTurn(thread.id, turnId, "reasoning");
20232381
20242382 yield* finalizeBufferedProposedPlan({
20252383 event,
apps/server/src/orchestration/decider.ts+6-4
......@@ -1912,7 +1912,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
19121912 return [unsettledEvent, sessionSetEvent];
19131913 }
19141914
1915 case "thread.message.assistant.delta": {
1915 case "thread.message.assistant.delta":
1916 case "thread.message.reasoning.delta": {
19161917 if (isImportedAgentSessionMessageId(command.messageId)) {
19171918 return yield* new OrchestrationCommandInvariantError({
19181919 commandType: command.type,
......@@ -1935,7 +1936,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
19351936 payload: {
19361937 threadId: command.threadId,
19371938 messageId: command.messageId,
1938 role: "assistant",
1939 role: command.type === "thread.message.reasoning.delta" ? "reasoning" : "assistant",
19391940 text: command.delta,
19401941 turnId: command.turnId ?? null,
19411942 streaming: true,
......@@ -1945,7 +1946,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
19451946 };
19461947 }
19471948
1948 case "thread.message.assistant.complete": {
1949 case "thread.message.assistant.complete":
1950 case "thread.message.reasoning.complete": {
19491951 if (isImportedAgentSessionMessageId(command.messageId)) {
19501952 return yield* new OrchestrationCommandInvariantError({
19511953 commandType: command.type,
......@@ -1968,7 +1970,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
19681970 payload: {
19691971 threadId: command.threadId,
19701972 messageId: command.messageId,
1971 role: "assistant",
1973 role: command.type === "thread.message.reasoning.complete" ? "reasoning" : "assistant",
19721974 text: "",
19731975 turnId: command.turnId ?? null,
19741976 streaming: false,
apps/server/src/orchestration/http.ts+4-1
......@@ -87,7 +87,10 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
8787 if (Option.isNone(snapshot)) {
8888 return yield* failEnvironmentNotFound("thread_not_found");
8989 }
90 return projectThreadDetailSnapshot(snapshot.value);
90 return projectThreadDetailSnapshot(
91 snapshot.value,
92 args.payload.reasoningMessages === "true",
93 );
9194 }),
9295 )
9396 .handle(
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+162-7
......@@ -496,7 +496,10 @@ describe("ClaudeAdapterLive", () => {
496496 assert.equal(createInput?.options.permissionMode, "bypassPermissions");
497497 assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);
498498 // 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 });
500503 }).pipe(
501504 Effect.provideService(Random.Random, makeDeterministicRandomService()),
502505 Effect.provide(harness.layer),
......@@ -553,7 +556,10 @@ describe("ClaudeAdapterLive", () => {
553556 });
554557
555558 const options = harness.getLastCreateQueryInput()?.options;
556 assert.deepEqual(options?.settings, { autoCompactWindow: 300000 });
559 assert.deepEqual(options?.settings, {
560 showThinkingSummaries: true,
561 autoCompactWindow: 300000,
562 });
557563 assert.deepEqual(options?.supportedDialogKinds, ["resume_return"]);
558564 }).pipe(
559565 Effect.provideService(Random.Random, makeDeterministicRandomService()),
......@@ -628,6 +634,61 @@ describe("ClaudeAdapterLive", () => {
628634 assert.deepEqual(createInput?.options.settings, {
629635 alwaysThinkingEnabled: false,
630636 });
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");
631692 }).pipe(
632693 Effect.provideService(Random.Random, makeDeterministicRandomService()),
633694 Effect.provide(harness.layer),
......@@ -650,7 +711,13 @@ describe("ClaudeAdapterLive", () => {
650711 });
651712
652713 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 });
654721 }).pipe(
655722 Effect.provideService(Random.Random, makeDeterministicRandomService()),
656723 Effect.provide(harness.layer),
......@@ -674,6 +741,7 @@ describe("ClaudeAdapterLive", () => {
674741
675742 const createInput = harness.getLastCreateQueryInput();
676743 assert.deepEqual(createInput?.options.settings, {
744 showThinkingSummaries: true,
677745 fastMode: true,
678746 });
679747 }).pipe(
......@@ -698,7 +766,9 @@ describe("ClaudeAdapterLive", () => {
698766 });
699767
700768 const createInput = harness.getLastCreateQueryInput();
701 assert.equal(createInput?.options.settings, undefined);
769 assert.deepEqual(createInput?.options.settings, {
770 showThinkingSummaries: true,
771 });
702772 }).pipe(
703773 Effect.provideService(Random.Random, makeDeterministicRandomService()),
704774 Effect.provide(harness.layer),
......@@ -781,7 +851,7 @@ describe("ClaudeAdapterLive", () => {
781851 const { options: customOptions, prompt: customPrompt } = yield* runCustomFlow;
782852 assert.equal(customOptions.model, SYNTHETIC_CLAUDE_COLLIDING_ALIAS);
783853 assert.equal(customOptions.effort, undefined);
784 assert.equal(customOptions.settings, undefined);
854 assert.deepEqual(customOptions.settings, { showThinkingSummaries: true });
785855 assert.deepEqual(customHarness.query.setModelCalls, [
786856 `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`,
787857 SYNTHETIC_CLAUDE_COLLIDING_ALIAS,
......@@ -791,7 +861,10 @@ describe("ClaudeAdapterLive", () => {
791861 const builtInOptions = yield* start(builtInHarness, SYNTHETIC_CLAUDE_CAPABLE_MODEL);
792862 assert.equal(builtInOptions.model, `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`);
793863 assert.equal(builtInOptions.effort, "max");
794 assert.deepEqual(builtInOptions.settings, { fastMode: true });
864 assert.deepEqual(builtInOptions.settings, {
865 showThinkingSummaries: true,
866 fastMode: true,
867 });
795868 });
796869 },
797870 );
......@@ -1673,7 +1746,8 @@ describe("ClaudeAdapterLive", () => {
16731746 );
16741747
16751748 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",
16771751 );
16781752 assert.equal(reasoningDelta?.type, "content.delta");
16791753 if (reasoningDelta?.type === "content.delta") {
......@@ -1727,6 +1801,87 @@ describe("ClaudeAdapterLive", () => {
17271801 );
17281802 });
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
17301885 it.effect("classifies only streamed Read image inputs as image views", () => {
17311886 const harness = makeHarness();
17321887 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+136-22
......@@ -225,7 +225,10 @@ const remapClaudeForkTurnBoundaries = (
225225};
226226
227227const 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>;
229232type ClaudeToolResultStreamKind = Extract<
230233 RuntimeContentStreamKind,
231234 "command_output" | "file_change_output"
......@@ -275,6 +278,8 @@ interface ClaudeTurnState {
275278 authenticationFailureMessage: string | undefined;
276279 rejectedRateLimitTypes: Set<string>;
277280 latestAssistantRateLimited: boolean;
281 emittedThinkingText: boolean;
282 readonly thinkingSnapshotIds: Set<string>;
278283}
279284
280285interface AssistantTextBlockState {
......@@ -1718,7 +1723,18 @@ function resultOutcome(
17181723}
17191724
17201725function 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";
17221738}
17231739
17241740function nativeProviderRefs(
......@@ -1735,7 +1751,11 @@ function nativeProviderRefs(
17351751 return {};
17361752}
17371753
1738function extractAssistantTextBlocks(message: SDKMessage): Array<string> {
1754function extractAssistantContentBlocks(
1755 message: SDKMessage,
1756 blockType: "text" | "thinking",
1757 field: "text" | "thinking",
1758): Array<string> {
17391759 if (message.type !== "assistant") {
17401760 return [];
17411761 }
......@@ -1750,19 +1770,24 @@ function extractAssistantTextBlocks(message: SDKMessage): Array<string> {
17501770 if (!block || typeof block !== "object") {
17511771 continue;
17521772 }
1753 const candidate = block as { type?: unknown; text?: unknown };
1754 if (
1755 candidate.type === "text" &&
1756 typeof candidate.text === "string" &&
1757 candidate.text.length > 0
1758 ) {
1759 fragments.push(candidate.text);
1773 const candidate = block as { type?: unknown; text?: unknown; thinking?: unknown };
1774 const value = field === "thinking" ? candidate.thinking : candidate.text;
1775 if (candidate.type === blockType && typeof value === "string" && value.length > 0) {
1776 fragments.push(value);
17601777 }
17611778 }
17621779
17631780 return fragments;
17641781}
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
17661791function extractContentBlockText(block: unknown): string {
17671792 if (!block || typeof block !== "object") {
17681793 return "";
......@@ -2358,6 +2383,70 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
23582383 }
23592384 });
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
23612450 const ensureThreadId = Effect.fn("ensureThreadId")(function* (
23622451 context: ClaudeSessionContext,
23632452 message: SDKMessage,
......@@ -2799,6 +2888,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
27992888 }
28002889 }
28012890
2891 if (event.type === "message_start" && context.turnState && !streamParentToolUseId) {
2892 context.turnState.emittedThinkingText = false;
2893 }
2894
28022895 if (event.type === "message_delta") {
28032896 if (message.parent_tool_use_id !== null && message.parent_tool_use_id !== undefined) {
28042897 return;
......@@ -2831,18 +2924,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
28312924 return;
28322925 }
28332926 const streamKind = streamKindFromDeltaType(event.delta.type);
2834 const assistantBlockEntry =
2835 event.delta.type === "text_delta"
2836 ? yield* ensureAssistantTextBlock(context, event.index)
2837 : context.turnState.assistantTextBlocks.get(event.index)
2838 ? {
2839 blockIndex: event.index,
2840 block: context.turnState.assistantTextBlocks.get(
2841 event.index,
2842 ) as AssistantTextBlockState,
2843 }
2844 : undefined;
2845 if (assistantBlockEntry?.block && event.delta.type === "text_delta") {
2927 if (streamKind === "reasoning_summary_text") {
2928 yield* emitReasoningSummaryDelta(context, {
2929 delta: deltaText,
2930 contentIndex: event.index,
2931 rawMethod: "claude/stream_event/content_block_delta",
2932 rawPayload: message,
2933 });
2934 return;
2935 }
2936 const assistantBlockEntry = yield* ensureAssistantTextBlock(context, event.index);
2937 if (assistantBlockEntry?.block) {
28462938 assistantBlockEntry.block.emittedTextDelta = true;
28472939 }
28482940 const stamp = yield* makeEventStamp();
......@@ -3297,6 +3389,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
32973389 authenticationFailureMessage: undefined,
32983390 rejectedRateLimitTypes: new Set(),
32993391 latestAssistantRateLimited: false,
3392 emittedThinkingText: false,
3393 thinkingSnapshotIds: new Set(),
33003394 };
33013395 context.session = {
33023396 ...context.session,
......@@ -3378,6 +3472,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
33783472 context.turnState.latestAssistantUsage = message.message.usage;
33793473 context.turnState.compactedSinceLatestAssistantUsage = false;
33803474 }
3475 yield* backfillThinkingFromSnapshot(context, message);
33813476 yield* backfillAssistantTextBlocksFromSnapshot(context, message);
33823477 }
33833478
......@@ -4770,6 +4865,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
47704865 const thinking = thinkingSupported
47714866 ? getModelSelectionBooleanOptionValue(modelSelection, "thinking")
47724867 : undefined;
4868 const thinkingDisplayArg = extraArgs["thinking-display"];
4869 const requestThinkingSummaries = shouldRequestClaudeThinkingSummaries({
4870 thinking,
4871 thinkingDisplay: typeof thinkingDisplayArg === "string" ? thinkingDisplayArg : undefined,
4872 });
47734873 const ultracode = isClaudeCatalogUltracodeEffort(effort);
47744874 const effectiveEffort = getEffectiveClaudeAgentEffort(
47754875 modelCatalog,
......@@ -4791,12 +4891,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
47914891 : runtimeModeToPermission[input.runtimeMode]);
47924892 const settings = {
47934893 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
4894 ...(requestThinkingSummaries ? { showThinkingSummaries: true } : {}),
47944895 ...(fastMode ? { fastMode: true } : {}),
47954896 ...(ultracode ? { ultracode: true } : {}),
47964897 ...(claudeSettings.autoCompactWindow
47974898 ? { autoCompactWindow: Number(claudeSettings.autoCompactWindow) }
47984899 : {}),
47994900 };
4901 if (requestThinkingSummaries && extraArgs["thinking-display"] === undefined) {
4902 extraArgs["thinking-display"] = "summarized";
4903 }
48004904 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
48014905 // The attachments dir grant lets the agent Read/copy pasted images at
48024906 // the paths ProviderService injects into the turn text, without an
......@@ -4824,6 +4928,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
48244928 effort: effectiveEffort as unknown as NonNullable<ClaudeQueryOptions["effort"]>,
48254929 }
48264930 : {}),
4931 ...(extraArgs["thinking-display"] === "summarized"
4932 ? {
4933 thinking: {
4934 type: "adaptive" as const,
4935 display: "summarized" as const,
4936 },
4937 }
4938 : {}),
48274939 ...(permissionMode ? { permissionMode } : {}),
48284940 ...(permissionMode === "bypassPermissions"
48294941 ? { allowDangerouslySkipPermissions: true }
......@@ -5104,6 +5216,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
51045216 authenticationFailureMessage: undefined,
51055217 rejectedRateLimitTypes: new Set(),
51065218 latestAssistantRateLimited: false,
5219 emittedThinkingText: false,
5220 thinkingSnapshotIds: new Set(),
51075221 };
51085222
51095223 const updatedAt = yield* nowIso;
apps/server/src/provider/Layers/CursorAdapter.ts+21
......@@ -877,6 +877,27 @@ export function makeCursorAdapter(
877877 }),
878878 );
879879 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;
880901 case "ContentDelta":
881902 ctx.assistantReply.push(event.text);
882903 yield* logNative(
apps/server/src/provider/Layers/GrokAdapter.ts+19-3
......@@ -467,13 +467,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
467467 | "AssistantItemCompleted"
468468 | "PlanUpdated"
469469 | "ToolCallUpdated"
470 | "ContentDelta";
470 | "ContentDelta"
471 | "ThoughtDelta";
471472 }
472473 >,
473474 ) {
474475 if (
475476 ctx.livenessTurnId !== turnId ||
476 (event._tag === "ContentDelta" && event.text.length === 0)
477 ((event._tag === "ContentDelta" || event._tag === "ThoughtDelta") &&
478 event.text.length === 0)
477479 ) {
478480 return;
479481 }
......@@ -1367,7 +1369,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
13671369 event._tag === "AssistantItemCompleted" ||
13681370 event._tag === "PlanUpdated" ||
13691371 event._tag === "ToolCallUpdated" ||
1370 event._tag === "ContentDelta"
1372 event._tag === "ContentDelta" ||
1373 event._tag === "ThoughtDelta"
13711374 ) {
13721375 yield* recordTurnActivity(ctx, notificationTurnId, event);
13731376 }
......@@ -1443,6 +1446,19 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
14431446 }
14441447 return;
14451448 }
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;
14461462 case "ContentDelta":
14471463 yield* offerRuntimeEvent(
14481464 makeAcpContentDeltaEvent({
apps/server/src/server.test.ts+118
......@@ -8525,6 +8525,124 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
85258525 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
85268526 );
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
85288646 it.effect("marks a socket thread snapshot as synchronized when requested", () =>
85298647 Effect.gen(function* () {
85308648 const thread = makeDefaultOrchestrationReadModel().threads[0]!;
apps/server/src/textGeneration/ThreadTitleContext.test.ts+10
......@@ -45,4 +45,14 @@ describe("thread title context", () => {
4545 }
4646 expect(formatThreadTitleContext([])).toEqual({ message: "", attachments: [] });
4747 });
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 });
4858});
apps/server/src/textGeneration/ThreadTitleContext.ts+8-2
......@@ -2,7 +2,7 @@ import type { ChatAttachment } from "@t3tools/contracts";
22import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
33
44export type ThreadTitleMessage = {
5 readonly role: "user" | "assistant" | "system";
5 readonly role: "user" | "assistant" | "system" | "reasoning";
66 readonly text: string;
77 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
88};
......@@ -25,7 +25,13 @@ export function limitTitleMessage(text: string, budget: number): string {
2525/** Reserve space for user intent before adding assistant findings, in conversation order. */
2626export function formatThreadTitleContext(messages: ReadonlyArray<ThreadTitleMessage>) {
2727 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 )
2935 return [];
3036 return [{ index, message, prefix: `${message.role.toUpperCase()}:\n` }];
3137 });
apps/server/src/ws.ts+7-3
......@@ -1807,6 +1807,7 @@ const makeWsRpcLayer = (
18071807 }),
18081808 threadResumeCompletionMarker: true,
18091809 threadSnapshotPagination: true,
1810 reasoningMessages: true,
18101811 };
18111812 });
18121813
......@@ -2137,7 +2138,7 @@ const makeWsRpcLayer = (
21372138 Stream.filter(isThisThreadDetailEvent),
21382139 Stream.map((event) => ({
21392140 kind: "event" as const,
2140 event,
2141 event: projectActivityEvent(event, input.reasoningMessages === true),
21412142 })),
21422143 );
21432144
......@@ -2207,7 +2208,7 @@ const makeWsRpcLayer = (
22072208 Stream.filter(isThisThreadDetailEvent),
22082209 Stream.map((event) => ({
22092210 kind: "event" as const,
2210 event: projectActivityEvent(event),
2211 event: projectActivityEvent(event, input.reasoningMessages === true),
22112212 })),
22122213 Stream.mapError(
22132214 (cause) =>
......@@ -2278,7 +2279,10 @@ const makeWsRpcLayer = (
22782279 return Stream.concat(
22792280 Stream.make({
22802281 kind: "snapshot" as const,
2281 snapshot: projectThreadDetailSnapshot(snapshot.value),
2282 snapshot: projectThreadDetailSnapshot(
2283 snapshot.value,
2284 input.reasoningMessages === true,
2285 ),
22822286 }),
22832287 afterSnapshot,
22842288 );
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+112
......@@ -1907,6 +1907,118 @@ describe("deriveMessagesTimelineRows", () => {
19071907 expect(rows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "assistant-final-entry"]);
19081908 });
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
19102022 it("derives a sane duration for a steer-superseded turn with one instant commentary message", () => {
19112023 // A steer ends the previous turn early: its only message completes the
19122024 // 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 {
299299
300300export interface TimelineDurationMessage {
301301 id: string;
302 role: "user" | "assistant" | "system";
302 role: ChatMessage["role"];
303303 createdAt: string;
304304 updatedAt: string;
305305 streaming: boolean;
......@@ -524,7 +524,7 @@ interface TurnFold {
524524 * user sends a message, the previous turn is still the "active" one until the
525525 * server creates the new turn, and folding must not flicker through that window.
526526 */
527function deriveUnsettledTurnId(
527export function deriveUnsettledTurnId(
528528 latestTurn: TimelineLatestTurn | null,
529529 runningTurnId: TurnId | null,
530530): TurnId | null {
......@@ -546,7 +546,9 @@ function lastUserMessageIndex(timelineEntries: ReadonlyArray<TimelineEntry>): nu
546546
547547function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
548548 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;
550552 }
551553 if (entry.kind === "proposed-plan") {
552554 return entry.proposedPlan.turnId;
......@@ -624,8 +626,13 @@ function deriveTurnFolds(input: {
624626 pendingUserBoundary = entry.message.createdAt;
625627 continue;
626628 }
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.
627633 const turnId =
628 entry.kind === "message" && entry.message.role === "assistant"
634 entry.kind === "message" &&
635 (entry.message.role === "assistant" || entry.message.role === "reasoning")
629636 ? (entry.message.turnId ?? null)
630637 : entry.kind === "work"
631638 ? (entry.entry.turnId ?? null)
......@@ -652,7 +659,10 @@ function deriveTurnFolds(input: {
652659 if (input.terminalAssistantMessageIds.has(entry.message.id)) {
653660 group.terminalEntry = entry;
654661 }
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") {
656666 group.hasStreamingMessage = true;
657667 }
658668 }
......@@ -670,6 +680,15 @@ function deriveTurnFolds(input: {
670680 const terminalEntryIndex = group.terminalEntry
671681 ? group.entries.findIndex((entry) => entry.id === group.terminalEntry?.id)
672682 : 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;
673692 for (const [index, entry] of group.entries.entries()) {
674693 if (entry.id === group.terminalEntry?.id) {
675694 continue;
......@@ -677,10 +696,18 @@ function deriveTurnFolds(input: {
677696 const isCompaction =
678697 entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction";
679698 const isSingleTrailingActivity =
680 group.entries.length === terminalEntryIndex + 2 &&
699 trailingEntryCount === 1 &&
681700 entry.kind === "work" &&
682701 !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 ) {
684711 continue;
685712 }
686713 // User input and subagent batches stay visible after their turn settles.
......@@ -696,13 +723,16 @@ function deriveTurnFolds(input: {
696723 continue;
697724 }
698725 // A lone compaction row stays visible on its own; it only folds away as
699 // part of a turn that already folds other work.
700 const hidesNonCompactionWork = group.entries.some(
726 // part of a turn that already folds other work. Thinking is the same: a
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(
701730 (entry) =>
702731 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"),
704734 );
705 if (!hidesNonCompactionWork) {
735 if (!hidesFoldableWork) {
706736 continue;
707737 }
708738
......@@ -778,7 +808,15 @@ function attachTrailingToolGroupsToAssistant(
778808 let hasTrailingToolGroup = false;
779809 for (let index = messageIndex + 1; index < rows.length; index += 1) {
780810 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") {
782820 break;
783821 }
784822 if (candidate.kind === "work-toggle" && candidate.turnId === turnId) {
......@@ -1326,6 +1364,16 @@ export function deriveMessagesTimelineRows(input: {
13261364 );
13271365 }
13281366 }
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
13301378 // A running setup owns the working slot above its card and shows no
13311379 // activity row of its own; every other state gets the usual tail.
......@@ -1333,7 +1381,12 @@ export function deriveMessagesTimelineRows(input: {
13331381 if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) {
13341382 appendWorkingRow();
13351383 }
1336 if (input.isWorking && !setupRunning && (!hasActivityRow || latestToolFailed)) {
1384 if (
1385 input.isWorking &&
1386 !setupRunning &&
1387 !hasStreamingReasoningRow &&
1388 (!hasActivityRow || latestToolFailed)
1389 ) {
13371390 nextRows.push({
13381391 kind: "thinking",
13391392 id: LIVE_ACTIVITY_ROW_ID,
apps/web/src/components/chat/MessagesTimeline.tsx+121
......@@ -167,6 +167,7 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis
167167import {
168168 computeStableMessagesTimelineRows,
169169 deriveMessagesTimelineRowsWithState,
170 deriveUnsettledTurnId,
170171 type MessagesTimelineRowsProjection,
171172 liveWorkEntryLabel,
172173 resolveAssistantMessageCopyState,
......@@ -280,6 +281,8 @@ interface TimelineRowSharedState {
280281 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
281282 onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void;
282283 onToggleSpawnRow: (entryId: string, expanded: boolean) => void;
284 onToggleReasoning: (messageId: string, expanded: boolean) => void;
285 expandedReasoningMessageIds: ReadonlySet<string>;
283286 workGroupViewState: WorkGroupViewState;
284287 agentPanelModel: AgentPanelModel;
285288 expandedSpawnEntryIds: ReadonlySet<string>;
......@@ -298,6 +301,7 @@ interface TimelineRowActivityState {
298301 isCompacting: boolean;
299302 isRevertingCheckpoint: boolean;
300303 latestTurnId: TurnId | null;
304 unsettledTurnId: TurnId | null;
301305 /**
302306 * A worktree setup whose script is still running after the agent took
303307 * over. The working header shows it as a chip with a popover; the stage
......@@ -516,6 +520,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
516520 const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState<ReadonlySet<string>>(
517521 new Set(),
518522 );
523 const [expandedReasoningMessageIds, setExpandedReasoningMessageIds] = useState<
524 ReadonlySet<string>
525 >(new Set());
519526 const listIdentityKey = displayThreadKey ?? routeThreadKey;
520527 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
521528 const listIdentityRef = useRef(listIdentityKey);
......@@ -526,6 +533,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
526533 let paintedExpandedTurnIds = expandedTurnIds;
527534 let paintedExpandedWorkGroupIds = expandedWorkGroupIds;
528535 let paintedExpandedSpawnEntryIds = expandedSpawnEntryIds;
536 let paintedExpandedReasoningMessageIds = expandedReasoningMessageIds;
529537 if (listIdentityRef.current !== listIdentityKey) {
530538 listIdentityRef.current = listIdentityKey;
531539 previousLatestTurnRef.current = latestTurn;
......@@ -533,9 +541,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
533541 paintedExpandedTurnIds = new Set();
534542 paintedExpandedWorkGroupIds = new Set();
535543 paintedExpandedSpawnEntryIds = new Set();
544 paintedExpandedReasoningMessageIds = new Set();
536545 setExpandedTurnIds(paintedExpandedTurnIds);
537546 setExpandedWorkGroupIds(paintedExpandedWorkGroupIds);
538547 setExpandedSpawnEntryIds(paintedExpandedSpawnEntryIds);
548 setExpandedReasoningMessageIds(paintedExpandedReasoningMessageIds);
539549 }
540550 const onToggleSpawnRow = useCallback((entryId: string, expanded: boolean) => {
541551 setExpandedSpawnEntryIds((current) => {
......@@ -660,6 +670,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({
660670 },
661671 [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure],
662672 );
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
664689 // An in-session interrupt leaves its turn expanded so the user keeps their
665690 // place; the next turn (or a reload, since this is local state) folds it.
......@@ -944,6 +969,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
944969 onToggleWorkGroup,
945970 onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure,
946971 onToggleSpawnRow,
972 onToggleReasoning,
973 expandedReasoningMessageIds: paintedExpandedReasoningMessageIds,
947974 workGroupViewState,
948975 agentPanelModel: agentPanelModel ?? EMPTY_AGENT_PANEL_MODEL,
949976 expandedSpawnEntryIds: paintedExpandedSpawnEntryIds,
......@@ -977,6 +1004,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
9771004 onToggleWorkGroup,
9781005 suspendEndScrollMaintenanceForDisclosure,
9791006 onToggleSpawnRow,
1007 onToggleReasoning,
1008 paintedExpandedReasoningMessageIds,
9801009 workGroupViewState,
9811010 agentPanelModel,
9821011 paintedExpandedSpawnEntryIds,
......@@ -1003,6 +1032,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
10031032 isCompacting,
10041033 isRevertingCheckpoint,
10051034 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),
10061038 backgroundWorktreeSetup,
10071039 }),
10081040 [
......@@ -1011,7 +1043,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({
10111043 isRevertingCheckpoint,
10121044 isWorking,
10131045 isPreparingWorktree,
1046 // Deliberately the fields `deriveUnsettledTurnId` reads, not the object:
1047 // its identity changes on every thread-shell patch.
10141048 latestTurn?.turnId,
1049 latestTurn?.state,
1050 latestTurn?.completedAt,
1051 runningTurnId,
10151052 ],
10161053 );
10171054
......@@ -1453,6 +1490,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
14531490 : (row.kind === "message" &&
14541491 row.message.role === "assistant" &&
14551492 !row.showAssistantMeta) ||
1493 (row.kind === "message" && row.message.role === "reasoning") ||
14561494 row.kind === "work" ||
14571495 row.kind === "work-live" ||
14581496 row.kind === "work-toggle" ||
......@@ -1488,6 +1526,9 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
14881526 {row.kind === "message" && row.message.role === "assistant" ? (
14891527 <AssistantTimelineRow row={row} />
14901528 ) : null}
1529 {row.kind === "message" && row.message.role === "reasoning" ? (
1530 <ReasoningTimelineRow row={row} />
1531 ) : null}
14911532 {row.kind === "assistant-meta" ? <AssistantMetaTimelineRow row={row} /> : null}
14921533 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
14931534 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
......@@ -2363,6 +2404,86 @@ function ThinkingTimelineRow() {
23632404 );
23642405}
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
23662487function CompactingLabel() {
23672488 return (
23682489 <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> = {
187187const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record<ResponseStreamingMode, string> = {
188188 turn: "Text appears once the agent finishes its turn.",
189189 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.",
191192};
192193
193194const TIMESTAMP_FORMAT_LABELS = {
apps/web/src/connection/storage.ts+4-2
......@@ -65,8 +65,10 @@ const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);
6565// exists for rollback safety: a pre-pagination client would decode a windowed
6666// v2 record, silently drop the unknown `page` field, and treat the partial
6767// 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.
6870const StoredThreadSnapshot = Schema.Struct({
69 schemaVersion: Schema.Literal(3),
71 schemaVersion: Schema.Literal(4),
7072 environmentId: EnvironmentId,
7173 threadId: ThreadId,
7274 snapshot: OrchestrationThreadDetailSnapshot,
......@@ -679,7 +681,7 @@ export const connectionStorageLayer = Layer.effectContext(
679681 saveThread: (environmentId, snapshot) =>
680682 Effect.gen(function* () {
681683 const encoded = yield* encodeStoredThreadSnapshot({
682 schemaVersion: 3,
684 schemaVersion: 4,
683685 environmentId,
684686 threadId: snapshot.thread.id,
685687 snapshot,
apps/web/src/session-logic.ts+5-3
......@@ -1613,11 +1613,13 @@ export function createMessageAttachmentPreviewProjector() {
16131613 };
16141614}
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. */
16171619export function isStreamingMessageTextUpdate(previous: ChatMessage, next: ChatMessage): boolean {
16181620 if (
1619 previous.role !== "assistant" ||
1620 next.role !== "assistant" ||
1621 !streamsText(previous.role) ||
1622 previous.role !== next.role ||
16211623 !previous.streaming ||
16221624 !next.streaming
16231625 ) {
packages/client-runtime/src/state/environmentHttpAuth.test.ts+2
......@@ -210,6 +210,7 @@ const LOADERS: ReadonlyArray<{
210210 ...input,
211211 threadId: THREAD.thread.id,
212212 window: { turnLimit: 20, beforeCursor: "older-page" },
213 reasoningMessages: true,
213214 }),
214215 },
215216];
......@@ -252,6 +253,7 @@ describe("authenticated environment HTTP requests", () => {
252253 if (loader.name === "older thread history") {
253254 expect(url.searchParams.get("turnLimit")).toBe("20");
254255 expect(url.searchParams.get("beforeCursor")).toBe("older-page");
256 expect(url.searchParams.get("reasoningMessages")).toBe("true");
255257 }
256258 expect(PREPARED.httpAuthorization).toMatchObject({ accessToken: "expired-token" });
257259 }),
packages/client-runtime/src/state/threadSnapshotHttp.ts+10-1
......@@ -42,6 +42,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(
4242 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
4343 readonly timeoutMs?: number;
4444 readonly window?: ThreadSnapshotWindow;
45 readonly reasoningMessages?: boolean;
4546}) {
4647 return yield* executeAuthenticatedEnvironmentHttpRequest({
4748 ...input,
......@@ -54,6 +55,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(
5455 client.threadSnapshot({
5556 params: { threadId: input.threadId },
5657 payload: {
58 ...(input.reasoningMessages === true ? { reasoningMessages: "true" as const } : {}),
5759 ...(input.window !== undefined ? { turnLimit: input.window.turnLimit } : {}),
5860 ...(input.window?.beforeCursor !== undefined
5961 ? { beforeCursor: input.window.beforeCursor }
......@@ -79,6 +81,7 @@ export class ThreadSnapshotLoader extends Context.Service<
7981 prepared: PreparedConnection,
8082 threadId: ThreadId,
8183 window?: ThreadSnapshotWindow,
84 reasoningMessages?: boolean,
8285 ) => Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>>;
8386 }
8487>()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {}
......@@ -97,12 +100,18 @@ export const threadSnapshotLoaderLayer: Layer.Layer<
97100 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
98101 const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization);
99102 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 ) =>
101109 fetchEnvironmentThreadSnapshot({
102110 prepared,
103111 threadId,
104112 signer,
105113 remoteAuthorization,
114 ...(reasoningMessages === true ? { reasoningMessages: true } : {}),
106115 ...(window !== undefined ? { window } : {}),
107116 }).pipe(
108117 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>;
133133
134134const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (options?: {
135135 readonly paginationCapability?: boolean;
136 readonly reasoningCapability?: boolean;
136137 readonly initialResponse?: LoaderResponse;
137138 /** Cached snapshot returned by the cache store (simulates a warm cache). */
138139 readonly cached?: OrchestrationThreadDetailSnapshot;
......@@ -140,6 +141,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt
140141 const inputs = yield* Queue.unbounded<OrchestrationThreadStreamItem>();
141142 const observed = yield* Queue.unbounded<EnvironmentThreadState>();
142143 const loaderWindows = yield* Ref.make<ReadonlyArray<ThreadSnapshotWindow | undefined>>([]);
144 const loaderReasoning = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]);
143145 const lastSubscribeInput = yield* Ref.make<Record<string, unknown> | undefined>(undefined);
144146 const savedThreads = yield* Ref.make<ReadonlyArray<OrchestrationThreadDetailSnapshot>>([]);
145147 // Older-page responses resolve through deferreds so tests can interleave
......@@ -156,6 +158,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt
156158 client,
157159 initialConfig: Effect.succeed({
158160 threadSnapshotPagination: options?.paginationCapability !== false,
161 reasoningMessages: options?.reasoningCapability === true,
159162 } as never),
160163 subscribeServerConfig: (input) => client.subscribeServerConfig(input),
161164 ready: Effect.void,
......@@ -169,8 +172,9 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt
169172 Option.some(PREPARED),
170173 );
171174 const snapshotLoader = ThreadSnapshotLoader.of({
172 load: (_prepared, _threadId, window) =>
175 load: (_prepared, _threadId, window, reasoningMessages) =>
173176 Ref.update(loaderWindows, (current) => [...current, window]).pipe(
177 Effect.andThen(Ref.update(loaderReasoning, (current) => [...current, reasoningMessages])),
174178 Effect.andThen(
175179 window?.beforeCursor === undefined
176180 ? Effect.succeed(
......@@ -231,6 +235,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt
231235 awaitState,
232236 resolveNextPage,
233237 loaderWindows,
238 loaderReasoning,
234239 lastSubscribeInput,
235240 savedThreads,
236241 threadState,
......@@ -287,6 +292,32 @@ const revertEvent = (sequence: number): OrchestrationThreadStreamItem => ({
287292});
288293
289294describe("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
290321 it.effect("windows the initial load when the server advertises pagination", () =>
291322 Effect.gen(function* () {
292323 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
258258 // from the session config. Gates loadOlderTurns so a reconnect to a
259259 // pre-pagination server never sends unsupported window parameters.
260260 const paginationSupported = yield* Ref.make(false);
261 const reasoningMessagesSupported = yield* Ref.make(false);
261262 // An older page whose thread watermark is ahead of the live state, parked
262263 // until the subscription catches up (see mergeOlderPage's caller). At most
263264 // one can exist because loadOlderTurns no-ops while loadingOlder is true.
......@@ -336,6 +337,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
336337 // window parameters to a server that may not accept them (review
337338 // finding). makeSubscribeInput re-sets it from the next session's config.
338339 yield* Ref.set(paginationSupported, false);
340 yield* Ref.set(reasoningMessagesSupported, false);
339341 yield* SubscriptionRef.update(state, (current) => ({
340342 ...current,
341343 status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data),
......@@ -655,7 +657,12 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
655657 turnLimit: OLDER_THREAD_PAGE_USER_TURN_LIMIT,
656658 beforeCursor: page.beforeCursor,
657659 };
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 );
659666 // Staleness check and merge run under the same lock as stream-item
660667 // application, so a revert/snapshot cannot land between them (TOCTOU
661668 // review finding) — anything that rewrites history bumps the epoch
......@@ -747,6 +754,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
747754 ({}) as {
748755 threadResumeCompletionMarker?: boolean;
749756 threadSnapshotPagination?: boolean;
757 reasoningMessages?: boolean;
750758 },
751759 ),
752760 );
......@@ -755,6 +763,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
755763 // servers reject unknown query params, and a windowed WS fallback to
756764 // such a server would silently hide history.
757765 const supportsPagination = config.threadSnapshotPagination === true;
766 const supportsReasoningMessages = config.reasoningMessages === true;
767 yield* Ref.set(reasoningMessagesSupported, supportsReasoningMessages);
758768 yield* Ref.set(paginationSupported, supportsPagination);
759769 yield* Ref.set(awaitingCompletion, supportsCompletionMarker);
760770 yield* markSynchronizing;
......@@ -801,6 +811,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
801811 prepared,
802812 threadId,
803813 supportsPagination ? { turnLimit: INITIAL_THREAD_USER_TURN_LIMIT } : undefined,
814 supportsReasoningMessages,
804815 );
805816 if (Option.isSome(httpSnapshot)) {
806817 yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value });
......@@ -822,6 +833,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
822833 threadId,
823834 ...(canResume ? { afterSequence: sequence } : {}),
824835 ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
836 ...(supportsReasoningMessages ? { reasoningMessages: true as const } : {}),
825837 // The WS fallback snapshot (sent when afterSequence is missing or
826838 // the gap is too large) should be windowed the same as the HTTP
827839 // 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({
498498// to strings). Both fields optional: omitting them keeps the full-snapshot
499499// behavior, so pagination stays opt-in per request.
500500const EnvironmentOrchestrationThreadSnapshotQuery = {
501 reasoningMessages: Schema.optional(Schema.Literal("true")),
501502 turnLimit: Schema.optional(
502503 Schema.FiniteFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)),
503504 ),
packages/contracts/src/orchestration.ts+32-1
......@@ -539,7 +539,15 @@ export const OrchestrationProject = Schema.Struct({
539539});
540540export 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]);
543551export type OrchestrationMessageRole = typeof OrchestrationMessageRole.Type;
544552
545553export const OrchestrationMessage = Schema.Struct({
......@@ -973,6 +981,8 @@ export type OrchestrationSubscribeShellInput = typeof OrchestrationSubscribeShel
973981
974982export const OrchestrationSubscribeThreadInput = Schema.Struct({
975983 threadId: ThreadId,
984 /** Opt in to reasoning roles; older clients receive system messages instead. */
985 reasoningMessages: Schema.optionalKey(Schema.Boolean),
976986 /**
977987 * When provided, the server skips the initial snapshot frame and instead
978988 * replays events after this sequence before streaming live events. Clients
......@@ -1471,6 +1481,25 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({
14711481 createdAt: IsoDateTime,
14721482});
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
14741503const ThreadHistoryImportCommand = Schema.Struct({
14751504 type: Schema.Literal("thread.history.import"),
14761505 commandId: CommandId,
......@@ -1599,6 +1628,8 @@ const InternalOrchestrationCommand = Schema.Union([
15991628 ThreadSessionSetCommand,
16001629 ThreadMessageAssistantDeltaCommand,
16011630 ThreadMessageAssistantCompleteCommand,
1631 ThreadMessageReasoningDeltaCommand,
1632 ThreadMessageReasoningCompleteCommand,
16021633 ThreadHistoryImportCommand,
16031634 ThreadMessageUserAppendCommand,
16041635 ThreadProposedPlanUpsertCommand,
packages/contracts/src/server.ts+2
......@@ -579,6 +579,8 @@ export const ServerConfig = Schema.Struct({
579579 * fields to servers that don't advertise this.
580580 */
581581 threadSnapshotPagination: Schema.optionalKey(Schema.Boolean),
582 /** Whether thread reads accept the reasoningMessages opt-in. */
583 reasoningMessages: Schema.optionalKey(Schema.Boolean),
582584 /**
583585 * Palettes published by this environment's machine. Never sent in a config
584586 * snapshot: the theme stream emits the current set before any change, so a