authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-10 23:38:42-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 23:38:42-03:00
log27eb79dc719d70a99db495b0cb20bd6e3ac10b8d
treec7132905631c7e42cec359675d6d118991f83b83
parent6a2d2466673c010a15cb530fa2a8f77b363e046d
signature Signed by PGP key B5690EEEBB952194

fix(chat): fold question answers into tool activity (#11014)


10 files changed, 368 insertions(+), 45 deletions(-)

apps/mobile/src/features/threads/QuestionAnswerHistory.tsx+7-6
......@@ -4,6 +4,7 @@ import type {
44 UserInputAttachments,
55} from "@t3tools/contracts";
66import { Image, Linking, Pressable, View } from "react-native";
7import { getQuestionAnswerText } from "@t3tools/client-runtime/work-log/user-input";
78import { AppText as Text } from "../../components/AppText";
89import { useAssetUrl } from "../../state/assets";
910
......@@ -41,6 +42,7 @@ export function QuestionAnswerHistory(props: {
4142 <View className="gap-2">
4243 {[
4344 ...new Set([
45 ...Object.keys(props.answer.questionTextById ?? {}),
4446 ...Object.keys(props.answer.answers),
4547 ...Object.keys(props.answer.attachmentsByQuestionId),
4648 ]),
......@@ -51,12 +53,11 @@ export function QuestionAnswerHistory(props: {
5153 {props.answer.questionTextById[questionId]}
5254 </Text>
5355 ) : null}
54 <Text className="text-sm text-foreground">
55 {[props.answer.answers[questionId]]
56 .flat()
57 .filter((value): value is string => typeof value === "string")
58 .join(", ")}
59 </Text>
56 {getQuestionAnswerText(props.answer.answers[questionId]) ? (
57 <Text className="ml-3 text-sm text-foreground-muted">
58 {getQuestionAnswerText(props.answer.answers[questionId])}
59 </Text>
60 ) : null}
6061 {(props.answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => (
6162 <AnswerFile
6263 key={attachment.id}
apps/mobile/src/features/threads/thread-work-log.tsx+20-1
......@@ -1,4 +1,8 @@
11import { QuestionAnswerHistory } from "./QuestionAnswerHistory";
2import {
3 getQuestionAnswerPreview,
4 hasQuestionAnswer,
5} from "@t3tools/client-runtime/work-log/user-input";
26import * as Haptics from "expo-haptics";
37import { Image } from "expo-image";
48import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
......@@ -745,6 +749,10 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
745749 const viewedImagePath = workEntryViewedImagePath(row.workEntry);
746750 const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);
747751 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(": ");
748756 const displayText = workEntryRowLabel(row.workEntry, expanded);
749757 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
750758 const failed = row.status === "failure";
......@@ -759,7 +767,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
759767 >
760768 <Pressable
761769 accessibilityRole={canExpand ? "button" : undefined}
762 accessibilityLabel={failed ? `${previewText}, tool call failed` : previewText}
770 accessibilityLabel={failed ? `${accessiblePreview}, tool call failed` : accessiblePreview}
763771 accessibilityHint={
764772 canExpand
765773 ? `Double tap to ${expanded ? "hide" : "show"} full details. Long press to copy.`
......@@ -820,6 +828,17 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
820828 numberOfLines={expanded ? undefined : 1}
821829 >
822830 {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}
823842 </Text>
824843 </>
825844 )}
apps/mobile/src/lib/threadActivity.ts+25-14
......@@ -1,4 +1,5 @@
11import * as Option from "effect/Option";
2import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input";
23import * as Schema from "effect/Schema";
34import {
45 requestKindFromRequestType,
......@@ -405,7 +406,7 @@ function deriveWorkLogEntries(
405406): DerivedWorkLogEntry[] {
406407 const ordered = Arr.sort(activities, activityOrder);
407408 const entries: DerivedWorkLogEntry[] = [];
408 for (const activity of ordered) {
409 for (const activity of foldUserInputActivities(ordered)) {
409410 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
410411 if (activity.kind === "tool.started") continue;
411412 // 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"] {
936937function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
937938 if (entry.agentSpawn) return "agent";
938939 if (
940 entry.questionAnswer ||
939941 entry.sourceActivityKind === "user-input.requested" ||
940942 entry.sourceActivityKind === "user-input.resolved"
941943 ) {
......@@ -2184,21 +2186,30 @@ export function buildThreadFeed(
21842186 : loadedMessages;
21852187 const oldestLoadedMessageCreatedAt =
21862188 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 );
21882200 const entries = Arr.sortWith(
21892201 [
2190 ...messages.map((message) => {
2191 let entry = messageEntriesCache.get(message);
2192 if (!entry) {
2193 entry = { type: "message", id: message.id, createdAt: message.createdAt, message };
2194 messageEntriesCache.set(message, entry);
2195 }
2196 return entry;
2197 }),
2198 ...activityEntries.filter(
2199 (entry) =>
2200 oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt,
2201 ),
2202 ...messages
2203 .filter((message) => message.role !== "user" || !foldedAnswerMessageIds.has(message.id))
2204 .map((message) => {
2205 let entry = messageEntriesCache.get(message);
2206 if (!entry) {
2207 entry = { type: "message", id: message.id, createdAt: message.createdAt, message };
2208 messageEntriesCache.set(message, entry);
2209 }
2210 return entry;
2211 }),
2212 ...activityEntries,
22022213 ],
22032214 (s) => new Date(s.createdAt),
22042215 Order.Date,
apps/server/src/orchestration/ActivityPayloadProjection.ts+4-2
......@@ -1,3 +1,4 @@
1import { projectQuestionToolInput } from "@t3tools/shared/toolActivity";
12import type {
23 OrchestrationEvent,
34 OrchestrationThreadActivity,
......@@ -370,18 +371,19 @@ export function projectActivityPayload(
370371 payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined")
371372 ? { ...payload, status: itemStatus }
372373 : payload;
374 const questionInput = projectQuestionToolInput(data, payload.title);
373375
374376 if (payload.itemType === "mcp_tool_call") {
375377 return {
376378 ...activity,
377379 payload: {
378380 ...projectedPayload,
379 data: projectMcpToolCallData(data),
381 data: { ...projectMcpToolCallData(data), ...questionInput },
380382 },
381383 };
382384 }
383385
384 const projectedData: Record<string, unknown> = {};
386 const projectedData: Record<string, unknown> = { ...questionInput };
385387 const item = projectCommandData(data);
386388 if (item) {
387389 projectedData.item = item;
apps/web/src/components/chat/MessagesTimeline.test.tsx+13-1
......@@ -348,12 +348,24 @@ describe("MessagesTimeline", () => {
348348 });
349349 const toggle = renderer!.root.findByProps({ "aria-expanded": false });
350350 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());
351361 const markup = JSON.stringify(renderer!.toJSON());
352362 expect(markup.match(/Provide a spec/g)).toHaveLength(1);
353 expect(markup.match(/spec\.txt/g)).toHaveLength(1);
363 expect(markup).toContain("spec.txt");
354364 expect(markup).toContain("Provide a screenshot");
355365 expect(markup).toContain("shot.png");
356366 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");
357369 } finally {
358370 await act(() => renderer?.unmount());
359371 }
apps/web/src/components/chat/MessagesTimeline.tsx+61-18
......@@ -1,4 +1,9 @@
11import { GitPullRequestIcon } from "lucide-react";
2import {
3 getQuestionAnswerPreview,
4 getQuestionAnswerText,
5 hasQuestionAnswer,
6} from "@t3tools/client-runtime/work-log/user-input";
27import {
38 type AssistantCitation,
49 type EnvironmentId,
......@@ -2115,7 +2120,7 @@ function LiveActivityRow({
21152120 active = false,
21162121 shimmer = false,
21172122}: {
2118 label: string;
2123 label: ReactNode;
21192124 iconName?: WorkEntryIconName;
21202125 toolIcon?: ToolActivityIcon | undefined;
21212126 failed?: boolean;
......@@ -2155,7 +2160,7 @@ function LiveActivityContent({
21552160 active = false,
21562161 highlighted = false,
21572162}: {
2158 label: string;
2163 label: ReactNode;
21592164 iconName: WorkEntryIconName | undefined;
21602165 toolIcon?: ToolActivityIcon | undefined;
21612166 failed?: boolean;
......@@ -2213,7 +2218,25 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
22132218 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
22142219 >
22152220 <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 }
22172240 iconName={workEntryIconName(row.entry)}
22182241 toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon}
22192242 failed={failed}
......@@ -3148,6 +3171,7 @@ const toolCallExpandedBodyClassName =
31483171
31493172function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
31503173 if (
3174 workEntry.questionAnswer ||
31513175 workEntry.sourceActivityKind === "user-input.requested" ||
31523176 workEntry.sourceActivityKind === "user-input.resolved"
31533177 ) {
......@@ -3325,9 +3349,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
33253349 showWarningIndicator || showDestructiveRowStyle
33263350 ? undefined
33273351 : (workEntry.toolIcon ?? workEntry.toolSource?.icon);
3328 const previewText = workEntry.questionAnswer
3329 ? "Question answer submitted"
3330 : (displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot));
3352 const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot);
3353 const answerPreview = workEntry.questionAnswer
3354 ? getQuestionAnswerPreview(workEntry.questionAnswer)
3355 : null;
33313356 const viewedImagePath = workEntryViewedImagePath(workEntry);
33323357 const viewedImage =
33333358 viewedImagePath && threadRef
......@@ -3337,6 +3362,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
33373362 })
33383363 : null;
33393364 const canExpand =
3365 Boolean(workEntry.questionAnswer) ||
33403366 (showFailedIndicator && previewText.trim().length > 0) ||
33413367 (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) ||
33423368 Boolean(
......@@ -3374,9 +3400,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
33743400 : workLogEntryIsToolLike(workEntry)
33753401 ? "text-secondary-label"
33763402 : "text-foreground/80";
3403 const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": ");
33773404 const accessibleDisplayText = showFailedIndicator
3378 ? `${previewText}, tool call failed`
3379 : previewText;
3405 ? `${accessiblePreview}, tool call failed`
3406 : accessiblePreview;
33803407 const rowToggleProps = canExpand
33813408 ? {
33823409 role: "button" as const,
......@@ -3421,7 +3448,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
34213448 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-sm leading-relaxed">
34223449 <span
34233450 className={cn(
3424 "min-w-0 flex-1",
3451 answerPreview ? "shrink-0" : "min-w-0 flex-1",
34253452 expanded ? "whitespace-pre-wrap break-words select-text" : "truncate",
34263453 headingClass,
34273454 )}
......@@ -3430,6 +3457,20 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
34303457 >
34313458 {previewText}
34323459 </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}
34333474 </p>
34343475 </div>
34353476 {showFailedIndicator &&
......@@ -3470,10 +3511,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
34703511 />
34713512 </div>
34723513 ) : null}
3473 {workEntry.questionAnswer ? (
3514 {expanded && workEntry.questionAnswer ? (
34743515 <QuestionAnswerHistory answer={workEntry.questionAnswer} />
34753516 ) : null}
3476 {expanded && canExpand && expandedBody ? (
3517 {expanded && canExpand && expandedBody && !workEntry.questionAnswer ? (
34773518 <div
34783519 className="mt-1 ms-7 cursor-default rounded-md bg-muted/40 px-3 py-2"
34793520 onClick={stopRowToggle}
......@@ -3506,20 +3547,22 @@ function QuestionAnswerHistory({
35063547 <div className="ms-7 mt-2 space-y-2" onClick={stopRowToggle}>
35073548 {[
35083549 ...new Set([
3550 ...Object.keys(answer.questionTextById ?? {}),
35093551 ...Object.keys(answer.answers),
35103552 ...Object.keys(answer.attachmentsByQuestionId),
35113553 ]),
35123554 ].map((questionId) => (
35133555 <div key={questionId} className="space-y-1">
35143556 {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>
35163565 ) : 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>
35233566 <div className="flex flex-wrap gap-2">
35243567 {(answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => {
35253568 const url = urls[attachments.indexOf(attachment)];
apps/web/src/session-logic.ts+15-3
......@@ -3,6 +3,7 @@ import {
33 type PendingApproval,
44} from "@t3tools/client-runtime/pending-requests";
55import { UserInputAttachmentAnswerPayload } from "@t3tools/contracts";
6import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input";
67import * as Option from "effect/Option";
78import * as Schema from "effect/Schema";
89import * as Arr from "effect/Array";
......@@ -451,7 +452,7 @@ export function deriveWorkLogEntries(
451452): WorkLogEntry[] {
452453 const ordered = [...activities].toSorted(compareActivitiesByOrder);
453454 const entries: DerivedWorkLogEntry[] = [];
454 for (const activity of ordered) {
455 for (const activity of foldUserInputActivities(ordered)) {
455456 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
456457 if (activity.kind === "tool.started") continue;
457458 // Agent task.started rows are CTA seeds: they carry the true spawn turn,
......@@ -1622,14 +1623,25 @@ export function deriveTimelineEntriesWithState(
16221623 const entries = replaceStreamingTimelineMessages(messages, previous);
16231624 if (entries !== null) return { messages, proposedPlans, workEntries, entries };
16241625 }
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);
16251633 const canAppend =
16261634 previous !== null &&
1635 !previous.entries.some((entry) => entry.kind === "message" && !showMessage(entry.message)) &&
16271636 hasExactArrayPrefix(previous.messages, messages) &&
16281637 hasExactArrayPrefix(previous.proposedPlans, proposedPlans) &&
16291638 hasExactArrayPrefix(previous.workEntries, workEntries);
16301639
16311640 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);
16331645 const proposedPlanRows = proposedPlans
16341646 .slice(previous.proposedPlans.length)
16351647 .map(timelineEntryFromProposedPlan);
......@@ -1645,7 +1657,7 @@ export function deriveTimelineEntriesWithState(
16451657 };
16461658 }
16471659
1648 const messageRows = messages.map(timelineEntryFromMessage);
1660 const messageRows = messages.filter(showMessage).map(timelineEntryFromMessage);
16491661 const proposedPlanRows = proposedPlans.map(timelineEntryFromProposedPlan);
16501662 const workRows = workEntries.map(timelineEntryFromWork);
16511663 return {
packages/client-runtime/package.json+4
......@@ -223,6 +223,10 @@
223223 "types": "./src/state/vcs.ts",
224224 "default": "./src/state/vcs.ts"
225225 },
226 "./work-log/user-input": {
227 "types": "./src/work-log/userInput.ts",
228 "default": "./src/work-log/userInput.ts"
229 },
226230 "./work-log/presentation": {
227231 "types": "./src/work-log/presentation.ts",
228232 "default": "./src/work-log/presentation.ts"
packages/client-runtime/src/work-log/userInput.ts created+186
......@@ -0,0 +1,186 @@
1import { projectQuestionToolInput } from "@t3tools/shared/toolActivity";
2import {
3 type OrchestrationThreadActivity,
4 UserInputAttachmentAnswerPayload,
5} from "@t3tools/contracts";
6import * as Schema from "effect/Schema";
7
8function 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
14const isQuestionAnswer = Schema.is(UserInputAttachmentAnswerPayload);
15
16function 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
25function 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
35function 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. */
76export 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
158export 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
165export 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
181export 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(
259259 summary: title ?? fallbackSummary,
260260 };
261261}
262
263export 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}