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