| author | |
| committer | |
| log | 27eb79dc719d70a99db495b0cb20bd6e3ac10b8d |
| tree | c7132905631c7e42cec359675d6d118991f83b83 |
| parent | 6a2d2466673c010a15cb530fa2a8f77b363e046d |
| signature | Signed by PGP key B5690EEEBB952194 |
10 files changed, 368 insertions(+), 45 deletions(-)
apps/mobile/src/features/threads/QuestionAnswerHistory.tsx+7-6| ... | @@ -4,6 +4,7 @@ import type { | ... | @@ -4,6 +4,7 @@ import type { |
| 4 | UserInputAttachments, | 4 | UserInputAttachments, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { Image, Linking, Pressable, View } from "react-native"; | 6 | import { Image, Linking, Pressable, View } from "react-native"; |
| 7 | import { getQuestionAnswerText } from "@t3tools/client-runtime/work-log/user-input"; | ||
| 7 | import { AppText as Text } from "../../components/AppText"; | 8 | import { AppText as Text } from "../../components/AppText"; |
| 8 | import { useAssetUrl } from "../../state/assets"; | 9 | import { useAssetUrl } from "../../state/assets"; |
| 9 | 10 | ||
| ... | @@ -41,6 +42,7 @@ export function QuestionAnswerHistory(props: { | ... | @@ -41,6 +42,7 @@ export function QuestionAnswerHistory(props: { |
| 41 | <View className="gap-2"> | 42 | <View className="gap-2"> |
| 42 | {[ | 43 | {[ |
| 43 | ...new Set([ | 44 | ...new Set([ |
| 45 | ...Object.keys(props.answer.questionTextById ?? {}), | ||
| 44 | ...Object.keys(props.answer.answers), | 46 | ...Object.keys(props.answer.answers), |
| 45 | ...Object.keys(props.answer.attachmentsByQuestionId), | 47 | ...Object.keys(props.answer.attachmentsByQuestionId), |
| 46 | ]), | 48 | ]), |
| ... | @@ -51,12 +53,11 @@ export function QuestionAnswerHistory(props: { | ... | @@ -51,12 +53,11 @@ export function QuestionAnswerHistory(props: { |
| 51 | {props.answer.questionTextById[questionId]} | 53 | {props.answer.questionTextById[questionId]} |
| 52 | </Text> | 54 | </Text> |
| 53 | ) : null} | 55 | ) : null} |
| 54 | <Text className="text-sm text-foreground"> | 56 | {getQuestionAnswerText(props.answer.answers[questionId]) ? ( |
| 55 | {[props.answer.answers[questionId]] | 57 | <Text className="ml-3 text-sm text-foreground-muted"> |
| 56 | .flat() | 58 | {getQuestionAnswerText(props.answer.answers[questionId])} |
| 57 | .filter((value): value is string => typeof value === "string") | 59 | </Text> |
| 58 | .join(", ")} | 60 | ) : null} |
| 59 | </Text> | ||
| 60 | {(props.answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => ( | 61 | {(props.answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => ( |
| 61 | <AnswerFile | 62 | <AnswerFile |
| 62 | key={attachment.id} | 63 | key={attachment.id} |
apps/mobile/src/features/threads/thread-work-log.tsx+20-1| ... | @@ -1,4 +1,8 @@ | ... | @@ -1,4 +1,8 @@ |
| 1 | import { QuestionAnswerHistory } from "./QuestionAnswerHistory"; | 1 | import { QuestionAnswerHistory } from "./QuestionAnswerHistory"; |
| 2 | import { | ||
| 3 | getQuestionAnswerPreview, | ||
| 4 | hasQuestionAnswer, | ||
| 5 | } from "@t3tools/client-runtime/work-log/user-input"; | ||
| 2 | import * as Haptics from "expo-haptics"; | 6 | import * as Haptics from "expo-haptics"; |
| 3 | import { Image } from "expo-image"; | 7 | import { Image } from "expo-image"; |
| 4 | import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; | 8 | import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; |
| ... | @@ -745,6 +749,10 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -745,6 +749,10 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 745 | const viewedImagePath = workEntryViewedImagePath(row.workEntry); | 749 | const viewedImagePath = workEntryViewedImagePath(row.workEntry); |
| 746 | const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); | 750 | const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); |
| 747 | const previewText = workEntryRowLabel(row.workEntry); | 751 | const previewText = workEntryRowLabel(row.workEntry); |
| 752 | const answerPreview = row.workEntry.questionAnswer | ||
| 753 | ? getQuestionAnswerPreview(row.workEntry.questionAnswer) | ||
| 754 | : null; | ||
| 755 | const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": "); | ||
| 748 | const displayText = workEntryRowLabel(row.workEntry, expanded); | 756 | const displayText = workEntryRowLabel(row.workEntry, expanded); |
| 749 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; | 757 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; |
| 750 | const failed = row.status === "failure"; | 758 | const failed = row.status === "failure"; |
| ... | @@ -759,7 +767,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -759,7 +767,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 759 | > | 767 | > |
| 760 | <Pressable | 768 | <Pressable |
| 761 | accessibilityRole={canExpand ? "button" : undefined} | 769 | accessibilityRole={canExpand ? "button" : undefined} |
| 762 | accessibilityLabel={failed ? `${previewText}, tool call failed` : previewText} | 770 | accessibilityLabel={failed ? `${accessiblePreview}, tool call failed` : accessiblePreview} |
| 763 | accessibilityHint={ | 771 | accessibilityHint={ |
| 764 | canExpand | 772 | canExpand |
| 765 | ? `Double tap to ${expanded ? "hide" : "show"} full details. Long press to copy.` | 773 | ? `Double tap to ${expanded ? "hide" : "show"} full details. Long press to copy.` |
| ... | @@ -820,6 +828,17 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -820,6 +828,17 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 820 | numberOfLines={expanded ? undefined : 1} | 828 | numberOfLines={expanded ? undefined : 1} |
| 821 | > | 829 | > |
| 822 | {displayText} | 830 | {displayText} |
| 831 | {answerPreview ? ( | ||
| 832 | <Text | ||
| 833 | className={ | ||
| 834 | !expanded && | ||
| 835 | row.workEntry.questionAnswer && | ||
| 836 | hasQuestionAnswer(row.workEntry.questionAnswer) | ||
| 837 | ? "text-foreground" | ||
| 838 | : "text-foreground-subtle" | ||
| 839 | } | ||
| 840 | >{` ${answerPreview}`}</Text> | ||
| 841 | ) : null} | ||
| 823 | </Text> | 842 | </Text> |
| 824 | </> | 843 | </> |
| 825 | )} | 844 | )} |
apps/mobile/src/lib/threadActivity.ts+25-14| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import * as Option from "effect/Option"; | 1 | import * as Option from "effect/Option"; |
| 2 | import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input"; | ||
| 2 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 3 | import { | 4 | import { |
| 4 | requestKindFromRequestType, | 5 | requestKindFromRequestType, |
| ... | @@ -405,7 +406,7 @@ function deriveWorkLogEntries( | ... | @@ -405,7 +406,7 @@ function deriveWorkLogEntries( |
| 405 | ): DerivedWorkLogEntry[] { | 406 | ): DerivedWorkLogEntry[] { |
| 406 | const ordered = Arr.sort(activities, activityOrder); | 407 | const ordered = Arr.sort(activities, activityOrder); |
| 407 | const entries: DerivedWorkLogEntry[] = []; | 408 | const entries: DerivedWorkLogEntry[] = []; |
| 408 | for (const activity of ordered) { | 409 | for (const activity of foldUserInputActivities(ordered)) { |
| 409 | if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; | 410 | if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; |
| 410 | if (activity.kind === "tool.started") continue; | 411 | if (activity.kind === "tool.started") continue; |
| 411 | // Like web: an agent's task.started row anchors its batch. It has a fixed | 412 | // Like web: an agent's task.started row anchors its batch. It has a fixed |
| ... | @@ -936,6 +937,7 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { | ... | @@ -936,6 +937,7 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { |
| 936 | function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { | 937 | function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { |
| 937 | if (entry.agentSpawn) return "agent"; | 938 | if (entry.agentSpawn) return "agent"; |
| 938 | if ( | 939 | if ( |
| 940 | entry.questionAnswer || | ||
| 939 | entry.sourceActivityKind === "user-input.requested" || | 941 | entry.sourceActivityKind === "user-input.requested" || |
| 940 | entry.sourceActivityKind === "user-input.resolved" | 942 | entry.sourceActivityKind === "user-input.resolved" |
| 941 | ) { | 943 | ) { |
| ... | @@ -2184,21 +2186,30 @@ export function buildThreadFeed( | ... | @@ -2184,21 +2186,30 @@ export function buildThreadFeed( |
| 2184 | : loadedMessages; | 2186 | : loadedMessages; |
| 2185 | const oldestLoadedMessageCreatedAt = | 2187 | const oldestLoadedMessageCreatedAt = |
| 2186 | options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; | 2188 | options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; |
| 2187 | const activityEntries = getThreadFeedActivityEntries(thread.activities); | 2189 | const activityEntries = getThreadFeedActivityEntries(thread.activities).filter( |
| 2190 | (entry) => | ||
| 2191 | oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt, | ||
| 2192 | ); | ||
| 2193 | const foldedAnswerMessageIds = new Set( | ||
| 2194 | activityEntries.flatMap((entry) => | ||
| 2195 | entry.activity.workEntry.questionAnswer | ||
| 2196 | ? [`async-answer:${entry.activity.workEntry.questionAnswer.requestId}`] | ||
| 2197 | : [], | ||
| 2198 | ), | ||
| 2199 | ); | ||
| 2188 | const entries = Arr.sortWith( | 2200 | const entries = Arr.sortWith( |
| 2189 | [ | 2201 | [ |
| 2190 | ...messages.map((message) => { | 2202 | ...messages |
| 2191 | let entry = messageEntriesCache.get(message); | 2203 | .filter((message) => message.role !== "user" || !foldedAnswerMessageIds.has(message.id)) |
| 2192 | if (!entry) { | 2204 | .map((message) => { |
| 2193 | entry = { type: "message", id: message.id, createdAt: message.createdAt, message }; | 2205 | let entry = messageEntriesCache.get(message); |
| 2194 | messageEntriesCache.set(message, entry); | 2206 | if (!entry) { |
| 2195 | } | 2207 | entry = { type: "message", id: message.id, createdAt: message.createdAt, message }; |
| 2196 | return entry; | 2208 | messageEntriesCache.set(message, entry); |
| 2197 | }), | 2209 | } |
| 2198 | ...activityEntries.filter( | 2210 | return entry; |
| 2199 | (entry) => | 2211 | }), |
| 2200 | oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt, | 2212 | ...activityEntries, |
| 2201 | ), | ||
| 2202 | ], | 2213 | ], |
| 2203 | (s) => new Date(s.createdAt), | 2214 | (s) => new Date(s.createdAt), |
| 2204 | Order.Date, | 2215 | Order.Date, |
apps/server/src/orchestration/ActivityPayloadProjection.ts+4-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { projectQuestionToolInput } from "@t3tools/shared/toolActivity"; | ||
| 1 | import type { | 2 | import type { |
| 2 | OrchestrationEvent, | 3 | OrchestrationEvent, |
| 3 | OrchestrationThreadActivity, | 4 | OrchestrationThreadActivity, |
| ... | @@ -370,18 +371,19 @@ export function projectActivityPayload( | ... | @@ -370,18 +371,19 @@ export function projectActivityPayload( |
| 370 | payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") | 371 | payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") |
| 371 | ? { ...payload, status: itemStatus } | 372 | ? { ...payload, status: itemStatus } |
| 372 | : payload; | 373 | : payload; |
| 374 | const questionInput = projectQuestionToolInput(data, payload.title); | ||
| 373 | 375 | ||
| 374 | if (payload.itemType === "mcp_tool_call") { | 376 | if (payload.itemType === "mcp_tool_call") { |
| 375 | return { | 377 | return { |
| 376 | ...activity, | 378 | ...activity, |
| 377 | payload: { | 379 | payload: { |
| 378 | ...projectedPayload, | 380 | ...projectedPayload, |
| 379 | data: projectMcpToolCallData(data), | 381 | data: { ...projectMcpToolCallData(data), ...questionInput }, |
| 380 | }, | 382 | }, |
| 381 | }; | 383 | }; |
| 382 | } | 384 | } |
| 383 | 385 | ||
| 384 | const projectedData: Record<string, unknown> = {}; | 386 | const projectedData: Record<string, unknown> = { ...questionInput }; |
| 385 | const item = projectCommandData(data); | 387 | const item = projectCommandData(data); |
| 386 | if (item) { | 388 | if (item) { |
| 387 | projectedData.item = item; | 389 | projectedData.item = item; |
apps/web/src/components/chat/MessagesTimeline.test.tsx+13-1| ... | @@ -348,12 +348,24 @@ describe("MessagesTimeline", () => { | ... | @@ -348,12 +348,24 @@ describe("MessagesTimeline", () => { |
| 348 | }); | 348 | }); |
| 349 | const toggle = renderer!.root.findByProps({ "aria-expanded": false }); | 349 | const toggle = renderer!.root.findByProps({ "aria-expanded": false }); |
| 350 | await act(() => toggle.props.onClick()); | 350 | await act(() => toggle.props.onClick()); |
| 351 | const questionToggle = renderer!.root.find( | ||
| 352 | (node) => | ||
| 353 | node.props["aria-label"]?.startsWith("Question answer submitted:") && | ||
| 354 | node.props["aria-expanded"] === false, | ||
| 355 | ); | ||
| 356 | expect(questionToggle.props["aria-label"]).toContain( | ||
| 357 | Object.values(answers)[0] ?? "spec.txt", | ||
| 358 | ); | ||
| 359 | expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec"); | ||
| 360 | await act(() => questionToggle.props.onClick()); | ||
| 351 | const markup = JSON.stringify(renderer!.toJSON()); | 361 | const markup = JSON.stringify(renderer!.toJSON()); |
| 352 | expect(markup.match(/Provide a spec/g)).toHaveLength(1); | 362 | expect(markup.match(/Provide a spec/g)).toHaveLength(1); |
| 353 | expect(markup.match(/spec\.txt/g)).toHaveLength(1); | 363 | expect(markup).toContain("spec.txt"); |
| 354 | expect(markup).toContain("Provide a screenshot"); | 364 | expect(markup).toContain("Provide a screenshot"); |
| 355 | expect(markup).toContain("shot.png"); | 365 | expect(markup).toContain("shot.png"); |
| 356 | for (const answer of Object.values(answers)) expect(markup).toContain(answer); | 366 | for (const answer of Object.values(answers)) expect(markup).toContain(answer); |
| 367 | await act(() => questionToggle.props.onClick()); | ||
| 368 | expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec"); | ||
| 357 | } finally { | 369 | } finally { |
| 358 | await act(() => renderer?.unmount()); | 370 | await act(() => renderer?.unmount()); |
| 359 | } | 371 | } |
apps/web/src/components/chat/MessagesTimeline.tsx+61-18| ... | @@ -1,4 +1,9 @@ | ... | @@ -1,4 +1,9 @@ |
| 1 | import { GitPullRequestIcon } from "lucide-react"; | 1 | import { GitPullRequestIcon } from "lucide-react"; |
| 2 | import { | ||
| 3 | getQuestionAnswerPreview, | ||
| 4 | getQuestionAnswerText, | ||
| 5 | hasQuestionAnswer, | ||
| 6 | } from "@t3tools/client-runtime/work-log/user-input"; | ||
| 2 | import { | 7 | import { |
| 3 | type AssistantCitation, | 8 | type AssistantCitation, |
| 4 | type EnvironmentId, | 9 | type EnvironmentId, |
| ... | @@ -2115,7 +2120,7 @@ function LiveActivityRow({ | ... | @@ -2115,7 +2120,7 @@ function LiveActivityRow({ |
| 2115 | active = false, | 2120 | active = false, |
| 2116 | shimmer = false, | 2121 | shimmer = false, |
| 2117 | }: { | 2122 | }: { |
| 2118 | label: string; | 2123 | label: ReactNode; |
| 2119 | iconName?: WorkEntryIconName; | 2124 | iconName?: WorkEntryIconName; |
| 2120 | toolIcon?: ToolActivityIcon | undefined; | 2125 | toolIcon?: ToolActivityIcon | undefined; |
| 2121 | failed?: boolean; | 2126 | failed?: boolean; |
| ... | @@ -2155,7 +2160,7 @@ function LiveActivityContent({ | ... | @@ -2155,7 +2160,7 @@ function LiveActivityContent({ |
| 2155 | active = false, | 2160 | active = false, |
| 2156 | highlighted = false, | 2161 | highlighted = false, |
| 2157 | }: { | 2162 | }: { |
| 2158 | label: string; | 2163 | label: ReactNode; |
| 2159 | iconName: WorkEntryIconName | undefined; | 2164 | iconName: WorkEntryIconName | undefined; |
| 2160 | toolIcon?: ToolActivityIcon | undefined; | 2165 | toolIcon?: ToolActivityIcon | undefined; |
| 2161 | failed?: boolean; | 2166 | failed?: boolean; |
| ... | @@ -2213,7 +2218,25 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: " | ... | @@ -2213,7 +2218,25 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: " |
| 2213 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} | 2218 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} |
| 2214 | > | 2219 | > |
| 2215 | <LiveActivityRow | 2220 | <LiveActivityRow |
| 2216 | label={label} | 2221 | label={ |
| 2222 | row.entry.questionAnswer ? ( | ||
| 2223 | <span className="flex min-w-0 gap-1.5"> | ||
| 2224 | <span className="shrink-0">{label}</span> | ||
| 2225 | <span | ||
| 2226 | className={cn( | ||
| 2227 | "truncate", | ||
| 2228 | !row.expanded && hasQuestionAnswer(row.entry.questionAnswer) | ||
| 2229 | ? "text-foreground" | ||
| 2230 | : "text-muted-foreground", | ||
| 2231 | )} | ||
| 2232 | > | ||
| 2233 | {getQuestionAnswerPreview(row.entry.questionAnswer)} | ||
| 2234 | </span> | ||
| 2235 | </span> | ||
| 2236 | ) : ( | ||
| 2237 | label | ||
| 2238 | ) | ||
| 2239 | } | ||
| 2217 | iconName={workEntryIconName(row.entry)} | 2240 | iconName={workEntryIconName(row.entry)} |
| 2218 | toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon} | 2241 | toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon} |
| 2219 | failed={failed} | 2242 | failed={failed} |
| ... | @@ -3148,6 +3171,7 @@ const toolCallExpandedBodyClassName = | ... | @@ -3148,6 +3171,7 @@ const toolCallExpandedBodyClassName = |
| 3148 | 3171 | ||
| 3149 | function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { | 3172 | function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { |
| 3150 | if ( | 3173 | if ( |
| 3174 | workEntry.questionAnswer || | ||
| 3151 | workEntry.sourceActivityKind === "user-input.requested" || | 3175 | workEntry.sourceActivityKind === "user-input.requested" || |
| 3152 | workEntry.sourceActivityKind === "user-input.resolved" | 3176 | workEntry.sourceActivityKind === "user-input.resolved" |
| 3153 | ) { | 3177 | ) { |
| ... | @@ -3325,9 +3349,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -3325,9 +3349,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 3325 | showWarningIndicator || showDestructiveRowStyle | 3349 | showWarningIndicator || showDestructiveRowStyle |
| 3326 | ? undefined | 3350 | ? undefined |
| 3327 | : (workEntry.toolIcon ?? workEntry.toolSource?.icon); | 3351 | : (workEntry.toolIcon ?? workEntry.toolSource?.icon); |
| 3328 | const previewText = workEntry.questionAnswer | 3352 | const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot); |
| 3329 | ? "Question answer submitted" | 3353 | const answerPreview = workEntry.questionAnswer |
| 3330 | : (displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot)); | 3354 | ? getQuestionAnswerPreview(workEntry.questionAnswer) |
| 3355 | : null; | ||
| 3331 | const viewedImagePath = workEntryViewedImagePath(workEntry); | 3356 | const viewedImagePath = workEntryViewedImagePath(workEntry); |
| 3332 | const viewedImage = | 3357 | const viewedImage = |
| 3333 | viewedImagePath && threadRef | 3358 | viewedImagePath && threadRef |
| ... | @@ -3337,6 +3362,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -3337,6 +3362,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 3337 | }) | 3362 | }) |
| 3338 | : null; | 3363 | : null; |
| 3339 | const canExpand = | 3364 | const canExpand = |
| 3365 | Boolean(workEntry.questionAnswer) || | ||
| 3340 | (showFailedIndicator && previewText.trim().length > 0) || | 3366 | (showFailedIndicator && previewText.trim().length > 0) || |
| 3341 | (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || | 3367 | (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || |
| 3342 | Boolean( | 3368 | Boolean( |
| ... | @@ -3374,9 +3400,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -3374,9 +3400,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 3374 | : workLogEntryIsToolLike(workEntry) | 3400 | : workLogEntryIsToolLike(workEntry) |
| 3375 | ? "text-secondary-label" | 3401 | ? "text-secondary-label" |
| 3376 | : "text-foreground/80"; | 3402 | : "text-foreground/80"; |
| 3403 | const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": "); | ||
| 3377 | const accessibleDisplayText = showFailedIndicator | 3404 | const accessibleDisplayText = showFailedIndicator |
| 3378 | ? `${previewText}, tool call failed` | 3405 | ? `${accessiblePreview}, tool call failed` |
| 3379 | : previewText; | 3406 | : accessiblePreview; |
| 3380 | const rowToggleProps = canExpand | 3407 | const rowToggleProps = canExpand |
| 3381 | ? { | 3408 | ? { |
| 3382 | role: "button" as const, | 3409 | role: "button" as const, |
| ... | @@ -3421,7 +3448,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -3421,7 +3448,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 3421 | <p className="flex min-w-0 w-full items-baseline gap-1.5 text-sm leading-relaxed"> | 3448 | <p className="flex min-w-0 w-full items-baseline gap-1.5 text-sm leading-relaxed"> |
| 3422 | <span | 3449 | <span |
| 3423 | className={cn( | 3450 | className={cn( |
| 3424 | "min-w-0 flex-1", | 3451 | answerPreview ? "shrink-0" : "min-w-0 flex-1", |
| 3425 | expanded ? "whitespace-pre-wrap break-words select-text" : "truncate", | 3452 | expanded ? "whitespace-pre-wrap break-words select-text" : "truncate", |
| 3426 | headingClass, | 3453 | headingClass, |
| 3427 | )} | 3454 | )} |
| ... | @@ -3430,6 +3457,20 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -3430,6 +3457,20 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 3430 | > | 3457 | > |
| 3431 | {previewText} | 3458 | {previewText} |
| 3432 | </span> | 3459 | </span> |
| 3460 | {answerPreview ? ( | ||
| 3461 | <span | ||
| 3462 | className={cn( | ||
| 3463 | "min-w-0 truncate", | ||
| 3464 | !expanded && | ||
| 3465 | workEntry.questionAnswer && | ||
| 3466 | hasQuestionAnswer(workEntry.questionAnswer) | ||
| 3467 | ? "text-foreground" | ||
| 3468 | : "text-muted-foreground", | ||
| 3469 | )} | ||
| 3470 | > | ||
| 3471 | {answerPreview} | ||
| 3472 | </span> | ||
| 3473 | ) : null} | ||
| 3433 | </p> | 3474 | </p> |
| 3434 | </div> | 3475 | </div> |
| 3435 | {showFailedIndicator && | 3476 | {showFailedIndicator && |
| ... | @@ -3470,10 +3511,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -3470,10 +3511,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 3470 | /> | 3511 | /> |
| 3471 | </div> | 3512 | </div> |
| 3472 | ) : null} | 3513 | ) : null} |
| 3473 | {workEntry.questionAnswer ? ( | 3514 | {expanded && workEntry.questionAnswer ? ( |
| 3474 | <QuestionAnswerHistory answer={workEntry.questionAnswer} /> | 3515 | <QuestionAnswerHistory answer={workEntry.questionAnswer} /> |
| 3475 | ) : null} | 3516 | ) : null} |
| 3476 | {expanded && canExpand && expandedBody ? ( | 3517 | {expanded && canExpand && expandedBody && !workEntry.questionAnswer ? ( |
| 3477 | <div | 3518 | <div |
| 3478 | className="mt-1 ms-7 cursor-default rounded-md bg-muted/40 px-3 py-2" | 3519 | className="mt-1 ms-7 cursor-default rounded-md bg-muted/40 px-3 py-2" |
| 3479 | onClick={stopRowToggle} | 3520 | onClick={stopRowToggle} |
| ... | @@ -3506,20 +3547,22 @@ function QuestionAnswerHistory({ | ... | @@ -3506,20 +3547,22 @@ function QuestionAnswerHistory({ |
| 3506 | <div className="ms-7 mt-2 space-y-2" onClick={stopRowToggle}> | 3547 | <div className="ms-7 mt-2 space-y-2" onClick={stopRowToggle}> |
| 3507 | {[ | 3548 | {[ |
| 3508 | ...new Set([ | 3549 | ...new Set([ |
| 3550 | ...Object.keys(answer.questionTextById ?? {}), | ||
| 3509 | ...Object.keys(answer.answers), | 3551 | ...Object.keys(answer.answers), |
| 3510 | ...Object.keys(answer.attachmentsByQuestionId), | 3552 | ...Object.keys(answer.attachmentsByQuestionId), |
| 3511 | ]), | 3553 | ]), |
| 3512 | ].map((questionId) => ( | 3554 | ].map((questionId) => ( |
| 3513 | <div key={questionId} className="space-y-1"> | 3555 | <div key={questionId} className="space-y-1"> |
| 3514 | {answer.questionTextById?.[questionId] ? ( | 3556 | {answer.questionTextById?.[questionId] ? ( |
| 3515 | <p className="text-sm text-muted-foreground">{answer.questionTextById[questionId]}</p> | 3557 | <p className="whitespace-pre-wrap text-sm text-muted-foreground"> |
| 3558 | {answer.questionTextById[questionId]} | ||
| 3559 | </p> | ||
| 3560 | ) : null} | ||
| 3561 | {getQuestionAnswerText(answer.answers[questionId]) ? ( | ||
| 3562 | <p className="ms-3 whitespace-pre-wrap text-sm text-muted-foreground"> | ||
| 3563 | {getQuestionAnswerText(answer.answers[questionId])} | ||
| 3564 | </p> | ||
| 3516 | ) : null} | 3565 | ) : null} |
| 3517 | <p className="whitespace-pre-wrap text-sm"> | ||
| 3518 | {[answer.answers[questionId]] | ||
| 3519 | .flat() | ||
| 3520 | .filter((value): value is string => typeof value === "string") | ||
| 3521 | .join(", ")} | ||
| 3522 | </p> | ||
| 3523 | <div className="flex flex-wrap gap-2"> | 3566 | <div className="flex flex-wrap gap-2"> |
| 3524 | {(answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => { | 3567 | {(answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => { |
| 3525 | const url = urls[attachments.indexOf(attachment)]; | 3568 | const url = urls[attachments.indexOf(attachment)]; |
apps/web/src/session-logic.ts+15-3| ... | @@ -3,6 +3,7 @@ import { | ... | @@ -3,6 +3,7 @@ import { |
| 3 | type PendingApproval, | 3 | type PendingApproval, |
| 4 | } from "@t3tools/client-runtime/pending-requests"; | 4 | } from "@t3tools/client-runtime/pending-requests"; |
| 5 | import { UserInputAttachmentAnswerPayload } from "@t3tools/contracts"; | 5 | import { UserInputAttachmentAnswerPayload } from "@t3tools/contracts"; |
| 6 | import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input"; | ||
| 6 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 7 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| 8 | import * as Arr from "effect/Array"; | 9 | import * as Arr from "effect/Array"; |
| ... | @@ -451,7 +452,7 @@ export function deriveWorkLogEntries( | ... | @@ -451,7 +452,7 @@ export function deriveWorkLogEntries( |
| 451 | ): WorkLogEntry[] { | 452 | ): WorkLogEntry[] { |
| 452 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | 453 | const ordered = [...activities].toSorted(compareActivitiesByOrder); |
| 453 | const entries: DerivedWorkLogEntry[] = []; | 454 | const entries: DerivedWorkLogEntry[] = []; |
| 454 | for (const activity of ordered) { | 455 | for (const activity of foldUserInputActivities(ordered)) { |
| 455 | if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; | 456 | if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; |
| 456 | if (activity.kind === "tool.started") continue; | 457 | if (activity.kind === "tool.started") continue; |
| 457 | // Agent task.started rows are CTA seeds: they carry the true spawn turn, | 458 | // Agent task.started rows are CTA seeds: they carry the true spawn turn, |
| ... | @@ -1622,14 +1623,25 @@ export function deriveTimelineEntriesWithState( | ... | @@ -1622,14 +1623,25 @@ export function deriveTimelineEntriesWithState( |
| 1622 | const entries = replaceStreamingTimelineMessages(messages, previous); | 1623 | const entries = replaceStreamingTimelineMessages(messages, previous); |
| 1623 | if (entries !== null) return { messages, proposedPlans, workEntries, entries }; | 1624 | if (entries !== null) return { messages, proposedPlans, workEntries, entries }; |
| 1624 | } | 1625 | } |
| 1626 | const foldedAnswerMessageIds = new Set( | ||
| 1627 | workEntries.flatMap((entry) => | ||
| 1628 | entry.questionAnswer ? [`async-answer:${entry.questionAnswer.requestId}`] : [], | ||
| 1629 | ), | ||
| 1630 | ); | ||
| 1631 | const showMessage = (message: ChatMessage) => | ||
| 1632 | message.role !== "user" || !foldedAnswerMessageIds.has(message.id); | ||
| 1625 | const canAppend = | 1633 | const canAppend = |
| 1626 | previous !== null && | 1634 | previous !== null && |
| 1635 | !previous.entries.some((entry) => entry.kind === "message" && !showMessage(entry.message)) && | ||
| 1627 | hasExactArrayPrefix(previous.messages, messages) && | 1636 | hasExactArrayPrefix(previous.messages, messages) && |
| 1628 | hasExactArrayPrefix(previous.proposedPlans, proposedPlans) && | 1637 | hasExactArrayPrefix(previous.proposedPlans, proposedPlans) && |
| 1629 | hasExactArrayPrefix(previous.workEntries, workEntries); | 1638 | hasExactArrayPrefix(previous.workEntries, workEntries); |
| 1630 | 1639 | ||
| 1631 | if (canAppend) { | 1640 | if (canAppend) { |
| 1632 | const messageRows = messages.slice(previous.messages.length).map(timelineEntryFromMessage); | 1641 | const messageRows = messages |
| 1642 | .slice(previous.messages.length) | ||
| 1643 | .filter(showMessage) | ||
| 1644 | .map(timelineEntryFromMessage); | ||
| 1633 | const proposedPlanRows = proposedPlans | 1645 | const proposedPlanRows = proposedPlans |
| 1634 | .slice(previous.proposedPlans.length) | 1646 | .slice(previous.proposedPlans.length) |
| 1635 | .map(timelineEntryFromProposedPlan); | 1647 | .map(timelineEntryFromProposedPlan); |
| ... | @@ -1645,7 +1657,7 @@ export function deriveTimelineEntriesWithState( | ... | @@ -1645,7 +1657,7 @@ export function deriveTimelineEntriesWithState( |
| 1645 | }; | 1657 | }; |
| 1646 | } | 1658 | } |
| 1647 | 1659 | ||
| 1648 | const messageRows = messages.map(timelineEntryFromMessage); | 1660 | const messageRows = messages.filter(showMessage).map(timelineEntryFromMessage); |
| 1649 | const proposedPlanRows = proposedPlans.map(timelineEntryFromProposedPlan); | 1661 | const proposedPlanRows = proposedPlans.map(timelineEntryFromProposedPlan); |
| 1650 | const workRows = workEntries.map(timelineEntryFromWork); | 1662 | const workRows = workEntries.map(timelineEntryFromWork); |
| 1651 | return { | 1663 | return { |
packages/client-runtime/package.json+4| ... | @@ -223,6 +223,10 @@ | ... | @@ -223,6 +223,10 @@ |
| 223 | "types": "./src/state/vcs.ts", | 223 | "types": "./src/state/vcs.ts", |
| 224 | "default": "./src/state/vcs.ts" | 224 | "default": "./src/state/vcs.ts" |
| 225 | }, | 225 | }, |
| 226 | "./work-log/user-input": { | ||
| 227 | "types": "./src/work-log/userInput.ts", | ||
| 228 | "default": "./src/work-log/userInput.ts" | ||
| 229 | }, | ||
| 226 | "./work-log/presentation": { | 230 | "./work-log/presentation": { |
| 227 | "types": "./src/work-log/presentation.ts", | 231 | "types": "./src/work-log/presentation.ts", |
| 228 | "default": "./src/work-log/presentation.ts" | 232 | "default": "./src/work-log/presentation.ts" |
packages/client-runtime/src/work-log/userInput.ts created+186| ... | @@ -0,0 +1,186 @@ | ||
| 1 | import { projectQuestionToolInput } from "@t3tools/shared/toolActivity"; | ||
| 2 | import { | ||
| 3 | type OrchestrationThreadActivity, | ||
| 4 | UserInputAttachmentAnswerPayload, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | |||
| 8 | function record(value: unknown): Record<string, unknown> | undefined { | ||
| 9 | return value !== null && typeof value === "object" && !Array.isArray(value) | ||
| 10 | ? (value as Record<string, unknown>) | ||
| 11 | : undefined; | ||
| 12 | } | ||
| 13 | |||
| 14 | const isQuestionAnswer = Schema.is(UserInputAttachmentAnswerPayload); | ||
| 15 | |||
| 16 | function displayOptionAnswer(value: unknown, labels: ReadonlyMap<string, string>): unknown { | ||
| 17 | if (typeof value === "string") return labels.get(value) ?? value; | ||
| 18 | if (Array.isArray(value)) return value.map((answer) => displayOptionAnswer(answer, labels)); | ||
| 19 | const nested = record(value); | ||
| 20 | return nested && "answers" in nested | ||
| 21 | ? { ...nested, answers: displayOptionAnswer(nested.answers, labels) } | ||
| 22 | : value; | ||
| 23 | } | ||
| 24 | |||
| 25 | function questionFingerprint( | ||
| 26 | turnId: string, | ||
| 27 | questions: ReadonlyArray<unknown>, | ||
| 28 | ): string | undefined { | ||
| 29 | const texts = questions.map((question) => (typeof question === "string" ? question.trim() : "")); | ||
| 30 | return texts.length > 0 && texts.every(Boolean) | ||
| 31 | ? JSON.stringify([turnId, texts.toSorted()]) | ||
| 32 | : undefined; | ||
| 33 | } | ||
| 34 | |||
| 35 | function withoutDuplicateQuestionTools( | ||
| 36 | activities: ReadonlyArray<OrchestrationThreadActivity>, | ||
| 37 | ): ReadonlyArray<OrchestrationThreadActivity> { | ||
| 38 | const questions = new Set<string>(); | ||
| 39 | for (const activity of activities) { | ||
| 40 | if (activity.kind !== "user-input.answer-submitted" || !activity.turnId) continue; | ||
| 41 | const payload = record(activity.payload); | ||
| 42 | const texts = Object.values(record(payload?.questionTextById) ?? {}); | ||
| 43 | const fingerprint = questionFingerprint(activity.turnId, texts); | ||
| 44 | if (fingerprint) questions.add(fingerprint); | ||
| 45 | } | ||
| 46 | if (questions.size === 0) return activities; | ||
| 47 | const duplicateToolIds = new Set<string>(); | ||
| 48 | for (const activity of activities) { | ||
| 49 | if (!activity.kind.startsWith("tool.") || !activity.turnId) continue; | ||
| 50 | const payload = record(activity.payload); | ||
| 51 | if (typeof payload?.toolCallId !== "string") continue; | ||
| 52 | const input = projectQuestionToolInput(record(payload.data) ?? {}, payload.title).input; | ||
| 53 | if (!input) continue; | ||
| 54 | const fingerprint = questionFingerprint( | ||
| 55 | activity.turnId, | ||
| 56 | input.questions.map((question) => record(question)?.question), | ||
| 57 | ); | ||
| 58 | if (fingerprint && questions.has(fingerprint)) { | ||
| 59 | duplicateToolIds.add(JSON.stringify([activity.turnId, payload.toolCallId])); | ||
| 60 | } | ||
| 61 | } | ||
| 62 | return activities.filter((activity) => { | ||
| 63 | const payload = record(activity.payload); | ||
| 64 | const toolCallId = payload?.toolCallId; | ||
| 65 | return ( | ||
| 66 | activity.tone === "error" || | ||
| 67 | /^(failed|declined|stopped|cancelled)$/.test(String(payload?.status)) || | ||
| 68 | !activity.kind.startsWith("tool.") || | ||
| 69 | typeof toolCallId !== "string" || | ||
| 70 | !duplicateToolIds.has(JSON.stringify([activity.turnId, toolCallId])) | ||
| 71 | ); | ||
| 72 | }); | ||
| 73 | } | ||
| 74 | |||
| 75 | /** Keep a question and its answer at the original tool position in the work log. */ | ||
| 76 | export function foldUserInputActivities( | ||
| 77 | activities: ReadonlyArray<OrchestrationThreadActivity>, | ||
| 78 | ): ReadonlyArray<OrchestrationThreadActivity> { | ||
| 79 | const requests = new Map<string, OrchestrationThreadActivity[]>(); | ||
| 80 | for (const activity of activities) { | ||
| 81 | if ( | ||
| 82 | activity.kind !== "user-input.requested" && | ||
| 83 | activity.kind !== "user-input.resolved" && | ||
| 84 | activity.kind !== "user-input.answer-submitted" | ||
| 85 | ) | ||
| 86 | continue; | ||
| 87 | const requestId = record(activity.payload)?.requestId; | ||
| 88 | if (typeof requestId !== "string" || !requestId) continue; | ||
| 89 | const group = requests.get(requestId) ?? []; | ||
| 90 | group.push(activity); | ||
| 91 | requests.set(requestId, group); | ||
| 92 | } | ||
| 93 | const replacements = new Map<OrchestrationThreadActivity, OrchestrationThreadActivity | null>(); | ||
| 94 | for (const [requestId, group] of requests) { | ||
| 95 | const payloads = group.map((activity) => record(activity.payload)!); | ||
| 96 | const questions = new Map<string, Record<string, unknown>>(); | ||
| 97 | const texts = new Map<string, unknown>(); | ||
| 98 | for (const payload of payloads) { | ||
| 99 | for (const [id, text] of Object.entries(record(payload.questionTextById) ?? {})) | ||
| 100 | texts.set(id, text); | ||
| 101 | for (const value of Array.isArray(payload.questions) ? payload.questions : []) { | ||
| 102 | const question = record(value); | ||
| 103 | if (typeof question?.id !== "string") continue; | ||
| 104 | questions.set(question.id, question); | ||
| 105 | if (typeof question.question === "string") texts.set(question.id, question.question); | ||
| 106 | } | ||
| 107 | } | ||
| 108 | const questionTextById = Object.fromEntries(texts); | ||
| 109 | const submitted = group.findLast( | ||
| 110 | (activity) => | ||
| 111 | activity.kind === "user-input.answer-submitted" && | ||
| 112 | record(record(activity.payload)?.answers), | ||
| 113 | ); | ||
| 114 | const rawAnswers = | ||
| 115 | record(record(submitted?.payload)?.answers) ?? | ||
| 116 | payloads.map((payload) => record(payload.answers)).findLast(Boolean) ?? | ||
| 117 | {}; | ||
| 118 | const answers = Object.fromEntries( | ||
| 119 | Object.entries(rawAnswers).map(([id, value]) => { | ||
| 120 | const options = questions.get(id)?.options; | ||
| 121 | const labels = new Map<string, string>(); | ||
| 122 | for (const candidate of Array.isArray(options) ? options : []) { | ||
| 123 | const option = record(candidate); | ||
| 124 | if (typeof option?.value === "string" && typeof option.label === "string") | ||
| 125 | labels.set(option.value, option.label); | ||
| 126 | } | ||
| 127 | return [id, displayOptionAnswer(value, labels)]; | ||
| 128 | }), | ||
| 129 | ); | ||
| 130 | const attachmentsByQuestionId = Object.fromEntries( | ||
| 131 | payloads.flatMap((payload) => Object.entries(record(payload.attachmentsByQuestionId) ?? {})), | ||
| 132 | ); | ||
| 133 | const answer = { requestId, questionTextById, answers, attachmentsByQuestionId }; | ||
| 134 | if (!isQuestionAnswer(answer)) continue; | ||
| 135 | const submittedAnswer = | ||
| 136 | Object.keys(answers).length > 0 || Object.keys(attachmentsByQuestionId).length > 0; | ||
| 137 | for (const activity of group) replacements.set(activity, null); | ||
| 138 | replacements.set(group[0]!, { | ||
| 139 | ...group[0]!, | ||
| 140 | kind: "user-input.answer-submitted", | ||
| 141 | tone: "tool", | ||
| 142 | summary: submittedAnswer | ||
| 143 | ? "User input submitted" | ||
| 144 | : group.some((activity) => activity.kind === "user-input.resolved") | ||
| 145 | ? "User input dismissed" | ||
| 146 | : "User input requested", | ||
| 147 | payload: answer, | ||
| 148 | }); | ||
| 149 | } | ||
| 150 | return withoutDuplicateQuestionTools( | ||
| 151 | activities.flatMap((activity) => { | ||
| 152 | const replacement = replacements.get(activity); | ||
| 153 | return replacement === null ? [] : [replacement ?? activity]; | ||
| 154 | }), | ||
| 155 | ); | ||
| 156 | } | ||
| 157 | |||
| 158 | export function getQuestionAnswerText(value: unknown): string { | ||
| 159 | if (typeof value === "string") return value; | ||
| 160 | if (Array.isArray(value)) return value.map(getQuestionAnswerText).filter(Boolean).join(", "); | ||
| 161 | const nested = record(value); | ||
| 162 | return nested ? getQuestionAnswerText(nested.answers) : ""; | ||
| 163 | } | ||
| 164 | |||
| 165 | export function getQuestionAnswerPreview(answer: UserInputAttachmentAnswerPayload): string { | ||
| 166 | const answers = Object.values(answer.answers).map(getQuestionAnswerText).filter(Boolean); | ||
| 167 | const attachments = Object.values(answer.attachmentsByQuestionId) | ||
| 168 | .flat() | ||
| 169 | .map((attachment) => attachment.name); | ||
| 170 | return ( | ||
| 171 | answers.length > 0 | ||
| 172 | ? answers.join(" · ") | ||
| 173 | : attachments.length > 0 | ||
| 174 | ? attachments.join(", ") | ||
| 175 | : Object.values(answer.questionTextById ?? {}).join(" · ") | ||
| 176 | ) | ||
| 177 | .replace(/\s+/g, " ") | ||
| 178 | .trim(); | ||
| 179 | } | ||
| 180 | |||
| 181 | export function hasQuestionAnswer(answer: UserInputAttachmentAnswerPayload): boolean { | ||
| 182 | return ( | ||
| 183 | Object.values(answer.answers).some(getQuestionAnswerText) || | ||
| 184 | Object.values(answer.attachmentsByQuestionId).some((attachments) => attachments.length > 0) | ||
| 185 | ); | ||
| 186 | } | ||
packages/shared/src/toolActivity.ts+33| ... | @@ -259,3 +259,36 @@ export function deriveToolActivityPresentation( | ... | @@ -259,3 +259,36 @@ export function deriveToolActivityPresentation( |
| 259 | summary: title ?? fallbackSummary, | 259 | summary: title ?? fallbackSummary, |
| 260 | }; | 260 | }; |
| 261 | } | 261 | } |
| 262 | |||
| 263 | export function projectQuestionToolInput(data: Record<string, unknown>, title: unknown) { | ||
| 264 | const item = asRecord(data.item); | ||
| 265 | const toolName = data.toolName ?? data.tool ?? item?.tool ?? title; | ||
| 266 | if (typeof toolName !== "string") return {}; | ||
| 267 | const name = toolName | ||
| 268 | .split(/__|[./]/) | ||
| 269 | .at(-1) | ||
| 270 | ?.replace(/[_\s]/g, "") | ||
| 271 | .toLowerCase(); | ||
| 272 | if (!name || !/^(askuserquestion|requestuserinput(?:async)?|askquestion|question)$/.test(name)) | ||
| 273 | return {}; | ||
| 274 | const input = asRecord( | ||
| 275 | data.input ?? data.rawInput ?? asRecord(data.state)?.input ?? item?.arguments, | ||
| 276 | ); | ||
| 277 | const questions = input?.questions ?? asRecord(input?.params)?.questions; | ||
| 278 | if (!Array.isArray(questions)) return {}; | ||
| 279 | // Clients match native tools to the canonical question; choices and answers | ||
| 280 | // already live on the user-input activities and need not cross the wire twice. | ||
| 281 | return { | ||
| 282 | toolName, | ||
| 283 | input: { | ||
| 284 | questions: questions.map((value) => { | ||
| 285 | const question = asRecord(value); | ||
| 286 | return { | ||
| 287 | question: asTrimmedString( | ||
| 288 | question?.question ?? question?.question_text ?? question?.prompt ?? question?.title, | ||
| 289 | ), | ||
| 290 | }; | ||
| 291 | }), | ||
| 292 | }, | ||
| 293 | }; | ||
| 294 | } |