History

feat(chat): attach files to question answers (#9871)

shivam committed 2 months ago

Commit 7220dfe1 parent 349ce3040 files · +2315 −153Browse files

Unified · Split

40 files
  1. apps/mobile/package.json +1 −0
  2. apps/mobile/src/features/threads/PendingUserInputCard.tsx +13 −13
  3. apps/mobile/src/features/threads/QuestionAnswerHistory.test.tsx +58 −0
  4. apps/mobile/src/features/threads/QuestionAnswerHistory.tsx +71 −0
  5. apps/mobile/src/features/threads/QuestionAttachments.tsx +183 −0
  6. apps/mobile/src/features/threads/thread-work-log.tsx +8 −1
  7. apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts +26 −1
  8. apps/mobile/src/lib/threadActivity.test.ts +70 −0
  9. apps/mobile/src/lib/threadActivity.ts +24 −3
  10. apps/mobile/src/state/question-attachments.ts +29 −0
  11. apps/mobile/src/state/use-composer-drafts.ts +8 −2
  12. apps/mobile/src/state/use-selected-thread-requests.test.tsx +140 −0
  13. apps/mobile/src/state/use-selected-thread-requests.ts +138 −4
  14. apps/server/src/environment/ServerEnvironment.ts +1 −0
  15. apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +13 −1
  16. apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +109 −7
  17. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +89 −31
  18. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +3 −0
  19. apps/server/src/orchestration/Normalizer.attachments.test.ts +202 −1
  20. apps/server/src/orchestration/Normalizer.ts +53 −7
  21. apps/server/src/orchestration/decider.questionAttachments.test.ts +130 −0
  22. apps/server/src/orchestration/decider.ts +76 −8
  23. apps/server/src/provider/Layers/ProviderService.ts +6 −1
  24. apps/server/src/provider/userInputAttachments.test.ts +61 −0
  25. apps/server/src/provider/userInputAttachments.ts +59 −0
  26. apps/web/src/components/ChatView.tsx +189 −22
  27. apps/web/src/components/chat/ChatComposer.tsx +156 −47
  28. apps/web/src/components/chat/MessagesTimeline.test.tsx +76 −1
  29. apps/web/src/components/chat/MessagesTimeline.tsx +70 −1
  30. apps/web/src/lib/attachmentUploadQueue.test.ts +26 −0
  31. apps/web/src/pendingUserInput.test.ts +19 −0
  32. apps/web/src/pendingUserInput.ts +12 −2
  33. apps/web/src/questionAttachments.test.ts +100 −0
  34. apps/web/src/questionAttachments.ts +53 −0
  35. apps/web/src/session-logic.ts +9 −0
  36. docs/user/question-attachments.md +9 −0
  37. packages/contracts/src/environment.ts +2 −0
  38. packages/contracts/src/orchestration.ts +18 −0
  39. packages/contracts/src/provider.ts +2 −0
  40. pnpm-lock.yaml +3 −0
apps/mobile/package.json+1−0
@@ -128,6 +128,7 @@ · 127 unmodified lines
128128 "@effect/vitest": "catalog:",
129129 "@pierre/trees": "1.0.0-beta.4",
130130 "@types/react": "~19.2.0",
131+ "@types/react-dom": "~19.2.3",
131132 "babel-preset-expo": "~57.0.9",
132133 "tailwindcss": "^4.0.0",
133134 "typescript": "catalog:"
apps/mobile/src/features/threads/PendingUserInputCard.tsx+13−13
@@ -1,3 +1,4 @@
1+import { QuestionAttachments } from "./QuestionAttachments";
12 import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts";
23 import { useCallback, useRef } from "react";
34 import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native";
@@ -15,7 +16,7 @@ import Animated, { · 11 unmodified lines
1516 import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout";
1617
1718 import { SymbolView } from "../../components/AppSymbol";
18−import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
19+import { AppText as Text } from "../../components/AppText";
1920 import { ControlPill } from "../../components/ControlPill";
2021 import { cn } from "../../lib/cn";
2122 import {
@@ -305,18 +306,17 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { · 283 unmodified lines
305306 );
306307 })}
307308 </View>
308− {question.allowCustomAnswer !== false ? (
309− <TextInput
310− value={draft?.customAnswer ?? ""}
311− onChangeText={(value) =>
312− props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
313− }
314− onFocus={() => props.onInputFocusChange?.(true)}
315− onBlur={() => props.onInputFocusChange?.(false)}
316− placeholder="Or type a custom answer"
317− className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground"
318− />
319− ) : null}
309+ <QuestionAttachments
310+ requestId={props.pendingUserInput.requestId}
311+ question={question}
312+ questions={props.pendingUserInput.questions}
313+ disabled={props.respondingUserInputId === props.pendingUserInput.requestId}
314+ value={draft?.customAnswer ?? ""}
315+ onChangeText={(value) =>
316+ props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
317+ }
318+ onInputFocusChange={props.onInputFocusChange}
319+ />
320320 </View>
321321 );
322322 })}
apps/mobile/src/features/threads/QuestionAnswerHistory.test.tsx+58−0
@@ -0,0 +1,58 @@
1+import { ApprovalRequestId, EnvironmentId } from "@t3tools/contracts";
2+import type { ReactNode } from "react";
3+import { renderToStaticMarkup } from "react-dom/server";
4+import { describe, expect, it, vi } from "vite-plus/test";
5+
6+vi.mock("react-native", () => ({
7+ View: "div",
8+ Pressable: ({ children }: { children: ReactNode }) => <button>{children}</button>,
9+ Image: "img",
10+ Linking: { openURL: vi.fn() },
11+}));
12+vi.mock("../../components/AppText", () => ({ AppText: "span" }));
13+vi.mock("../../state/assets", () => ({ useAssetUrl: () => null }));
14+
15+import { QuestionAnswerHistory } from "./QuestionAnswerHistory";
16+
17+describe("QuestionAnswerHistory", () => {
18+ it.each([{}, { text: "Text-only answer", file: "Answer with a file" }])(
19+ "renders attachment-only questions alongside text answers: %j",
20+ (answers) => {
21+ const markup = renderToStaticMarkup(
22+ <QuestionAnswerHistory
23+ environmentId={EnvironmentId.make("environment-local")}
24+ answer={{
25+ requestId: ApprovalRequestId.make("question-request"),
26+ answers,
27+ questionTextById: { file: "Provide a spec", image: "Provide a screenshot" },
28+ attachmentsByQuestionId: {
29+ file: [
30+ {
31+ type: "file",
32+ id: "spec",
33+ name: "spec.txt",
34+ mimeType: "text/plain",
35+ sizeBytes: 4,
36+ },
37+ ],
38+ image: [
39+ {
40+ type: "image",
41+ id: "shot",
42+ name: "shot.png",
43+ mimeType: "image/png",
44+ sizeBytes: 4,
45+ },
46+ ],
47+ },
48+ }}
49+ />,
50+ );
51+ expect(markup.match(/Provide a spec/g)).toHaveLength(1);
52+ expect(markup.match(/spec\.txt/g)).toHaveLength(1);
53+ expect(markup).toContain("Provide a screenshot");
54+ expect(markup).toContain("shot.png");
55+ for (const answer of Object.values(answers)) expect(markup).toContain(answer);
56+ },
57+ );
58+});
apps/mobile/src/features/threads/QuestionAnswerHistory.tsx+71−0
@@ -0,0 +1,71 @@
1+import type {
2+ EnvironmentId,
3+ UserInputAttachmentAnswerPayload,
4+ UserInputAttachments,
5+} from "@t3tools/contracts";
6+import { Image, Linking, Pressable, View } from "react-native";
7+import { AppText as Text } from "../../components/AppText";
8+import { useAssetUrl } from "../../state/assets";
9+
10+function AnswerFile(props: {
11+ environmentId: EnvironmentId;
12+ attachment: UserInputAttachments[string][number];
13+}) {
14+ const url = useAssetUrl(props.environmentId, {
15+ _tag: "attachment",
16+ attachmentId: props.attachment.id,
17+ });
18+ return (
19+ <Pressable
20+ accessibilityRole="link"
21+ accessibilityLabel={props.attachment.name}
22+ disabled={!url}
23+ onPress={() => {
24+ if (url) void Linking.openURL(url);
25+ }}
26+ className="gap-1"
27+ >
28+ {props.attachment.type === "image" && url ? (
29+ <Image source={{ uri: url }} style={{ width: 160, height: 96 }} resizeMode="contain" />
30+ ) : null}
31+ <Text className="text-sm text-foreground underline">{props.attachment.name}</Text>
32+ </Pressable>
33+ );
34+}
35+
36+export function QuestionAnswerHistory(props: {
37+ environmentId: EnvironmentId;
38+ answer: UserInputAttachmentAnswerPayload;
39+}) {
40+ return (
41+ <View className="gap-2">
42+ {[
43+ ...new Set([
44+ ...Object.keys(props.answer.answers),
45+ ...Object.keys(props.answer.attachmentsByQuestionId),
46+ ]),
47+ ].map((questionId) => (
48+ <View key={questionId} className="gap-1">
49+ {props.answer.questionTextById?.[questionId] ? (
50+ <Text className="text-sm text-foreground-muted">
51+ {props.answer.questionTextById[questionId]}
52+ </Text>
53+ ) : 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>
60+ {(props.answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => (
61+ <AnswerFile
62+ key={attachment.id}
63+ environmentId={props.environmentId}
64+ attachment={attachment}
65+ />
66+ ))}
67+ </View>
68+ ))}
69+ </View>
70+ );
71+}
apps/mobile/src/features/threads/QuestionAttachments.tsx+183−0
@@ -0,0 +1,183 @@
1+import { TextInputWrapper } from "expo-paste-input";
2+import { AppTextInput as TextInput } from "../../components/AppText";
3+import { useNativePaste } from "../../lib/useNativePaste";
4+import { convertPastedImagesToAttachments } from "../../lib/composerImages";
5+import {
6+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
7+ type ApprovalRequestId,
8+ type UserInputQuestion,
9+} from "@t3tools/contracts";
10+import { useAtomValue } from "@effect/atom-react";
11+import { Alert, View } from "react-native";
12+import { useEffect, useRef } from "react";
13+import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
14+import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
15+import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages";
16+import { useThreadSelection } from "../../state/use-thread-selection";
17+import { useServerConfigs } from "../../state/entities";
18+import { appAtomRegistry } from "../../state/atom-registry";
19+import {
20+ appendComposerDraftAttachments,
21+ composerDraftsAtom,
22+ removeComposerDraftAttachment,
23+ releaseUnusedComposerAttachmentFiles,
24+} from "../../state/use-composer-drafts";
25+import {
26+ changeQuestionAttachmentPreparation,
27+ questionAttachmentDraftKey,
28+ questionAttachmentPreparationAtom,
29+} from "../../state/question-attachments";
30+
31+export function QuestionAttachments(props: {
32+ requestId: ApprovalRequestId;
33+ question: UserInputQuestion;
34+ questions: ReadonlyArray<UserInputQuestion>;
35+ disabled: boolean;
36+ value: string;
37+ onChangeText: (value: string) => void;
38+ onInputFocusChange?: ((focused: boolean) => void) | undefined;
39+}) {
40+ const { selectedThread } = useThreadSelection();
41+ const configs = useServerConfigs();
42+ const drafts = useAtomValue(composerDraftsAtom);
43+ const scopeKey = JSON.stringify([
44+ selectedThread?.environmentId,
45+ selectedThread?.id,
46+ props.requestId,
47+ props.question.id,
48+ ]);
49+ const pickerScope = useRef<{ key: string; active: boolean } | null>(null);
50+ useEffect(() => {
51+ const scope = { key: scopeKey, active: true };
52+ pickerScope.current = scope;
53+ return () => {
54+ scope.active = false;
55+ };
56+ }, [scopeKey]);
57+ const append = (
58+ key: string,
59+ attachments: Parameters<typeof appendComposerDraftAttachments>[1],
60+ ) => {
61+ if (!selectedThread) return 0;
62+ const current = appAtomRegistry.get(composerDraftsAtom);
63+ const otherCount = props.questions.reduce((count, question) => {
64+ const target = questionAttachmentDraftKey(
65+ selectedThread.environmentId,
66+ selectedThread.id,
67+ props.requestId,
68+ question.id,
69+ );
70+ return target === key ? count : count + (current[target]?.attachments.length ?? 0);
71+ }, 0);
72+ return appendComposerDraftAttachments(key, attachments, {
73+ maxAttachments: Math.max(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - otherCount),
74+ });
75+ };
76+ const paste = useNativePaste((uris) => {
77+ const scope = pickerScope.current;
78+ if (
79+ !selectedThread ||
80+ props.disabled ||
81+ !configs.get(selectedThread.environmentId)?.environment.capabilities.questionAttachments
82+ )
83+ return;
84+ const key = questionAttachmentDraftKey(
85+ selectedThread.environmentId,
86+ selectedThread.id,
87+ props.requestId,
88+ props.question.id,
89+ );
90+ changeQuestionAttachmentPreparation(key, 1);
91+ void convertPastedImagesToAttachments({
92+ uris,
93+ existingCount: appAtomRegistry.get(composerDraftsAtom)[key]?.attachments.length ?? 0,
94+ })
95+ .then(async (images) => {
96+ if (
97+ scope?.active &&
98+ (appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) > 0
99+ ) {
100+ if (append(key, images) > 0)
101+ Alert.alert("Could not paste image", "Too many attachments.");
102+ } else await releaseUnusedComposerAttachmentFiles(images);
103+ })
104+ .catch((error) =>
105+ Alert.alert("Could not paste image", error instanceof Error ? error.message : "Try again."),
106+ )
107+ .finally(() => changeQuestionAttachmentPreparation(key, -1));
108+ });
109+ if (!selectedThread || props.question.allowCustomAnswer === false) return null;
110+ const { environmentId, id: threadId } = selectedThread;
111+ const capabilities = configs.get(environmentId)?.environment.capabilities;
112+ const canAttach = capabilities?.questionAttachments === true;
113+ const key = questionAttachmentDraftKey(
114+ environmentId,
115+ threadId,
116+ props.requestId,
117+ props.question.id,
118+ );
119+ const attachments = drafts[key]?.attachments ?? [];
120+ const pick = async (kind: "media" | "files") => {
121+ const scope = pickerScope.current;
122+ changeQuestionAttachmentPreparation(key, 1);
123+ try {
124+ const existingCount = appAtomRegistry.get(composerDraftsAtom)[key]?.attachments.length ?? 0;
125+ const result =
126+ kind === "files"
127+ ? await pickComposerFiles({
128+ existingCount,
129+ maxBytes: capabilities?.fileAttachments?.maxUploadBytes,
130+ })
131+ : await pickComposerMedia({
132+ existingCount,
133+ maxVideoBytes: capabilities?.fileAttachments?.maxUploadBytes,
134+ });
135+ const picked = "files" in result ? result.files : result.attachments;
136+ // Resolution on another client clears the reservation while the picker is open.
137+ if (
138+ !scope?.active ||
139+ (appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) === 0
140+ ) {
141+ await releaseUnusedComposerAttachmentFiles(picked);
142+ return;
143+ }
144+ const rejected = append(key, picked);
145+ if (result.error || rejected > 0)
146+ Alert.alert("Could not attach file", result.error ?? "Too many attachments.");
147+ } catch (error) {
148+ Alert.alert("Could not attach file", error instanceof Error ? error.message : "Try again.");
149+ } finally {
150+ changeQuestionAttachmentPreparation(key, -1);
151+ }
152+ };
153+ return (
154+ <View className="gap-2">
155+ {canAttach ? (
156+ <ComposerAttachmentButton
157+ disabled={props.disabled}
158+ supportsFiles={Boolean(capabilities?.fileAttachments)}
159+ onPickMedia={() => pick("media")}
160+ onPickFiles={() => pick("files")}
161+ />
162+ ) : null}
163+ <ComposerAttachmentStrip
164+ environmentId={environmentId}
165+ attachments={attachments}
166+ onRemove={(id) => {
167+ if (!props.disabled) removeComposerDraftAttachment(key, id);
168+ }}
169+ />
170+ <TextInputWrapper onPaste={paste}>
171+ <TextInput
172+ value={props.value}
173+ editable={!props.disabled}
174+ onChangeText={props.onChangeText}
175+ onFocus={() => props.onInputFocusChange?.(true)}
176+ onBlur={() => props.onInputFocusChange?.(false)}
177+ placeholder="Or type a custom answer"
178+ className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground"
179+ />
180+ </TextInputWrapper>
181+ </View>
182+ );
183+}
apps/mobile/src/features/threads/thread-work-log.tsx+8−1
@@ -1,3 +1,4 @@
1+import { QuestionAnswerHistory } from "./QuestionAnswerHistory";
12 import * as Haptics from "expo-haptics";
23 import { Image } from "expo-image";
34 import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
@@ -849,13 +850,19 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( · 845 unmodified lines
849850 </View>
850851 </Pressable>
851852
852− {expanded && (fullDetail || viewedImagePath) ? (
853+ {expanded && (fullDetail || viewedImagePath || row.workEntry.questionAnswer) ? (
853854 <Animated.View
854855 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
855856 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
856857 layout={WORK_LOG_LAYOUT_TRANSITION}
857858 className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5"
858859 >
860+ {row.workEntry.questionAnswer ? (
861+ <QuestionAnswerHistory
862+ environmentId={props.environmentId}
863+ answer={row.workEntry.questionAnswer}
864+ />
865+ ) : null}
859866 {viewedImagePath ? (
860867 <View className="pb-1.5">
861868 {props.renderImage({ href: viewedImagePath, alt: null, title: null })}
apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts+26−1
@@ -1,5 +1,10 @@
1−import { EnvironmentId } from "@t3tools/contracts";
1+import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
22 import { describe, expect, it, vi } from "vite-plus/test";
3+import {
4+ questionAttachmentDraftKey,
5+ questionAttachmentDraftPrefix,
6+} from "../state/question-attachments";
7+vi.mock("../state/atom-registry", () => ({ appAtomRegistry: {} }));
38
49 import {
510 composerAttachmentUploadBlockReason,
@@ -217,6 +222,26 @@ describe("composer attachment upload queue", () => { · 211 unmodified lines
217222 });
218223
219224 describe("draft upload scope and offline submission", () => {
225+ it("uploads question drafts in their environment without matching other thread prefixes", () => {
226+ const requestId = ApprovalRequestId.make("request:1");
227+ for (const environment of [environmentId, EnvironmentId.make("remote:server")]) {
228+ const threadId = ThreadId.make("thread:1");
229+ const prefix = questionAttachmentDraftPrefix(environment, threadId);
230+ const key = questionAttachmentDraftKey(environment, threadId, requestId, "question:1");
231+ expect(composerDraftEnvironmentId(key, [])).toBe(environment);
232+ expect(key.startsWith(prefix)).toBe(true);
233+ for (const suffix of ["-extra", ":extra", "/extra", "%extra"]) {
234+ const otherKey = questionAttachmentDraftKey(
235+ environment,
236+ ThreadId.make(`${threadId}${suffix}`),
237+ requestId,
238+ "question:1",
239+ );
240+ expect(otherKey.startsWith(prefix)).toBe(false);
241+ expect(composerDraftEnvironmentId(otherKey, [])).toBe(environment);
242+ }
243+ }
244+ });
220245 it("resolves thread, new-task, and queued-task drafts without crossing environments", () => {
221246 expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId);
222247 expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId);
apps/mobile/src/lib/threadActivity.test.ts+70−0
@@ -2,6 +2,7 @@ import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests" · 1 unmodified line
22 import { describe, expect, it } from "vite-plus/test";
33
44 import {
5+ ApprovalRequestId,
56 EventId,
67 MessageId,
78 ProjectId,
@@ -3314,3 +3315,72 @@ describe("quiet timeline: nested agents", () => { · 3306 unmodified lines
33143315 ]);
33153316 });
33163317 });
3318+
3319+it("accepts ready attachment-only answers while preserving selected options", () => {
3320+ const question = {
3321+ id: "q",
3322+ header: "Spec",
3323+ question: "Provide a specification",
3324+ options: [{ label: "Yes", description: "Approve" }],
3325+ multiSelect: false,
3326+ };
3327+ expect(buildPendingUserInputAnswers([question], { q: { attachmentCount: 1 } })).toEqual({
3328+ q: "",
3329+ });
3330+ expect(
3331+ buildPendingUserInputAnswers([question], {
3332+ q: { attachmentCount: 1, selectedOptionValues: ["Yes"] },
3333+ }),
3334+ ).toEqual({ q: "Yes" });
3335+ expect(
3336+ buildPendingUserInputAnswers([question], {
3337+ q: { attachmentCount: 1, attachmentsBlocked: true },
3338+ }),
3339+ ).toBeNull();
3340+ expect(
3341+ buildPendingUserInputAnswers([{ ...question, allowCustomAnswer: false }], {
3342+ q: { attachmentCount: 1 },
3343+ }),
3344+ ).toBeNull();
3345+});
3346+
3347+it("makes attachment-only question answers expandable in the mobile feed", () => {
3348+ const answer = {
3349+ requestId: ApprovalRequestId.make("question-request"),
3350+ answers: { q: "" },
3351+ questionTextById: { q: "Attach the specification" },
3352+ attachmentsByQuestionId: {
3353+ q: [
3354+ {
3355+ type: "file" as const,
3356+ id: "question-file",
3357+ name: "spec.txt",
3358+ mimeType: "text/plain",
3359+ sizeBytes: 4,
3360+ },
3361+ ],
3362+ },
3363+ };
3364+ const thread = makeThread({
3365+ id: ThreadId.make("thread-answer"),
3366+ projectId: ProjectId.make("project-answer"),
3367+ title: "Answer history",
3368+ activities: [
3369+ makeActivity({
3370+ id: EventId.make("answer-submitted"),
3371+ createdAt: "2026-09-08T00:00:00.000Z",
3372+ kind: "user-input.answer-submitted",
3373+ summary: "Answered questions",
3374+ payload: answer,
3375+ }),
3376+ ],
3377+ });
3378+ const [group] = buildThreadFeed(thread);
3379+ expect(group?.type).toBe("activity-group");
3380+ if (group?.type !== "activity-group") return;
3381+ expect(group.activities[0]).toMatchObject({
3382+ canExpand: true,
3383+ workEntry: { questionAnswer: answer },
3384+ });
3385+ expect(group.activities[0]?.getFullDetail()).toBeNull();
3386+});
apps/mobile/src/lib/threadActivity.ts+24−3
@@ -1,8 +1,10 @@
1+import * as Option from "effect/Option";
2+import * as Schema from "effect/Schema";
13 import {
24 requestKindFromRequestType,
35 type PendingApproval,
46 } from "@t3tools/client-runtime/pending-requests";
5−import { isToolLifecycleItemType } from "@t3tools/contracts";
7+import { UserInputAttachmentAnswerPayload, isToolLifecycleItemType } from "@t3tools/contracts";
68 import type {
79 OrchestrationLatestTurn,
810 OrchestrationThread,
@@ -41,6 +43,8 @@ export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/ · 32 unmodified lines
4143 export interface PendingUserInputDraftAnswer {
4244 readonly selectedOptionValues?: ReadonlyArray<string>;
4345 readonly customAnswer?: string;
46+ readonly attachmentCount?: number;
47+ readonly attachmentsBlocked?: boolean;
4448 }
4549
4650 export interface ThreadFeedActivity {
@@ -76,6 +80,7 @@ export interface ThreadFeedActivity { · 29 unmodified lines
7680 }
7781
7882 export interface WorkLogEntry {
83+ readonly questionAnswer?: UserInputAttachmentAnswerPayload;
7984 id: string;
8085 createdAt: string;
8186 turnId: TurnId | null;
@@ -302,6 +307,7 @@ function resolvePendingUserInputAnswer( · 220 unmodified lines
302307 question: UserInputQuestion,
303308 draft: PendingUserInputDraftAnswer | undefined,
304309 ): string | ReadonlyArray<string> | null {
310+ if (draft?.attachmentsBlocked) return null;
305311 const customAnswer =
306312 question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);
307313 if (customAnswer) {
@@ -310,9 +316,16 @@ function resolvePendingUserInputAnswer( · 2 unmodified lines
310316
311317 const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
312318 if (question.multiSelect) {
313− return selectedOptionValues.length > 0 ? selectedOptionValues : null;
319+ return selectedOptionValues.length > 0
320+ ? selectedOptionValues
321+ : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0
322+ ? ""
323+ : null;
314324 }
315− return selectedOptionValues[0] ?? null;
325+ return (
326+ selectedOptionValues[0] ??
327+ (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)
328+ );
316329 }
317330
318331 /** Some providers settle agents through task.updated instead of task.completed. */
@@ -435,6 +448,8 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool · 116 unmodified lines
435448 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
436449 }
437450
451+const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload);
452+
438453 function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
439454 const payload =
440455 activity.payload && typeof activity.payload === "object"
@@ -480,6 +495,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo · 39 unmodified lines
480495 ? "info"
481496 : activity.tone,
482497 sourceActivityKind: activity.kind,
498+ ...(() => {
499+ if (activity.kind !== "user-input.answer-submitted") return {};
500+ const answer = decodeQuestionAttachmentAnswer(activity.payload);
501+ return Option.isSome(answer) ? { questionAnswer: answer.value } : {};
502+ })(),
483503 };
484504 const toolCallId =
485505 asTrimmedString(payload?.toolCallId) ?? asTrimmedString(asRecord(payload?.data)?.toolCallId);
@@ -969,6 +989,7 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { · 483 unmodified lines
969989 * for every row (see the deferred-expansion test).
970990 */
971991 function workEntryCanExpand(entry: WorkLogEntry): boolean {
992+ if (entry.questionAnswer) return true;
972993 if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0;
973994 if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true;
974995 if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true;
apps/mobile/src/state/question-attachments.ts+29−0
@@ -0,0 +1,29 @@
1+import type { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
2+import { Atom } from "effect/unstable/reactivity";
3+import { appAtomRegistry } from "./atom-registry";
4+
5+export function questionAttachmentDraftPrefix(
6+ environmentId: EnvironmentId,
7+ threadId: ThreadId,
8+): string {
9+ return `${environmentId}:question-${encodeURIComponent(JSON.stringify(threadId))}-`;
10+}
11+export function questionAttachmentDraftKey(
12+ environmentId: EnvironmentId,
13+ threadId: ThreadId,
14+ requestId: ApprovalRequestId,
15+ questionId: string,
16+): string {
17+ return `${questionAttachmentDraftPrefix(environmentId, threadId)}${encodeURIComponent(JSON.stringify([requestId, questionId]))}`;
18+}
19+export const questionAttachmentPreparationAtom = Atom.make<Record<string, number>>({}).pipe(
20+ Atom.keepAlive,
21+);
22+export function changeQuestionAttachmentPreparation(key: string, delta: number): void {
23+ const counts = appAtomRegistry.get(questionAttachmentPreparationAtom);
24+ if (delta < 0 && !(key in counts)) return;
25+ appAtomRegistry.set(questionAttachmentPreparationAtom, {
26+ ...counts,
27+ [key]: Math.max(0, (counts[key] ?? 0) + delta),
28+ });
29+}
apps/mobile/src/state/use-composer-drafts.ts+8−2
@@ -925,7 +925,7 @@ export function appendComposerDraftText(draftKey: string, value: string): void { · 924 unmodified lines
925925 export function appendComposerDraftAttachments(
926926 draftKey: string,
927927 attachments: ReadonlyArray<DraftComposerAttachment>,
928− options?: { readonly allowOverflow?: boolean },
928+ options?: { readonly allowOverflow?: boolean; readonly maxAttachments?: number },
929929 ): number {
930930 if (attachments.length === 0) {
931931 return 0;
@@ -935,7 +935,13 @@ export function appendComposerDraftAttachments( · 3 unmodified lines
935935 const existing = normalizeDraft(current[draftKey]);
936936 const remaining = options?.allowOverflow
937937 ? attachments.length
938− : Math.max(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - existing.attachments.length);
938+ : Math.max(
939+ 0,
940+ Math.min(
941+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
942+ options?.maxAttachments ?? PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
943+ ) - existing.attachments.length,
944+ );
939945 const accepted = attachments.slice(0, remaining);
940946 rejected = attachments.slice(remaining);
941947 if (accepted.length === 0) {
apps/mobile/src/state/use-selected-thread-requests.test.tsx+140−0
@@ -0,0 +1,140 @@
1+import { renderToStaticMarkup } from "react-dom/server";
2+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3+
4+const fixture = vi.hoisted(() => ({
5+ drafts: {} as Record<string, unknown>,
6+ uploads: {} as Record<string, unknown>,
7+ preparations: {} as Record<string, number>,
8+ preparationAtom: Symbol("preparation"),
9+}));
10+vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
11+vi.mock("@effect/atom-react", () => ({
12+ useAtomValue: (atom: unknown) =>
13+ atom === "drafts"
14+ ? fixture.drafts
15+ : atom === "uploads"
16+ ? fixture.uploads
17+ : atom === fixture.preparationAtom
18+ ? fixture.preparations
19+ : {},
20+}));
21+vi.mock("./use-composer-drafts", () => ({
22+ composerDraftsAtom: "drafts",
23+ clearComposerDraft: vi.fn(),
24+}));
25+vi.mock("./composer-attachment-uploads", async () => ({
26+ ...(await import("../lib/composerAttachmentUploadQueue")),
27+ composerAttachmentUploadsAtom: "uploads",
28+}));
29+vi.mock("./question-attachments", async (importOriginal) => ({
30+ ...(await importOriginal<typeof import("./question-attachments")>()),
31+ questionAttachmentPreparationAtom: fixture.preparationAtom,
32+}));
33+vi.mock("./entities", () => ({
34+ useServerConfigs: () =>
35+ new Map([
36+ [
37+ "environment-1",
38+ {
39+ environment: {
40+ capabilities: {
41+ questionAttachments: true,
42+ attachmentUploads: true,
43+ fileAttachments: { maxUploadBytes: 20_000_000 },
44+ },
45+ },
46+ },
47+ ],
48+ ]),
49+}));
50+vi.mock("./threads", () => ({ threadEnvironment: {} }));
51+vi.mock("./use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
52+vi.mock("./use-thread-selection", () => ({
53+ useThreadSelection: () => ({
54+ selectedThread: { environmentId: "environment-1", id: "thread-1" },
55+ }),
56+}));
57+vi.mock("./use-thread-detail", () => ({
58+ useSelectedThreadDetail: () => ({
59+ activities: [
60+ {
61+ id: "request-activity",
62+ kind: "user-input.requested",
63+ createdAt: "2026-09-08T00:00:00Z",
64+ payload: {
65+ requestId: "request-1",
66+ questions: ["first", "second"].map((id) => ({
67+ id,
68+ header: id,
69+ question: `Attach ${id} file`,
70+ options: [],
71+ allowCustomAnswer: true,
72+ })),
73+ },
74+ },
75+ ],
76+ }),
77+}));
78+
79+import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
80+import { questionAttachmentDraftKey } from "./question-attachments";
81+import { useSelectedThreadRequests } from "./use-selected-thread-requests";
82+
83+const environmentId = EnvironmentId.make("environment-1");
84+const key = (question: string) =>
85+ questionAttachmentDraftKey(
86+ environmentId,
87+ ThreadId.make("thread-1"),
88+ ApprovalRequestId.make("request-1"),
89+ question,
90+ );
91+function submitButtonMarkup() {
92+ function Probe() {
93+ const { activePendingUserInputAnswers } = useSelectedThreadRequests();
94+ return <button disabled={activePendingUserInputAnswers === null}>Submit answers</button>;
95+ }
96+ return renderToStaticMarkup(<Probe />);
97+}
98+beforeEach(() => {
99+ fixture.preparations = {};
100+ fixture.drafts = Object.fromEntries(
101+ ["first", "second"].map((id) => [
102+ key(id),
103+ {
104+ attachments: [
105+ {
106+ id,
107+ type: "file",
108+ name: `${id}.txt`,
109+ mimeType: "text/plain",
110+ sizeBytes: 4,
111+ fileUri: `file:///${id}.txt`,
112+ },
113+ ],
114+ },
115+ ]),
116+ );
117+ fixture.uploads = { "environment-1:first": { status: "ready" } };
118+});
119+describe("question attachment submission readiness", () => {
120+ it.each([
121+ undefined,
122+ { status: "uploading", progress: 0.5 },
123+ { status: "failed", reason: "Offline" },
124+ ])("keeps Submit disabled until all question uploads finish: %j", (state) => {
125+ if (state) fixture.uploads["environment-1:second"] = state;
126+ expect(submitButtonMarkup()).toContain("disabled");
127+ fixture.uploads["environment-1:second"] = { status: "ready" };
128+ expect(submitButtonMarkup()).not.toContain("disabled");
129+ });
130+ it("ignores an upload in another environment", () => {
131+ fixture.uploads["environment-1:second"] = { status: "ready" };
132+ fixture.uploads["environment-2:second"] = { status: "uploading", progress: 0.5 };
133+ expect(submitButtonMarkup()).not.toContain("disabled");
134+ });
135+ it("waits for attachment preparation even when uploads are ready", () => {
136+ fixture.uploads["environment-1:second"] = { status: "ready" };
137+ fixture.preparations[key("first")] = 1;
138+ expect(submitButtonMarkup()).toContain("disabled");
139+ });
140+});
apps/mobile/src/state/use-selected-thread-requests.ts+138−4
@@ -1,6 +1,19 @@
11 import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
2+import { useServerConfigs } from "./entities";
3+import { Alert } from "react-native";
4+import {
5+ questionAttachmentDraftKey,
6+ questionAttachmentDraftPrefix,
7+ questionAttachmentPreparationAtom,
8+} from "./question-attachments";
9+import { composerDraftsAtom, clearComposerDraft } from "./use-composer-drafts";
10+import {
11+ composerAttachmentUploadsAtom,
12+ composerAttachmentUploadBlockReason,
13+ composerAttachmentsStillUploading,
14+} from "./composer-attachment-uploads";
215 import { useAtomValue } from "@effect/atom-react";
3−import { useCallback, useMemo, useState } from "react";
16+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
417
518 import {
619 ApprovalRequestId,
@@ -81,6 +94,7 @@ export function useSelectedThreadRequests() { · 74 unmodified lines
8194 const selectedThread = useSelectedThreadDetail();
8295 const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom);
8396 const [respondingApprovalId, setRespondingApprovalId] = useState<ApprovalRequestId | null>(null);
97+ const userInputResponsesInFlight = useRef(new Set<string>());
8498 const [respondingUserInputId, setRespondingUserInputId] = useState<ApprovalRequestId | null>(
8599 null,
86100 );
@@ -91,11 +105,81 @@ export function useSelectedThreadRequests() { · 4 unmodified lines
91105 );
92106 const activePendingApproval = activePendingApprovals[0] ?? null;
93107 const activePendingUserInput = activePendingUserInputs[0] ?? null;
108+ const questionServerConfigs = useServerConfigs();
109+ const attachmentDrafts = useAtomValue(composerDraftsAtom);
110+ const preparationCounts = useAtomValue(questionAttachmentPreparationAtom);
111+ const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
112+ useEffect(() => {
113+ if (!selectedThreadShell || !selectedThread) return;
114+ const prefix = questionAttachmentDraftPrefix(
115+ selectedThreadShell.environmentId,
116+ selectedThreadShell.id,
117+ );
118+ const retained = new Set(
119+ activePendingUserInputs.flatMap((request) =>
120+ request.questions.map((question) =>
121+ questionAttachmentDraftKey(
122+ selectedThreadShell.environmentId,
123+ selectedThreadShell.id,
124+ request.requestId,
125+ question.id,
126+ ),
127+ ),
128+ ),
129+ );
130+ const counts = { ...appAtomRegistry.get(questionAttachmentPreparationAtom) };
131+ let changed = false;
132+ for (const key of new Set([...Object.keys(attachmentDrafts), ...Object.keys(counts)])) {
133+ if (!key.startsWith(prefix) || retained.has(key)) continue;
134+ if (attachmentDrafts[key]) clearComposerDraft(key);
135+ if (key in counts) {
136+ delete counts[key];
137+ changed = true;
138+ }
139+ }
140+ if (changed) appAtomRegistry.set(questionAttachmentPreparationAtom, counts);
141+ }, [activePendingUserInputs, attachmentDrafts, selectedThread, selectedThreadShell]);
94142 const activePendingUserInputDrafts =
95143 activePendingUserInput && selectedThreadShell
96− ? (userInputDraftsByRequestKey[
97− scopedRequestKey(selectedThreadShell.environmentId, activePendingUserInput.requestId)
98− ] ?? {})
144+ ? Object.fromEntries(
145+ activePendingUserInput.questions.map((question) => {
146+ const key = questionAttachmentDraftKey(
147+ selectedThreadShell.environmentId,
148+ selectedThreadShell.id,
149+ activePendingUserInput.requestId,
150+ question.id,
151+ );
152+ const attachments = attachmentDrafts[key]?.attachments ?? [];
153+ const uploadInput = {
154+ environmentId: selectedThreadShell.environmentId,
155+ attachments,
156+ serverConfig: questionServerConfigs.get(selectedThreadShell.environmentId) ?? null,
157+ states: uploadStates,
158+ };
159+ return [
160+ question.id,
161+ {
162+ ...userInputDraftsByRequestKey[
163+ scopedRequestKey(
164+ selectedThreadShell.environmentId,
165+ activePendingUserInput.requestId,
166+ )
167+ ]?.[question.id],
168+ attachmentCount: attachments.length,
169+ attachmentsBlocked:
170+ (attachments.length > 0 &&
171+ questionServerConfigs.get(selectedThreadShell.environmentId)?.environment
172+ .capabilities.questionAttachments !== true) ||
173+ (preparationCounts[key] ?? 0) > 0 ||
174+ composerAttachmentsStillUploading(uploadInput) ||
175+ composerAttachmentUploadBlockReason({
176+ ...uploadInput,
177+ connected: true,
178+ }) !== null,
179+ },
180+ ];
181+ }),
182+ )
99183 : {};
100184 const activePendingUserInputAnswers = activePendingUserInput
101185 ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingUserInputDrafts)
@@ -154,6 +238,52 @@ export function useSelectedThreadRequests() { · 52 unmodified lines
154238 return;
155239 }
156240
241+ const responseKey = questionAttachmentDraftKey(
242+ selectedThreadShell.environmentId,
243+ selectedThreadShell.id,
244+ activePendingUserInput.requestId,
245+ "",
246+ );
247+ if (userInputResponsesInFlight.current.has(responseKey)) return;
248+ const attachmentsByQuestionId = new Map<
249+ string,
250+ import("@t3tools/contracts").UserInputAttachments[string]
251+ >();
252+ for (const question of activePendingUserInput.questions) {
253+ const key = questionAttachmentDraftKey(
254+ selectedThreadShell.environmentId,
255+ selectedThreadShell.id,
256+ activePendingUserInput.requestId,
257+ question.id,
258+ );
259+ if ((appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) > 0) return;
260+ const attachments = appAtomRegistry.get(composerDraftsAtom)[key]?.attachments ?? [];
261+ if (attachments.length === 0) continue;
262+ if (
263+ attachments.some(
264+ (attachment) =>
265+ !attachment.uploadedAttachmentId ||
266+ attachment.uploadEnvironmentId !== selectedThreadShell.environmentId,
267+ )
268+ ) {
269+ Alert.alert(
270+ "Attachments are not ready",
271+ "Wait for uploads to finish, or retry failed uploads.",
272+ );
273+ return;
274+ }
275+ attachmentsByQuestionId.set(
276+ question.id,
277+ attachments.map((attachment) => ({
278+ type: attachment.type,
279+ id: attachment.uploadedAttachmentId!,
280+ name: attachment.name,
281+ mimeType: attachment.mimeType,
282+ sizeBytes: attachment.sizeBytes,
283+ })),
284+ );
285+ }
286+ userInputResponsesInFlight.current.add(responseKey);
157287 setRespondingUserInputId(activePendingUserInput.requestId);
158288 const result = await respondToUserInput({
159289 environmentId: selectedThreadShell.environmentId,
@@ -161,8 +291,12 @@ export function useSelectedThreadRequests() { · 1 unmodified line
161291 threadId: selectedThreadShell.id,
162292 requestId: activePendingUserInput.requestId,
163293 answers: activePendingUserInputAnswers,
294+ ...(attachmentsByQuestionId.size > 0
295+ ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }
296+ : {}),
164297 },
165298 });
299+ userInputResponsesInFlight.current.delete(responseKey);
166300 setRespondingUserInputId((current) =>
167301 current === activePendingUserInput.requestId ? null : current,
168302 );
apps/server/src/environment/ServerEnvironment.ts+1−0
@@ -215,6 +215,7 @@ export const make = Effect.gen(function* () { · 214 unmodified lines
215215 repositoryIdentity: true,
216216 connectionProbe: true,
217217 attachmentUploads: true,
218+ questionAttachments: true,
218219 fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
219220 pullRequests: true,
220221 threadSettlement: true,
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+13−1
@@ -239,6 +239,17 @@ describe("OrchestrationEngine", () => { · 238 unmodified lines
239239 threadId,
240240 requestId,
241241 answers: { "0": "pnpm", "1": "Example" },
242+ attachmentsByQuestionId: {
243+ "1": [
244+ {
245+ type: "file" as const,
246+ id: "thread-1-00000000-0000-4000-8000-0000000000aa-txt",
247+ name: "spec.txt",
248+ mimeType: "text/plain",
249+ sizeBytes: 4,
250+ },
251+ ],
252+ },
242253 createdAt: "2026-01-01T00:00:02.000Z",
243254 };
244255 await expect(
@@ -256,8 +267,9 @@ describe("OrchestrationEngine", () => { · 11 unmodified lines
256267 (message) => message.role === "user",
257268 );
258269 expect(userMessages).toHaveLength(1);
270+ expect(userMessages?.[0]?.attachments).toEqual(response.attachmentsByQuestionId["1"]);
259271 expect(userMessages?.[0]?.text).toBe(
260− "Which package manager?\npnpm\n\nWhat should it be named?\nExample",
272+ "Which package manager?\npnpm\n\nWhat should it be named?\nExample\nAttached file: spec.txt (thread-1-00000000-0000-4000-8000-0000000000aa-txt)",
261273 );
262274 expect(
263275 after.threads[0]?.activities.find((activity) => activity.kind === "user-input.resolved")
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+109−7
@@ -411,7 +411,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 410 unmodified lines
411411 FROM projection_state
412412 ORDER BY projector ASC
413413 `;
414− assert.equal(stateRows.length, Object.keys(ORCHESTRATION_PROJECTOR_NAMES).length);
414+ assert.equal(stateRows.length, Object.keys(ORCHESTRATION_PROJECTOR_NAMES).length + 1);
415415 for (const row of stateRows) {
416416 assert.equal(row.lastAppliedSequence, 3);
417417 }
@@ -1164,14 +1164,18 @@ it.layer( · 746 unmodified lines
11641164
11651165 yield* projectionPipeline.bootstrap;
11661166 yield* projectionPipeline.bootstrap;
1167− assert.deepEqual(
1168− yield* projectionState.listAll(),
1169− cursorsBeforeFailure.map((cursor) => ({
1167+ assert.deepEqual(yield* projectionState.listAll(), [
1168+ {
1169+ projector: "projection.attachment-cleanup",
1170+ lastAppliedSequence: pendingEvent.sequence,
1171+ updatedAt: pendingEvent.occurredAt,
1172+ },
1173+ ...cursorsBeforeFailure.map((cursor) => ({
11701174 ...cursor,
11711175 lastAppliedSequence: pendingEvent.sequence,
11721176 updatedAt: pendingEvent.occurredAt,
11731177 })),
1174− );
1178+ ]);
11751179 const replayedMessages = yield* sql<{ readonly text: string }>`
11761180 SELECT text FROM projection_thread_messages WHERE message_id = 'message-rollback'
11771181 `;
@@ -1364,10 +1368,53 @@ it.layer( · 186 unmodified lines
13641368 },
13651369 });
13661370
1371+ const answerKeepId = "thread-revert-files-00000000-0000-4000-8000-000000000006-txt";
1372+ const answerRemoveId = "thread-revert-files-00000000-0000-4000-8000-000000000007-txt";
1373+ for (const [id, turnId] of [
1374+ [answerKeepId, "turn-keep"],
1375+ [answerRemoveId, "turn-remove"],
1376+ ] as const) {
1377+ yield* appendAndProject({
1378+ type: "thread.activity-appended",
1379+ eventId: EventId.make(`answer-${id}`),
1380+ aggregateKind: "thread",
1381+ aggregateId: threadId,
1382+ occurredAt: now,
1383+ commandId: CommandId.make(`answer-${id}`),
1384+ causationEventId: null,
1385+ correlationId: CorrelationId.make(`answer-${id}`),
1386+ metadata: {},
1387+ payload: {
1388+ threadId,
1389+ activity: {
1390+ id: EventId.make(`answer-${id}`),
1391+ kind: "user-input.answer-submitted",
1392+ tone: "info",
1393+ summary: "Answer with file",
1394+ createdAt: now,
1395+ turnId: TurnId.make(turnId),
1396+ payload: {
1397+ requestId: ApprovalRequestId.make(id),
1398+ answers: { q: "See file" },
1399+ attachmentsByQuestionId: {
1400+ q: [
1401+ { type: "file", id, name: "answer.txt", mimeType: "text/plain", sizeBytes: 6 },
1402+ ],
1403+ },
1404+ },
1405+ },
1406+ },
1407+ });
1408+ }
13671409 const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`);
13681410 const keepFilePath = path.join(attachmentsDir, `${keepFileAttachmentId}.pdf`);
13691411 const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`);
13701412 yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });
1413+ yield* fileSystem.writeFileString(path.join(attachmentsDir, `${answerKeepId}.txt`), "answer");
1414+ yield* fileSystem.writeFileString(
1415+ path.join(attachmentsDir, `${answerRemoveId}.txt`),
1416+ "answer",
1417+ );
13711418 yield* fileSystem.writeFileString(keepPath, "keep");
13721419 yield* fileSystem.writeFileString(keepFilePath, "keep");
13731420 yield* fileSystem.writeFileString(removePath, "remove");
@@ -1460,9 +1507,33 @@ it.layer( · 86 unmodified lines
14601507
14611508 assert.isTrue(yield* exists(keepPath));
14621509 assert.isTrue(yield* exists(keepFilePath));
1510+ assert.isTrue(yield* exists(path.join(attachmentsDir, `${answerKeepId}.txt`)));
1511+ assert.isFalse(yield* exists(path.join(attachmentsDir, `${answerRemoveId}.txt`)));
14631512 assert.isFalse(yield* exists(removePath));
14641513 assert.isTrue(yield* exists(laterPath));
14651514 assert.isTrue(yield* exists(otherThreadPath));
1515+
1516+ // Replay message and activity history from different cursors, as during a projection rebuild.
1517+ yield* sql`DELETE FROM projection_thread_messages WHERE thread_id = ${threadId}`;
1518+ yield* sql`DELETE FROM projection_thread_activities WHERE thread_id = ${threadId}`;
1519+ yield* sql`UPDATE projection_state SET last_applied_sequence = 0
1520+ WHERE projector IN ('projection.thread-messages', 'projection.thread-activities', 'projection.threads')`;
1521+ yield* fileSystem.writeFileString(removePath, "remove");
1522+ yield* fileSystem.writeFileString(
1523+ path.join(attachmentsDir, `${answerRemoveId}.txt`),
1524+ "answer",
1525+ );
1526+ yield* sql`CREATE TRIGGER fail_bootstrap_thread BEFORE UPDATE ON projection_threads
1527+ BEGIN SELECT RAISE(FAIL, 'forced bootstrap failure'); END`;
1528+ yield* projectionPipeline.bootstrap.pipe(Effect.flip);
1529+ assert.isTrue(yield* exists(removePath));
1530+ yield* sql`DROP TRIGGER fail_bootstrap_thread`;
1531+ yield* projectionPipeline.bootstrap;
1532+ assert.isTrue(yield* exists(keepPath));
1533+ assert.isTrue(yield* exists(laterPath));
1534+ assert.isTrue(yield* exists(path.join(attachmentsDir, `${answerKeepId}.txt`)));
1535+ assert.isFalse(yield* exists(removePath));
1536+ assert.isFalse(yield* exists(path.join(attachmentsDir, `${answerRemoveId}.txt`)));
14661537 }),
14671538 );
14681539 });
@@ -1934,6 +2005,15 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 465 unmodified lines
19342005 },
19352006 });
19362007
2008+ yield* sql`CREATE TRIGGER fail_later_stream_projector BEFORE UPDATE ON projection_state
2009+ WHEN NEW.projector = 'projection.threads'
2010+ BEGIN SELECT RAISE(FAIL, 'forced later projector failure'); END`;
2011+ yield* projectionPipeline.bootstrap.pipe(Effect.flip);
2012+ const committedMessage = yield* sql<{ readonly text: string }>`
2013+ SELECT text FROM projection_thread_messages WHERE message_id = 'message-a'
2014+ `;
2015+ assert.deepEqual(committedMessage, [{ text: "hello world" }]);
2016+ yield* sql`DROP TRIGGER fail_later_stream_projector`;
19372017 yield* projectionPipeline.bootstrap;
19382018 yield* projectionPipeline.bootstrap;
19392019
@@ -3884,7 +3964,7 @@ const engineLayer = it.layer( · 1944 unmodified lines
38843964 Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
38853965 Layer.provide(ThreadBackgroundLiveness.layer),
38863966 Layer.provide(ThreadPlanProgress.layer),
3887− Layer.provide(OrchestrationProjectionPipelineLive),
3967+ Layer.provideMerge(OrchestrationProjectionPipelineLive),
38883968 Layer.provide(OrchestrationEventStoreLive),
38893969 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
38903970 Layer.provide(RepositoryIdentityResolver.layer),
@@ -4236,7 +4316,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { · 345 unmodified lines
42364316 yield* readCursors,
42374317 cursorsBeforeFailure.map((cursor) => ({
42384318 ...cursor,
4239− lastAppliedSequence: result.sequence,
4319+ lastAppliedSequence:
4320+ cursor.projector === "projection.attachment-cleanup"
4321+ ? cursor.lastAppliedSequence
4322+ : result.sequence,
42404323 })),
42414324 );
42424325 assert.isFalse(yield* exists(attachmentPath));
@@ -4266,6 +4349,25 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { · 23 unmodified lines
42664349 WHERE command_id = ${cleanupFailureCommandId}
42674350 `;
42684351 assert.deepEqual(cleanupFailureReceipts, [{ status: "accepted" }]);
4352+
4353+ const pipeline = yield* OrchestrationProjectionPipeline;
4354+ const cleanupCursor = sql<{ readonly lastAppliedSequence: number }>`
4355+ SELECT last_applied_sequence AS "lastAppliedSequence" FROM projection_state
4356+ WHERE projector = 'projection.attachment-cleanup'
4357+ `;
4358+ const cursorBeforeRetry = yield* cleanupCursor;
4359+ yield* pipeline.bootstrap;
4360+ assert.deepEqual(yield* cleanupCursor, cursorBeforeRetry);
4361+ assert.isTrue(yield* exists(blockedAttachmentPath));
4362+
4363+ yield* fileSystem.remove(blockedAttachmentPath, { recursive: true });
4364+ yield* fileSystem.writeFileString(blockedAttachmentPath, "retry this attachment");
4365+ yield* pipeline.bootstrap;
4366+ assert.isFalse(yield* exists(blockedAttachmentPath));
4367+ assert.isAbove(
4368+ (yield* cleanupCursor)[0]!.lastAppliedSequence,
4369+ cursorBeforeRetry[0]!.lastAppliedSequence,
4370+ );
42694371 }),
42704372 );
42714373 });
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+89−31
@@ -1,6 +1,7 @@
11 import {
22 ApprovalRequestId,
33 isImportedAgentSessionMessageId,
4+ UserInputAttachmentAnswerPayload,
45 type ChatAttachment,
56 type OrchestrationEvent,
67 type OrchestrationSessionStatus,
@@ -12,6 +13,7 @@ import * as FileSystem from "effect/FileSystem"; · 5 unmodified lines
1213 import * as Layer from "effect/Layer";
1314 import * as Option from "effect/Option";
1415 import * as Path from "effect/Path";
16+import * as Schema from "effect/Schema";
1517 import * as Stream from "effect/Stream";
1618 import * as SqlClient from "effect/unstable/sql/SqlClient";
1719
@@ -327,6 +329,8 @@ function retainProjectionProposedPlansAfterRevert( · 309 unmodified lines
327329 );
328330 }
329331
332+const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload);
333+
330334 function collectThreadAttachmentRelativePaths(
331335 threadId: string,
332336 messages: ReadonlyArray<ProjectionThreadMessage>,
@@ -359,9 +363,9 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* · 26 unmodified lines
359363 const path = yield* Effect.service(Path.Path);
360364
361365 const attachmentsRootDir = serverConfig.attachmentsDir;
362− const readAttachmentRootEntries = fileSystem
363− .readDirectory(attachmentsRootDir, { recursive: false })
364− .pipe(Effect.orElseSucceed(() => [] as Array<string>));
366+ const readAttachmentRootEntries = fileSystem.readDirectory(attachmentsRootDir, {
367+ recursive: false,
368+ });
365369
366370 const removeDeletedThreadAttachmentEntry = Effect.fn("removeDeletedThreadAttachmentEntry")(
367371 function* (threadSegment: string, entry: string) {
@@ -423,7 +427,7 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* · 55 unmodified lines
423427 }
424428
425429 const absolutePath = path.join(attachmentsRootDir, relativePath);
426− const fileInfo = yield* fileSystem.stat(absolutePath).pipe(Effect.orElseSucceed(() => null));
430+ const fileInfo = yield* fileSystem.stat(absolutePath);
427431 if (!fileInfo || fileInfo.type !== "File") {
428432 return;
429433 }
@@ -1156,7 +1160,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 726 unmodified lines
11561160
11571161 const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = Effect.fn(
11581162 "applyThreadActivitiesProjection",
1159− )(function* (event, _attachmentSideEffects) {
1163+ )(function* (event, attachmentSideEffects) {
11601164 switch (event.type) {
11611165 case "thread.created":
11621166 yield* projectionThreadActivityRepository.deleteByThreadId({
@@ -1204,6 +1208,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 41 unmodified lines
12041208 yield* Effect.forEach(keptRows, projectionThreadActivityRepository.upsert, {
12051209 concurrency: 1,
12061210 }).pipe(Effect.asVoid);
1211+ attachmentSideEffects.prunedThreadRelativePaths.set(event.payload.threadId, new Set());
12071212 return;
12081213 }
12091214
@@ -1866,10 +1871,20 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 656 unmodified lines
18661871 const messages = yield* projectionThreadMessageRepository.listByThreadId({
18671872 threadId: ThreadId.make(threadId),
18681873 });
1869− prunedThreadRelativePaths.set(
1870− threadId,
1871− collectThreadAttachmentRelativePaths(threadId, messages),
1872− );
1874+ const retainedPaths = collectThreadAttachmentRelativePaths(threadId, messages);
1875+ const activities = yield* projectionThreadActivityRepository.listByThreadId({
1876+ threadId: ThreadId.make(threadId),
1877+ });
1878+ for (const activity of activities) {
1879+ if (activity.kind !== "user-input.answer-submitted") continue;
1880+ const payload = decodeQuestionAttachmentAnswer(activity.payload);
1881+ if (Option.isNone(payload)) continue;
1882+ for (const attachment of Object.values(payload.value.attachmentsByQuestionId).flat()) {
1883+ const relativePath = attachmentRelativePath(attachment);
1884+ if (relativePath) retainedPaths.add(relativePath);
1885+ }
1886+ }
1887+ prunedThreadRelativePaths.set(threadId, retainedPaths);
18731888 }
18741889
18751890 yield* runAttachmentSideEffects({ deletedThreadIds, prunedThreadRelativePaths });
@@ -1879,29 +1894,17 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 3 unmodified lines
18791894 Effect.provideService(ServerConfig, serverConfig),
18801895 (effect, event) =>
18811896 effect.pipe(
1897+ Effect.as(true),
18821898 Effect.catch((cause) =>
18831899 Effect.logWarning("failed to apply projected attachment side-effects", {
18841900 sequence: event.sequence,
18851901 eventType: event.type,
18861902 cause,
1887− }),
1903+ }).pipe(Effect.as(false)),
18881904 ),
18891905 ),
18901906 );
18911907
1892− const applyProjectorForEvent = Effect.fn("applyProjectorForEvent")(function* (
1893− projector: ProjectorDefinition,
1894− event: OrchestrationEvent,
1895− attachmentSideEffects: AttachmentSideEffects,
1896− ) {
1897− yield* projector.apply(event, attachmentSideEffects);
1898− yield* projectionStateRepository.upsert({
1899− projector: projector.name,
1900− lastAppliedSequence: event.sequence,
1901− updatedAt: event.occurredAt,
1902− });
1903− });
1904−
19051908 const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* (
19061909 projector: ProjectorDefinition,
19071910 event: OrchestrationEvent,
@@ -1911,8 +1914,16 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 3 unmodified lines
19111914 prunedThreadRelativePaths: new Map<string, Set<string>>(),
19121915 };
19131916
1914− yield* sql.withTransaction(applyProjectorForEvent(projector, event, attachmentSideEffects));
1915− yield* applyAttachmentSideEffects(event, attachmentSideEffects);
1917+ yield* sql.withTransaction(
1918+ Effect.gen(function* () {
1919+ yield* projector.apply(event, attachmentSideEffects);
1920+ yield* projectionStateRepository.upsert({
1921+ projector: projector.name,
1922+ lastAppliedSequence: event.sequence,
1923+ updatedAt: event.occurredAt,
1924+ });
1925+ }),
1926+ );
19161927 });
19171928
19181929 const bootstrapProjector = (projector: ProjectorDefinition) =>
@@ -1958,7 +1969,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 39 unmodified lines
19581969 );
19591970 // Return the cleanup effect so the caller runs it after the outer transaction commits.
19601971 // @effect-diagnostics-next-line returnEffectInGen:off
1961− return applyAttachmentSideEffects(event, attachmentSideEffects);
1972+ return applyAttachmentSideEffects(event, attachmentSideEffects).pipe(Effect.asVoid);
19621973 },
19631974 Effect.provideService(FileSystem.FileSystem, fileSystem),
19641975 Effect.provideService(Path.Path, path),
@@ -1975,11 +1986,58 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 10 unmodified lines
19751986 yield* cleanup;
19761987 });
19771988
1978− const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach(
1979− projectors,
1980− bootstrapProjector,
1981− { concurrency: 1 },
1982− ).pipe(
1989+ const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.gen(function* () {
1990+ const cleanupProjector = "projection.attachment-cleanup";
1991+ const states = yield* projectionStateRepository.listAll();
1992+ const byProjector = new Map(states.map((state) => [state.projector, state]));
1993+ const cleanupState = byProjector.get(cleanupProjector);
1994+ const cleanupStart = Math.min(
1995+ cleanupState?.lastAppliedSequence ?? 0,
1996+ ...projectors.map((projector) => byProjector.get(projector.name)?.lastAppliedSequence ?? 0),
1997+ );
1998+ // Persist this boundary before replay: a reset projector can encounter an old
1999+ // revert, then fail after other projectors have committed past that event.
2000+ yield* projectionStateRepository.upsert({
2001+ projector: cleanupProjector,
2002+ lastAppliedSequence: cleanupStart,
2003+ updatedAt: cleanupState?.updatedAt ?? "1970-01-01T00:00:00.000Z",
2004+ });
2005+ yield* Effect.forEach(projectors, bootstrapProjector, { concurrency: 1, discard: true });
2006+
2007+ // Cleanup has its own cursor so retries never have to replay committed text.
2008+ // All message and activity references are current before any files are removed.
2009+ const pendingCleanup = new Map<string, OrchestrationEvent>();
2010+ let lastEvent: OrchestrationEvent | undefined;
2011+ yield* Stream.runForEach(
2012+ eventStore.readFromSequence(cleanupStart, Number.MAX_SAFE_INTEGER),
2013+ (event) =>
2014+ Effect.sync(() => {
2015+ lastEvent = event;
2016+ if (event.type === "thread.reverted" || event.type === "thread.deleted") {
2017+ pendingCleanup.set(`${event.type}:${event.payload.threadId}`, event);
2018+ }
2019+ }),
2020+ );
2021+ for (const event of pendingCleanup.values()) {
2022+ if (event.type !== "thread.reverted" && event.type !== "thread.deleted") continue;
2023+ const threadId = event.payload.threadId;
2024+ const cleaned = yield* applyAttachmentSideEffects(event, {
2025+ deletedThreadIds: new Set(event.type === "thread.deleted" ? [threadId] : []),
2026+ prunedThreadRelativePaths: new Map(
2027+ event.type === "thread.reverted" ? [[threadId, new Set<string>()]] : [],
2028+ ),
2029+ });
2030+ // Leave the cleanup cursor behind this event so the next bootstrap retries it.
2031+ if (!cleaned) return;
2032+ }
2033+ if (lastEvent) {
2034+ yield* projectionStateRepository.upsert({
2035+ projector: cleanupProjector,
2036+ lastAppliedSequence: lastEvent.sequence,
2037+ updatedAt: lastEvent.occurredAt,
2038+ });
2039+ }
2040+ }).pipe(
19832041 Effect.provideService(FileSystem.FileSystem, fileSystem),
19842042 Effect.provideService(Path.Path, path),
19852043 Effect.provideService(ServerConfig, serverConfig),
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+3−0
@@ -1609,6 +1609,9 @@ const make = Effect.gen(function* () { · 1608 unmodified lines
16091609 threadId: event.payload.threadId,
16101610 requestId: event.payload.requestId,
16111611 answers: event.payload.answers,
1612+ ...(event.payload.attachmentsByQuestionId
1613+ ? { attachmentsByQuestionId: event.payload.attachmentsByQuestionId }
1614+ : {}),
16121615 })
16131616 .pipe(
16141617 Effect.catchCause((cause) =>
apps/server/src/orchestration/Normalizer.attachments.test.ts+202−1
@@ -5,13 +5,15 @@ import * as NodePath from "node:path"; · 4 unmodified lines
55 import * as NodeServices from "@effect/platform-node/NodeServices";
66 import { describe, expect, it } from "@effect/vitest";
77 import {
8− type ClientOrchestrationCommand,
8+ ClientOrchestrationCommand,
99 CommandId,
10+ ApprovalRequestId,
1011 MessageId,
1112 ThreadId,
1213 } from "@t3tools/contracts";
1314 import * as Effect from "effect/Effect";
1415 import * as Layer from "effect/Layer";
16+import * as Schema from "effect/Schema";
1517
1618 import * as ServerConfig from "../config.ts";
1719 import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
@@ -23,6 +25,7 @@ const testLayer = Layer.mergeAll( · 5 unmodified lines
2325 ).pipe(Layer.provideMerge(NodeServices.layer));
2426
2527 const attachmentUuid = "00000000-0000-4000-8000-0000000000aa";
28+const isClientCommand = Schema.is(ClientOrchestrationCommand);
2629
2730 function turnStartCommand(input: {
2831 readonly threadId?: string;
@@ -358,3 +361,201 @@ describe("normalizeDispatchCommand attachments", () => { · 329 unmodified lines
358361 }).pipe(Effect.provide(testLayer)),
359362 );
360363 });
364+
365+describe("question attachments", () => {
366+ it.effect("enforces the total response limit and claims duplicate filenames independently", () =>
367+ Effect.gen(function* () {
368+ const config = yield* ServerConfig.ServerConfig;
369+ const id = `pending-${attachmentUuid}-txt`;
370+ NodeFS.writeFileSync(NodePath.join(config.attachmentsDir, `${id}.txt`), "report");
371+ const attachment = {
372+ type: "file" as const,
373+ id,
374+ name: 'notes "final" ü.txt',
375+ mimeType: "text/plain",
376+ sizeBytes: 6,
377+ };
378+ const command: ClientOrchestrationCommand = {
379+ type: "thread.user-input.respond",
380+ commandId: CommandId.make("answer-cap"),
381+ threadId: ThreadId.make("thread-1"),
382+ requestId: ApprovalRequestId.make("request-cap"),
383+ answers: { first: "", second: "" },
384+ createdAt: "2026-08-01T00:00:00.000Z",
385+ attachmentsByQuestionId: {
386+ first: Array.from({ length: 4 }, () => attachment),
387+ second: Array.from({ length: 5 }, () => attachment),
388+ },
389+ };
390+ const failure = yield* normalizeDispatchCommand(command).pipe(Effect.flip);
391+ expect(failure.message).toContain("up to 8");
392+ expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${id}.txt`]);
393+ const accepted = {
394+ ...command,
395+ attachmentsByQuestionId: {
396+ ...command.attachmentsByQuestionId,
397+ second: Array.from({ length: 4 }, () => attachment),
398+ },
399+ };
400+ const normalized = yield* normalizeDispatchCommand(accepted);
401+ if (normalized.type !== "thread.user-input.respond") throw new Error("Wrong command");
402+ const attachments = Object.values(normalized.attachmentsByQuestionId!).flat();
403+ expect(attachments).toHaveLength(8);
404+ expect(new Set(attachments.map((item) => item.id)).size).toBe(8);
405+ for (const item of attachments) {
406+ expect(item.name).toBe(attachment.name);
407+ expect(
408+ NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${item.id}.txt`), "utf8"),
409+ ).toBe("report");
410+ }
411+ yield* cleanupFailedUploadedAttachments(accepted, normalized);
412+ expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${id}.txt`]);
413+ }).pipe(Effect.provide(testLayer)),
414+ );
415+ it("requires uploaded metadata for question images, including pasted images", () => {
416+ expect(
417+ isClientCommand({
418+ type: "thread.user-input.respond",
419+ commandId: "answer",
420+ threadId: "thread-1",
421+ requestId: "request",
422+ answers: { q: "" },
423+ createdAt: "2026-08-01T00:00:00.000Z",
424+ attachmentsByQuestionId: {
425+ q: [
426+ {
427+ type: "image",
428+ name: "image.png",
429+ mimeType: "image/png",
430+ sizeBytes: 6,
431+ dataUrl: "data:image/png;base64,cGl4ZWxz",
432+ },
433+ ],
434+ },
435+ }),
436+ ).toBe(false);
437+ });
438+
439+ it.effect("preserves a __proto__ question key and cleans up its claimed files", () =>
440+ Effect.gen(function* () {
441+ const config = yield* ServerConfig.ServerConfig;
442+ const id = `pending-${attachmentUuid}`;
443+ NodeFS.writeFileSync(NodePath.join(config.attachmentsDir, `${id}.png`), "pixels");
444+ const command: ClientOrchestrationCommand = {
445+ type: "thread.user-input.respond",
446+ commandId: CommandId.make("answer"),
447+ threadId: ThreadId.make("thread-1"),
448+ requestId: ApprovalRequestId.make("request"),
449+ answers: { ["__proto__"]: "" },
450+ createdAt: "2026-08-01T00:00:00.000Z",
451+ attachmentsByQuestionId: {
452+ ["__proto__"]: [
453+ { type: "image", id, name: "image.png", mimeType: "image/png", sizeBytes: 6 },
454+ ],
455+ },
456+ };
457+ const normalized = yield* normalizeDispatchCommand(command);
458+ if (normalized.type !== "thread.user-input.respond") throw new Error("Wrong command");
459+ expect(Object.keys(normalized.attachmentsByQuestionId!)).toEqual(["__proto__"]);
460+ const attachment = normalized.attachmentsByQuestionId!["__proto__"]![0]!;
461+ const claimedPath = NodePath.join(config.attachmentsDir, `${attachment.id}.png`);
462+ expect(NodeFS.existsSync(claimedPath)).toBe(true);
463+ yield* cleanupFailedUploadedAttachments(command, normalized);
464+ expect(NodeFS.existsSync(claimedPath)).toBe(false);
465+ }).pipe(Effect.provide(testLayer)),
466+ );
467+ it.effect(
468+ "claims images and files by question, preserves answers, and cleans up failed dispatches",
469+ () =>
470+ Effect.gen(function* () {
471+ const config = yield* ServerConfig.ServerConfig;
472+ const imageId = `pending-${attachmentUuid}`;
473+ const fileId = `pending-${attachmentUuid}-txt`;
474+ NodeFS.writeFileSync(NodePath.join(config.attachmentsDir, `${imageId}.png`), "pixels");
475+ NodeFS.writeFileSync(NodePath.join(config.attachmentsDir, `${fileId}.txt`), "report");
476+ const command: ClientOrchestrationCommand = {
477+ type: "thread.user-input.respond",
478+ commandId: CommandId.make("answer"),
479+ threadId: ThreadId.make("thread-1"),
480+ requestId: ApprovalRequestId.make("request"),
481+ answers: { q1: ["Selected option"], q2: "" },
482+ createdAt: "2026-08-01T00:00:00.000Z",
483+ attachmentsByQuestionId: {
484+ q1: [
485+ {
486+ type: "image",
487+ id: imageId,
488+ name: "image.png",
489+ mimeType: "image/png",
490+ sizeBytes: 6,
491+ },
492+ ],
493+ q2: [
494+ {
495+ type: "file",
496+ id: fileId,
497+ name: "report.txt",
498+ mimeType: "text/plain",
499+ sizeBytes: 6,
500+ },
501+ ],
502+ },
503+ };
504+ const normalized = yield* normalizeDispatchCommand(command);
505+ if (normalized.type !== "thread.user-input.respond") throw new Error("Wrong command");
506+ expect(normalized.answers).toEqual(command.answers);
507+ const image = normalized.attachmentsByQuestionId!.q1![0]!;
508+ const file = normalized.attachmentsByQuestionId!.q2![0]!;
509+ expect(
510+ NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${image.id}.png`), "utf8"),
511+ ).toBe("pixels");
512+ expect(
513+ NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${file.id}.txt`), "utf8"),
514+ ).toBe("report");
515+ yield* cleanupFailedUploadedAttachments(command, normalized);
516+ expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${image.id}.png`))).toBe(
517+ false,
518+ );
519+ expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${file.id}.txt`))).toBe(
520+ false,
521+ );
522+ expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${imageId}.png`))).toBe(
523+ true,
524+ );
525+ const retry = yield* normalizeDispatchCommand(command);
526+ expect(retry.type).toBe("thread.user-input.respond");
527+ }).pipe(Effect.provide(testLayer)),
528+ );
529+
530+ it.effect("removes all claimed copies if a later question upload is missing", () =>
531+ Effect.gen(function* () {
532+ const config = yield* ServerConfig.ServerConfig;
533+ const id = `pending-${attachmentUuid}`;
534+ NodeFS.writeFileSync(NodePath.join(config.attachmentsDir, `${id}.png`), "pixels");
535+ const result = yield* normalizeDispatchCommand({
536+ type: "thread.user-input.respond",
537+ commandId: CommandId.make("answer"),
538+ threadId: ThreadId.make("thread-1"),
539+ requestId: ApprovalRequestId.make("request"),
540+ answers: { q1: "", q2: "" },
541+ createdAt: "2026-08-01T00:00:00.000Z",
542+ attachmentsByQuestionId: {
543+ q1: [{ type: "image", id, name: "image.png", mimeType: "image/png", sizeBytes: 6 }],
544+ q2: [
545+ {
546+ type: "file",
547+ id: `${id}-txt`,
548+ name: "missing.txt",
549+ mimeType: "text/plain",
550+ sizeBytes: 6,
551+ },
552+ ],
553+ },
554+ }).pipe(Effect.result);
555+ expect(result._tag).toBe("Failure");
556+ expect(
557+ NodeFS.readdirSync(config.attachmentsDir).filter((name) => name.startsWith("thread-1-")),
558+ ).toEqual([]);
559+ }).pipe(Effect.provide(testLayer)),
560+ );
561+});
apps/server/src/orchestration/Normalizer.ts+53−7
@@ -4,6 +4,8 @@ import * as FileSystem from "effect/FileSystem"; · 3 unmodified lines
44 import * as Path from "effect/Path";
55 import {
66 type ClientOrchestrationCommand,
7+ type UserInputAttachments,
8+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
79 type IsoDateTime,
810 type OrchestrationCommand,
911 OrchestrationDispatchCommandError,
@@ -129,13 +131,28 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => · 119 unmodified lines
129131 } satisfies OrchestrationCommand;
130132 }
131133
132− if (canonicalCommand.type !== "thread.turn.start") {
134+ if (
135+ canonicalCommand.type !== "thread.turn.start" &&
136+ canonicalCommand.type !== "thread.user-input.respond"
137+ ) {
133138 return canonicalCommand as OrchestrationCommand;
134139 }
135140
141+ const attachments =
142+ canonicalCommand.type === "thread.turn.start"
143+ ? canonicalCommand.message.attachments
144+ : Object.values(canonicalCommand.attachmentsByQuestionId ?? {}).flat();
145+ if (
146+ canonicalCommand.type === "thread.user-input.respond" &&
147+ attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS
148+ ) {
149+ return yield* new OrchestrationDispatchCommandError({
150+ message: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per question response.`,
151+ });
152+ }
136153 const claimedAttachmentPaths: string[] = [];
137154 const normalizedAttachments = yield* Effect.forEach(
138− canonicalCommand.message.attachments,
155+ attachments,
139156 (attachment) =>
140157 Effect.gen(function* () {
141158 if (!("dataUrl" in attachment)) {
@@ -260,6 +277,25 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => · 118 unmodified lines
260277 { concurrency: 1 },
261278 ).pipe(Effect.tapError(() => removeClaimedAttachmentPaths(claimedAttachmentPaths)));
262279
280+ if (canonicalCommand.type === "thread.user-input.respond") {
281+ let index = 0;
282+ const attachmentsByQuestionId = Object.fromEntries(
283+ Object.entries(canonicalCommand.attachmentsByQuestionId ?? {}).map(
284+ ([questionId, original]) => {
285+ const claimed = normalizedAttachments.slice(
286+ index,
287+ index + original.length,
288+ ) as UserInputAttachments[string];
289+ index += original.length;
290+ return [questionId, claimed];
291+ },
292+ ),
293+ );
294+ return {
295+ ...canonicalCommand,
296+ ...(attachments.length > 0 ? { attachmentsByQuestionId } : {}),
297+ };
298+ }
263299 return {
264300 ...canonicalCommand,
265301 message: {
@@ -272,14 +308,24 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => · 6 unmodified lines
272308 export const cleanupFailedUploadedAttachments = Effect.fn(
273309 "Normalizer.cleanupFailedUploadedAttachments",
274310 )(function* (command: ClientOrchestrationCommand, normalizedCommand: OrchestrationCommand) {
275− if (command.type !== "thread.turn.start" || normalizedCommand.type !== "thread.turn.start") {
276− return;
277− }
311+ const originalAttachments =
312+ command.type === "thread.turn.start"
313+ ? command.message.attachments
314+ : command.type === "thread.user-input.respond"
315+ ? Object.values(command.attachmentsByQuestionId ?? {}).flat()
316+ : [];
317+ const normalizedAttachments =
318+ normalizedCommand.type === "thread.turn.start"
319+ ? normalizedCommand.message.attachments
320+ : normalizedCommand.type === "thread.user-input.respond"
321+ ? Object.values(normalizedCommand.attachmentsByQuestionId ?? {}).flat()
322+ : [];
323+ if (normalizedAttachments.length === 0) return;
278324
279325 const serverConfig = yield* ServerConfig;
280326 const claimedPaths: string[] = [];
281− for (const [index, attachment] of normalizedCommand.message.attachments.entries()) {
282− const original = command.message.attachments[index];
327+ for (const [index, attachment] of normalizedAttachments.entries()) {
328+ const original = originalAttachments[index];
283329 if (
284330 !original ||
285331 "dataUrl" in original ||
apps/server/src/orchestration/decider.questionAttachments.test.ts+130−0
@@ -0,0 +1,130 @@
1+import {
2+ ApprovalRequestId,
3+ EventId,
4+ CommandId,
5+ ProjectId,
6+ ProviderInstanceId,
7+ ThreadId,
8+ type OrchestrationReadModel,
9+} from "@t3tools/contracts";
10+import * as NodeServices from "@effect/platform-node/NodeServices";
11+import { expect, it } from "@effect/vitest";
12+import * as Effect from "effect/Effect";
13+
14+import { decideOrchestrationCommand } from "./decider.ts";
15+
16+const UPDATED_AT = "2026-01-01T00:00:00.000Z";
17+
18+const readModel: OrchestrationReadModel = {
19+ snapshotSequence: 0,
20+ projects: [],
21+ threads: [
22+ {
23+ id: ThreadId.make("thread-1"),
24+ projectId: ProjectId.make("project-1"),
25+ title: "Manual title",
26+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
27+ runtimeMode: "full-access",
28+ interactionMode: "default",
29+ branch: null,
30+ worktreePath: null,
31+ latestTurn: null,
32+ createdAt: UPDATED_AT,
33+ updatedAt: UPDATED_AT,
34+ archivedAt: null,
35+ settledOverride: null,
36+ settledAt: null,
37+ snoozedUntil: null,
38+ snoozedAt: null,
39+ deletedAt: null,
40+ messages: [],
41+ proposedPlans: [],
42+ activities: [],
43+ checkpoints: [],
44+ session: null,
45+ },
46+ ],
47+ updatedAt: UPDATED_AT,
48+};
49+
50+const requestId = ApprovalRequestId.make("question-request");
51+const command = {
52+ type: "thread.user-input.respond" as const,
53+ commandId: CommandId.make("answer"),
54+ threadId: ThreadId.make("thread-1"),
55+ requestId,
56+ answers: { q: "" },
57+ createdAt: UPDATED_AT,
58+ attachmentsByQuestionId: {
59+ q: [
60+ {
61+ type: "file" as const,
62+ id: "thread-1-00000000-0000-4000-8000-0000000000aa-txt",
63+ name: "spec.txt",
64+ mimeType: "text/plain",
65+ sizeBytes: 4,
66+ },
67+ ],
68+ },
69+};
70+const request = {
71+ id: EventId.make("question"),
72+ kind: "user-input.requested",
73+ summary: "Question",
74+ tone: "info" as const,
75+ turnId: null,
76+ createdAt: UPDATED_AT,
77+ payload: {
78+ requestId,
79+ questions: [
80+ { id: "q", header: "Spec", question: "Provide a spec", options: [], allowCustomAnswer: true },
81+ ],
82+ },
83+};
84+it.layer(NodeServices.layer)("question attachment answers", (it) => {
85+ it.effect("persists the original answer with its attachment and emits a provider response", () =>
86+ Effect.gen(function* () {
87+ const result = yield* decideOrchestrationCommand({
88+ readModel,
89+ command,
90+ userInputActivity: request,
91+ });
92+ const events = Array.isArray(result) ? result : [result];
93+ expect(events.map((event) => event.type)).toEqual([
94+ "thread.activity-appended",
95+ "thread.user-input-response-requested",
96+ ]);
97+ expect(events[0]?.payload).toMatchObject({
98+ activity: {
99+ kind: "user-input.answer-submitted",
100+ payload: { answers: { q: "" }, attachmentsByQuestionId: command.attachmentsByQuestionId },
101+ },
102+ });
103+ expect(events[1]?.payload).toMatchObject({
104+ answers: { q: "" },
105+ attachmentsByQuestionId: command.attachmentsByQuestionId,
106+ });
107+ }),
108+ );
109+ it.effect("rejects attachments for a resolved or unknown request", () =>
110+ Effect.gen(function* () {
111+ const result = yield* decideOrchestrationCommand({ readModel, command }).pipe(Effect.result);
112+ expect(result._tag).toBe("Failure");
113+ }),
114+ );
115+ it.effect("rejects unknown question IDs and predefined-choice-only protocols", () =>
116+ Effect.gen(function* () {
117+ for (const question of [
118+ { ...request.payload.questions[0], id: "other" },
119+ { ...request.payload.questions[0], allowCustomAnswer: false },
120+ ]) {
121+ const result = yield* decideOrchestrationCommand({
122+ readModel,
123+ command,
124+ userInputActivity: { ...request, payload: { ...request.payload, questions: [question] } },
125+ }).pipe(Effect.result);
126+ expect(result._tag).toBe("Failure");
127+ }
128+ }),
129+ );
130+});
apps/server/src/orchestration/decider.ts+76−8
@@ -1222,6 +1222,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 1221 unmodified lines
12221222 threadId: command.threadId,
12231223 });
12241224 const request = userInputActivity;
1225+ const attachments = Object.values(command.attachmentsByQuestionId ?? {}).flat();
1226+ let questionTextById: Record<string, string> = {};
1227+ if (attachments.length > 0) {
1228+ const payload =
1229+ request?.kind === "user-input.requested"
1230+ ? decodeUserInputRequestedPayload(request.payload)
1231+ : Option.none();
1232+ if (Option.isNone(payload)) {
1233+ return yield* new OrchestrationCommandInvariantError({
1234+ commandType: command.type,
1235+ detail:
1236+ request?.kind === "user-input.resolved"
1237+ ? "This question has already been answered."
1238+ : "This question is no longer pending.",
1239+ });
1240+ }
1241+ questionTextById = Object.fromEntries(
1242+ payload.value.questions.map((question) => [question.id, question.question]),
1243+ );
1244+ for (const questionId of Object.keys(command.attachmentsByQuestionId ?? {})) {
1245+ const question = payload.value.questions.find((question) => question.id === questionId);
1246+ if (!question || question.allowCustomAnswer === false) {
1247+ return yield* new OrchestrationCommandInvariantError({
1248+ commandType: command.type,
1249+ detail: "This question does not accept file references.",
1250+ });
1251+ }
1252+ }
1253+ }
12251254 if (
12261255 request &&
12271256 Predicate.isObject(request.payload) &&
@@ -1237,13 +1266,22 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 9 unmodified lines
12371266 const replies: string[] = [];
12381267 for (const question of payload.value.questions) {
12391268 const answer = command.answers[question.id];
1240− if (typeof answer !== "string" || answer.trim().length === 0) {
1269+ if (
1270+ typeof answer !== "string" ||
1271+ (answer.trim().length === 0 && !command.attachmentsByQuestionId?.[question.id]?.length)
1272+ ) {
12411273 return yield* new OrchestrationCommandInvariantError({
12421274 commandType: command.type,
12431275 detail: "Answer each question before sending.",
12441276 });
12451277 }
1246− replies.push(`${question.question}\n${answer.trim()}`);
1278+ const questionAttachments = command.attachmentsByQuestionId?.[question.id] ?? [];
1279+ const attachmentLabels = questionAttachments
1280+ .map((attachment) => `Attached file: ${attachment.name} (${attachment.id})`)
1281+ .join("\n");
1282+ replies.push(
1283+ [`${question.question}\n${answer.trim()}`, attachmentLabels].filter(Boolean).join("\n"),
1284+ );
12471285 }
12481286 // Commit the answer and its message together. The normal turn path
12491287 // steers a running agent or resumes an idle session.
@@ -1266,6 +1304,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 16 unmodified lines
12661304 requestId: command.requestId,
12671305 responseMode: "message",
12681306 answers: command.answers,
1307+ ...(command.attachmentsByQuestionId
1308+ ? { attachmentsByQuestionId: command.attachmentsByQuestionId }
1309+ : {}),
12691310 },
12701311 },
12711312 },
@@ -1280,30 +1321,57 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 8 unmodified lines
12801321 messageId: MessageId.make(`async-answer:${command.requestId}`),
12811322 role: "user",
12821323 text: replies.join("\n\n"),
1283− attachments: [],
1324+ attachments,
12841325 },
12851326 },
12861327 ],
12871328 });
12881329 }
1289− return {
1330+ const responseEvent = {
12901331 ...(yield* withEventBase({
12911332 aggregateKind: "thread",
12921333 aggregateId: command.threadId,
12931334 occurredAt: command.createdAt,
12941335 commandId: command.commandId,
1295− metadata: {
1296− requestId: command.requestId,
1297− },
1336+ metadata: { requestId: command.requestId },
12981337 })),
1299− type: "thread.user-input-response-requested",
1338+ type: "thread.user-input-response-requested" as const,
13001339 payload: {
13011340 threadId: command.threadId,
13021341 requestId: command.requestId,
13031342 answers: command.answers,
1343+ ...(command.attachmentsByQuestionId
1344+ ? { attachmentsByQuestionId: command.attachmentsByQuestionId }
1345+ : {}),
13041346 createdAt: command.createdAt,
13051347 },
13061348 };
1349+ if (attachments.length === 0) return responseEvent;
1350+ const historyEvent = yield* decideOrchestrationCommand({
1351+ readModel,
1352+ command: {
1353+ type: "thread.activity.append",
1354+ commandId: command.commandId,
1355+ threadId: command.threadId,
1356+ createdAt: command.createdAt,
1357+ activity: {
1358+ id: EventId.make(`question-answer:${command.commandId}`),
1359+ kind: "user-input.answer-submitted",
1360+ summary: "Question answer submitted",
1361+ tone: "info",
1362+ turnId: request?.turnId ?? null,
1363+ createdAt: command.createdAt,
1364+ payload: {
1365+ requestId: command.requestId,
1366+ answers: command.answers,
1367+ questionTextById,
1368+ attachmentsByQuestionId: command.attachmentsByQuestionId,
1369+ detail: attachments.map((attachment) => attachment.name).join("\n"),
1370+ },
1371+ },
1372+ },
1373+ });
1374+ return [...(Array.isArray(historyEvent) ? historyEvent : [historyEvent]), responseEvent];
13071375 }
13081376
13091377 case "thread.user-input.dismiss": {
apps/server/src/provider/Layers/ProviderService.ts+6−1
@@ -49,6 +49,7 @@ import * as Schema from "effect/Schema"; · 48 unmodified lines
4949 import * as SchemaIssue from "effect/SchemaIssue";
5050 import * as Stream from "effect/Stream";
5151
52+import { appendUserInputAttachmentPaths } from "../userInputAttachments.ts";
5253 import { resolveAttachmentPath } from "../../attachmentStore.ts";
5354 import * as ServerConfig from "../../config.ts";
5455 import {
@@ -1919,7 +1920,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( · 1864 unmodified lines
19191920 "provider.thread_id": input.threadId,
19201921 "provider.request_id": input.requestId,
19211922 });
1922− yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers);
1923+ const answers = yield* appendUserInputAttachmentPaths({
1924+ ...input,
1925+ attachmentsDir: serverConfig.attachmentsDir,
1926+ }).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem));
1927+ yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, answers);
19231928 }).pipe(
19241929 withMetrics({
19251930 counter: providerTurnsTotal,
apps/server/src/provider/userInputAttachments.test.ts+61−0
@@ -0,0 +1,61 @@
1+// @effect-diagnostics nodeBuiltinImport:off
2+import * as NodeFS from "node:fs";
3+import * as NodePath from "node:path";
4+import * as NodeServices from "@effect/platform-node/NodeServices";
5+import { describe, expect, it } from "@effect/vitest";
6+import * as Effect from "effect/Effect";
7+import * as Layer from "effect/Layer";
8+import * as ServerConfig from "../config.ts";
9+import { appendUserInputAttachmentPaths } from "./userInputAttachments.ts";
10+
11+const layer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-answer-paths-" }).pipe(
12+ Layer.provideMerge(NodeServices.layer),
13+);
14+const attachment = {
15+ type: "file" as const,
16+ id: "thread-1-00000000-0000-4000-8000-0000000000aa-txt",
17+ name: 'spec "final".txt',
18+ mimeType: "text/plain",
19+ sizeBytes: 4,
20+};
21+describe("question answer paths", () => {
22+ it.effect("keeps selected values intact and appends an actual readable server path", () =>
23+ Effect.gen(function* () {
24+ const config = yield* ServerConfig.ServerConfig;
25+ const path = NodePath.join(config.attachmentsDir, `${attachment.id}.txt`);
26+ NodeFS.writeFileSync(path, "spec");
27+ const original = { q: ["First", "Second"], other: "No file" };
28+ const answers = yield* appendUserInputAttachmentPaths({
29+ answers: original,
30+ attachmentsDir: config.attachmentsDir,
31+ attachmentsByQuestionId: { q: [attachment] },
32+ });
33+ expect(answers.q).toEqual([
34+ "First",
35+ "Second",
36+ `Attached file "spec \\"final\\".txt": "${path}"`,
37+ ]);
38+ expect(answers.other).toBe("No file");
39+ expect(original.q).toEqual(["First", "Second"]);
40+ expect(NodeFS.readFileSync(path, "utf8")).toBe("spec");
41+ const specialKey = yield* appendUserInputAttachmentPaths({
42+ answers: {},
43+ attachmentsDir: config.attachmentsDir,
44+ attachmentsByQuestionId: { ["__proto__"]: [attachment] },
45+ });
46+ expect(Object.keys(specialKey)).toEqual(["__proto__"]);
47+ expect(specialKey["__proto__"]).toContain(path);
48+ }).pipe(Effect.provide(layer)),
49+ );
50+ it.effect("does not send an unavailable attachment path", () =>
51+ Effect.gen(function* () {
52+ const config = yield* ServerConfig.ServerConfig;
53+ const result = yield* appendUserInputAttachmentPaths({
54+ answers: { q: "" },
55+ attachmentsDir: config.attachmentsDir,
56+ attachmentsByQuestionId: { q: [attachment] },
57+ }).pipe(Effect.result);
58+ expect(result._tag).toBe("Failure");
59+ }).pipe(Effect.provide(layer)),
60+ );
61+});
apps/server/src/provider/userInputAttachments.ts+59−0
@@ -0,0 +1,59 @@
1+import type { ProviderUserInputAnswers, UserInputAttachments } from "@t3tools/contracts";
2+import * as Schema from "effect/Schema";
3+import * as Effect from "effect/Effect";
4+import * as FileSystem from "effect/FileSystem";
5+import { resolveAttachmentPath } from "../attachmentStore.ts";
6+import { ProviderValidationError } from "./Errors.ts";
7+
8+const quoteReference = Schema.encodeSync(Schema.fromJsonString(Schema.String));
9+
10+/** Keep provider answer protocols unchanged; paths refer to files on the provider's server. */
11+export const appendUserInputAttachmentPaths = Effect.fn("appendUserInputAttachmentPaths")(
12+ function* (input: {
13+ answers: ProviderUserInputAnswers;
14+ attachmentsByQuestionId?: UserInputAttachments | undefined;
15+ attachmentsDir: string;
16+ }) {
17+ const answers = new Map(Object.entries(input.answers));
18+ const fs = yield* FileSystem.FileSystem;
19+ for (const [questionId, attachments] of Object.entries(input.attachmentsByQuestionId ?? {})) {
20+ if (attachments.length === 0) continue;
21+ const references: string[] = [];
22+ for (const attachment of attachments) {
23+ const path = resolveAttachmentPath({ attachmentsDir: input.attachmentsDir, attachment });
24+ if (
25+ !path ||
26+ !(yield* fs.exists(path).pipe(
27+ Effect.mapError(
28+ (cause) =>
29+ new ProviderValidationError({
30+ operation: "respondToUserInput",
31+ issue: `Could not access attachment '${attachment.name}'.`,
32+ cause,
33+ }),
34+ ),
35+ ))
36+ ) {
37+ return yield* new ProviderValidationError({
38+ operation: "respondToUserInput",
39+ issue: `Attachment '${attachment.name}' is no longer available. Attach it again.`,
40+ });
41+ }
42+ references.push(
43+ `Attached ${attachment.type} ${quoteReference(attachment.name)}: ${quoteReference(path)}`,
44+ );
45+ }
46+ const answer = answers.get(questionId);
47+ const text = references.join("\n");
48+ answers.set(
49+ questionId,
50+ Array.isArray(answer)
51+ ? [...answer, text]
52+ : typeof answer === "string" && answer.length > 0
53+ ? `${answer}\n\n${text}`
54+ : text,
55+ );
56+ }
57+ return Object.fromEntries(answers);
58+ },
59+);
apps/web/src/components/ChatView.tsx+189−22
@@ -8,6 +8,13 @@ import { · 7 unmodified lines
88 import { feedbackBannerItem } from "./chat/ComposerFeedback";
99 import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";
1010 import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
11+import {
12+ questionAttachmentDraftId,
13+ questionAttachmentDraftPrefix,
14+ clearQuestionAttachmentDraft,
15+ useQuestionAttachmentPreparation,
16+} from "../questionAttachments";
17+import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue";
1118 import {
1219 type AssistantCitation,
1320 type ApprovalRequestId,
@@ -264,7 +271,7 @@ import { · 250 unmodified lines
264271 finalizePromotedDraftThreadByRef,
265272 markPromotedDraftThreadByRef,
266273 useComposerDraftStore,
267− type DraftId,
274+ DraftId,
268275 } from "../composerDraftStore";
269276 import {
270277 appendTerminalContextsToPrompt,
@@ -1610,6 +1617,7 @@ export default function ChatView(props: ChatViewProps) { · 1339 unmodified lines
16101617 null,
16111618 );
16121619 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
1620+ const userInputResponsesInFlight = useRef(new Set<string>());
16131621 const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<
16141622 ApprovalRequestId[]
16151623 >([]);
@@ -2310,6 +2318,8 @@ export default function ChatView(props: ChatViewProps) { · 694 unmodified lines
23102318 const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true;
23112319 const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null;
23122320 const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null;
2321+ const supportsQuestionAttachments =
2322+ attachmentEnvironmentConfig?.environment.capabilities.questionAttachments === true;
23132323 const supportsAttachmentUploads =
23142324 attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true;
23152325 const advertisedFileAttachmentBytes =
@@ -2627,16 +2637,120 @@ export default function ChatView(props: ChatViewProps) { · 311 unmodified lines
26272637 [threadActivities],
26282638 );
26292639 const activePendingUserInput = pendingUserInputs[0] ?? null;
2630− const activePendingDraftAnswers = useMemo(
2640+ const activePendingRequestKey = JSON.stringify([
2641+ environmentId,
2642+ activeThreadId,
2643+ activePendingUserInput?.requestId,
2644+ ]);
2645+ const pendingQuestionDraftKeys = useMemo(
26312646 () =>
2632− activePendingUserInput
2633− ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ??
2634− EMPTY_PENDING_USER_INPUT_ANSWERS)
2635− : EMPTY_PENDING_USER_INPUT_ANSWERS,
2636− [activePendingUserInput, pendingUserInputAnswersByRequestId],
2647+ activeThreadId
2648+ ? pendingUserInputs.flatMap((request) =>
2649+ request.questions.map((question) =>
2650+ questionAttachmentDraftId(
2651+ environmentId,
2652+ activeThreadId,
2653+ request.requestId,
2654+ question.id,
2655+ ),
2656+ ),
2657+ )
2658+ : [],
2659+ [activeThreadId, environmentId, pendingUserInputs],
26372660 );
2661+ const questionComposerDrafts = useComposerDraftStore(
2662+ useShallow((state) =>
2663+ Object.fromEntries(
2664+ pendingQuestionDraftKeys.map((key) => [key, state.draftsByThreadKey[key]]),
2665+ ),
2666+ ),
2667+ );
2668+ const questionUploadsBlocked = useAttachmentUploadStore(
2669+ useShallow((state) =>
2670+ Object.fromEntries(
2671+ pendingQuestionDraftKeys.map((key) => {
2672+ const draft = questionComposerDrafts[key];
2673+ const attachments = draft ? [...draft.images, ...draft.files] : [];
2674+ return [
2675+ key,
2676+ attachments.some((attachment) => {
2677+ const upload = state.uploadsByImageId[attachment.id];
2678+ return upload?.status !== "ready" || upload.environmentId !== environmentId;
2679+ }),
2680+ ];
2681+ }),
2682+ ),
2683+ ),
2684+ );
2685+ const questionPreparations = useQuestionAttachmentPreparation(
2686+ useShallow((state) =>
2687+ Object.fromEntries(pendingQuestionDraftKeys.map((key) => [key, state.counts[key] ?? 0])),
2688+ ),
2689+ );
2690+ useEffect(() => {
2691+ if (routeThreadState.status !== "live" || routeThreadState.data._tag !== "Some") return;
2692+ const questionThread = routeThreadState.data.value;
2693+ const { userInputs: currentRequests } = derivePendingRequests(questionThread.activities);
2694+ const prefix = questionAttachmentDraftPrefix(environmentId, questionThread.id);
2695+ const retained = new Set(
2696+ currentRequests.flatMap((request) =>
2697+ request.questions.map((question) =>
2698+ questionAttachmentDraftId(
2699+ environmentId,
2700+ questionThread.id,
2701+ request.requestId,
2702+ question.id,
2703+ ),
2704+ ),
2705+ ),
2706+ );
2707+ const keys = new Set([
2708+ ...Object.keys(useComposerDraftStore.getState().draftsByThreadKey),
2709+ ...Object.keys(useQuestionAttachmentPreparation.getState().counts),
2710+ ]);
2711+ for (const key of keys) {
2712+ if (key.startsWith(prefix) && !retained.has(DraftId.make(key)))
2713+ clearQuestionAttachmentDraft(DraftId.make(key));
2714+ }
2715+ }, [environmentId, routeThreadState.data, routeThreadState.status]);
2716+ const activePendingDraftAnswers = useMemo(() => {
2717+ if (!activePendingUserInput || !activeThreadId) return EMPTY_PENDING_USER_INPUT_ANSWERS;
2718+ return Object.fromEntries(
2719+ activePendingUserInput.questions.map((question) => {
2720+ const key = questionAttachmentDraftId(
2721+ environmentId,
2722+ activeThreadId,
2723+ activePendingUserInput.requestId,
2724+ question.id,
2725+ );
2726+ const draft = questionComposerDrafts[key];
2727+ const attachments = draft ? [...draft.images, ...draft.files] : [];
2728+ return [
2729+ question.id,
2730+ {
2731+ ...pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[question.id],
2732+ attachmentCount: attachments.length,
2733+ attachmentsBlocked:
2734+ (attachments.length > 0 && !supportsQuestionAttachments) ||
2735+ (questionPreparations[key] ?? 0) > 0 ||
2736+ questionUploadsBlocked[key] === true,
2737+ },
2738+ ];
2739+ }),
2740+ );
2741+ }, [
2742+ activePendingUserInput,
2743+ activeThreadId,
2744+ environmentId,
2745+ questionComposerDrafts,
2746+ questionUploadsBlocked,
2747+ supportsQuestionAttachments,
2748+ questionPreparations,
2749+ pendingUserInputAnswersByRequestId,
2750+ activePendingRequestKey,
2751+ ]);
26382752 const activePendingQuestionIndex = activePendingUserInput
2639− ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0)
2753+ ? (pendingUserInputQuestionIndexByRequestId[activePendingRequestKey] ?? 0)
26402754 : 0;
26412755 const activePendingProgress = useMemo(
26422756 () =>
@@ -7061,8 +7175,38 @@ export default function ChatView(props: ChatViewProps) { · 4418 unmodified lines
70617175
70627176 const onRespondToUserInput = useCallback(
70637177 async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => {
7064− if (!activeThreadId) return;
7065−
7178+ if (!activeThreadId || !activePendingUserInput || activePendingIsResponding) return;
7179+ const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]);
7180+ if (userInputResponsesInFlight.current.has(responseKey)) return;
7181+ const attachmentsByQuestionId = new Map<
7182+ string,
7183+ import("@t3tools/contracts").UserInputAttachments[string]
7184+ >();
7185+ for (const question of activePendingUserInput.questions) {
7186+ const target = questionAttachmentDraftId(
7187+ environmentId,
7188+ activeThreadId,
7189+ requestId,
7190+ question.id,
7191+ );
7192+ if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return;
7193+ const draft = useComposerDraftStore.getState().getComposerDraft(target);
7194+ const attachments = draft ? [...draft.images, ...draft.files] : [];
7195+ if (attachments.length === 0) continue;
7196+ const uploaded = getUploadedAttachments({ environmentId, images: attachments });
7197+ if (!uploaded) {
7198+ setThreadError(
7199+ activeThreadId,
7200+ "Wait for attachments to finish uploading, or remove failed uploads.",
7201+ );
7202+ return;
7203+ }
7204+ attachmentsByQuestionId.set(
7205+ question.id,
7206+ uploaded as import("@t3tools/contracts").UserInputAttachments[string],
7207+ );
7208+ }
7209+ userInputResponsesInFlight.current.add(responseKey);
70667210 setRespondingUserInputRequestIds((existing) =>
70677211 existing.includes(requestId) ? existing : [...existing, requestId],
70687212 );
@@ -7072,6 +7216,9 @@ export default function ChatView(props: ChatViewProps) { · 3 unmodified lines
70727216 threadId: activeThreadId,
70737217 requestId,
70747218 answers,
7219+ ...(attachmentsByQuestionId.size > 0
7220+ ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }
7221+ : {}),
70757222 },
70767223 });
70777224 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
@@ -7081,10 +7228,18 @@ export default function ChatView(props: ChatViewProps) { · 3 unmodified lines
70817228 error instanceof Error ? error.message : "Failed to submit user input.",
70827229 );
70837230 }
7231+ userInputResponsesInFlight.current.delete(responseKey);
70847232 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
70857233 return result;
70867234 },
7087− [activeThreadId, environmentId, respondToThreadUserInput, setThreadError],
7235+ [
7236+ activeThreadId,
7237+ activePendingUserInput,
7238+ activePendingIsResponding,
7239+ environmentId,
7240+ respondToThreadUserInput,
7241+ setThreadError,
7242+ ],
70887243 );
70897244
70907245 // Closes an async question without messaging the agent. The server records
@@ -7120,10 +7275,10 @@ export default function ChatView(props: ChatViewProps) { · 29 unmodified lines
71207275 }
71217276 setPendingUserInputQuestionIndexByRequestId((existing) => ({
71227277 ...existing,
7123− [activePendingUserInput.requestId]: nextQuestionIndex,
7278+ [activePendingRequestKey]: nextQuestionIndex,
71247279 }));
71257280 },
7126− [activePendingUserInput],
7281+ [activePendingUserInput, activePendingRequestKey],
71277282 );
71287283
71297284 const onSelectActivePendingUserInputOption = useCallback(
@@ -7143,11 +7298,11 @@ export default function ChatView(props: ChatViewProps) { · 13 unmodified lines
71437298
71447299 return {
71457300 ...existing,
7146− [activePendingUserInput.requestId]: {
7147− ...existing[activePendingUserInput.requestId],
7301+ [activePendingRequestKey]: {
7302+ ...existing[activePendingRequestKey],
71487303 [questionId]: togglePendingUserInputOptionSelection(
71497304 question,
7150− existing[activePendingUserInput.requestId]?.[questionId],
7305+ existing[activePendingRequestKey]?.[questionId],
71517306 optionValue,
71527307 ),
71537308 },
@@ -7156,7 +7311,12 @@ export default function ChatView(props: ChatViewProps) { · 2 unmodified lines
71567311 promptRef.current = "";
71577312 composerRef.current?.resetCursorState({ cursor: 0 });
71587313 },
7159− [activePendingProgress?.activeQuestion, activePendingUserInput, composerRef],
7314+ [
7315+ activePendingProgress?.activeQuestion,
7316+ activePendingUserInput,
7317+ activePendingRequestKey,
7318+ composerRef,
7319+ ],
71607320 );
71617321
71627322 const onChangeActivePendingUserInputCustomAnswer = useCallback(
@@ -7177,10 +7337,10 @@ export default function ChatView(props: ChatViewProps) { · 14 unmodified lines
71777337 promptRef.current = value;
71787338 setPendingUserInputAnswersByRequestId((existing) => ({
71797339 ...existing,
7180− [activePendingUserInput.requestId]: {
7181− ...existing[activePendingUserInput.requestId],
7340+ [activePendingRequestKey]: {
7341+ ...existing[activePendingRequestKey],
71827342 [questionId]: setPendingUserInputCustomAnswer(
7183− existing[activePendingUserInput.requestId]?.[questionId],
7343+ existing[activePendingRequestKey]?.[questionId],
71847344 value,
71857345 ),
71867346 },
@@ -7194,11 +7354,16 @@ export default function ChatView(props: ChatViewProps) { · 7 unmodified lines
71947354 composerRef.current?.focusAt(nextCursor);
71957355 }
71967356 },
7197− [activePendingUserInput, composerRef],
7357+ [activePendingUserInput, activePendingRequestKey, composerRef],
71987358 );
71997359
72007360 const onAdvanceActivePendingUserInput = useCallback(() => {
7201− if (!activePendingUserInput || !activePendingProgress) {
7361+ if (
7362+ !activePendingUserInput ||
7363+ !activePendingProgress ||
7364+ !activePendingProgress.canAdvance ||
7365+ activePendingIsResponding
7366+ ) {
72027367 return;
72037368 }
72047369 if (activePendingProgress.isLastQuestion) {
@@ -7212,6 +7377,7 @@ export default function ChatView(props: ChatViewProps) { · 7 unmodified lines
72127377 activePendingProgress,
72137378 activePendingResolvedAnswers,
72147379 activePendingUserInput,
7380+ activePendingIsResponding,
72157381 onRespondToUserInput,
72167382 setActivePendingUserInputQuestionIndex,
72177383 ]);
@@ -8119,6 +8285,7 @@ export default function ChatView(props: ChatViewProps) { · 901 unmodified lines
81198285 environmentId={environmentId}
81208286 attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown}
81218287 supportsAttachmentUploads={supportsAttachmentUploads}
8288+ supportsQuestionAttachments={supportsQuestionAttachments}
81228289 maxFileAttachmentBytes={maxFileAttachmentBytes}
81238290 routeKind={routeKind}
81248291 routeThreadRef={routeThreadRef}
apps/web/src/components/chat/ChatComposer.tsx+156−47
@@ -1,4 +1,9 @@
11 import { RefreshIcon } from "~/components/ui/refresh-icon";
2+import {
3+ questionAttachmentDraftId,
4+ useQuestionAttachmentPreparation,
5+ changeQuestionAttachmentPreparation,
6+} from "../../questionAttachments";
27 import type {
38 ApprovalRequestId,
49 AssistantCitation,
@@ -1227,6 +1232,7 @@ export interface ChatComposerProps { · 1222 unmodified lines
12271232 environmentId: EnvironmentId;
12281233 attachmentUploadsCapabilityKnown: boolean;
12291234 supportsAttachmentUploads: boolean;
1235+ supportsQuestionAttachments: boolean;
12301236 maxFileAttachmentBytes: number | null;
12311237 routeKind: "server" | "draft";
12321238 routeThreadRef: ScopedThreadRef;
@@ -1376,6 +1382,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 143 unmodified lines
13761382 environmentId,
13771383 attachmentUploadsCapabilityKnown,
13781384 supportsAttachmentUploads,
1385+ supportsQuestionAttachments,
13791386 maxFileAttachmentBytes,
13801387 routeKind,
13811388 routeThreadRef,
@@ -1468,9 +1475,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 86 unmodified lines
14681475 // happened while they awaited.
14691476 const composerDraftTargetKeyRef = useRef("");
14701477 composerDraftTargetKeyRef.current = composerTargetKey(composerDraftTarget);
1478+ const questionAttachmentTarget =
1479+ pendingUserInputs[0] && activePendingProgress?.activeQuestion
1480+ ? questionAttachmentDraftId(
1481+ environmentId,
1482+ activeThreadId!,
1483+ pendingUserInputs[0].requestId,
1484+ activePendingProgress.activeQuestion.id,
1485+ )
1486+ : null;
1487+ const attachmentDraftTarget = questionAttachmentTarget ?? composerDraftTarget;
1488+ const attachmentDraft = useComposerThreadDraft(attachmentDraftTarget);
1489+ const attachmentTargetKey = composerTargetKey(attachmentDraftTarget);
1490+ const questionPreparations = useQuestionAttachmentPreparation((state) => state.counts);
14711491 const prompt = composerDraft.prompt;
1472− const composerImages = composerDraft.images;
1473− const composerFiles = composerDraft.files;
1492+ const composerImages = attachmentDraft.images;
1493+ const composerFiles = attachmentDraft.files;
14741494 const composerVideos = composerFiles.filter((file) =>
14751495 isPreviewableComposerVideo(file, environmentId),
14761496 );
@@ -1500,7 +1520,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 23 unmodified lines
15001520 );
15011521 return composerImages.filter((image) => !previewAnnotationIds.has(image.id));
15021522 }, [composerImages, composerPreviewAnnotations]);
1503− const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
1523+ const nonPersistedComposerImageIds = attachmentDraft.nonPersistedImageIds;
15041524 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
15051525 const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length;
15061526 const fileStagingLimit = fileAttachmentStagingLimit({
@@ -1515,6 +1535,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 8 unmodified lines
15151535 maxFileAttachmentBytes,
15161536 });
15171537 const attachmentBlockReason =
1538+ (questionAttachmentTarget &&
1539+ !supportsQuestionAttachments &&
1540+ (composerImages.length > 0 || composerFiles.length > 0)
1541+ ? "Update this server to send files with question answers"
1542+ : null) ??
15181543 fileCapabilityBlockReason ??
15191544 (supportsAttachmentUploads
15201545 ? needsReattachFileCount > 0
@@ -1528,7 +1553,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 7 unmodified lines
15281553 })
15291554 : null);
15301555 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
1531− const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
15321556 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
15331557 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
15341558 const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);
@@ -1596,11 +1620,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 61 unmodified lines
15961620 if (attachment.type === "file" && composerFileNeedsReattach(attachment)) {
15971621 continue;
15981622 }
1599− startAttachmentUpload({ environmentId, image: attachment, draftTarget: composerDraftTarget });
1623+ startAttachmentUpload({
1624+ environmentId,
1625+ image: attachment,
1626+ draftTarget: attachmentDraftTarget,
1627+ });
16001628 }
16011629 }, [
16021630 attachmentUploadsCapabilityKnown,
1603− composerDraftTarget,
1631+ attachmentDraftTarget,
16041632 composerFiles,
16051633 composerImages,
16061634 environmentId,
@@ -1621,7 +1649,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 14 unmodified lines
16211649 const upload = uploadsByImageId[file.id];
16221650 if (upload?.status === "ready" && upload.environmentId === environmentId) {
16231651 setComposerDraftFileUpload(
1624− composerDraftTarget,
1652+ attachmentDraftTarget,
16251653 file.id,
16261654 environmentId,
16271655 upload.attachmentId,
@@ -1630,7 +1658,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2 unmodified lines
16301658 }
16311659 }, [
16321660 attachmentUploadsCapabilityKnown,
1633− composerDraftTarget,
1661+ attachmentDraftTarget,
16341662 composerFiles,
16351663 environmentId,
16361664 maxFileAttachmentBytes,
@@ -1713,7 +1741,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 76 unmodified lines
17131741 );
17141742 const sendDisabledReason =
17151743 externalSendDisabledReason ??
1716− (activePendingProgress ? null : (attachmentBlockReason ?? providerSendBlockReason));
1744+ (activePendingProgress
1745+ ? attachmentBlockReason
1746+ : (attachmentBlockReason ?? providerSendBlockReason));
17171747 const isSendDisabled = sendDisabledReason !== null;
17181748 const selectedProviderStatus = useMemo(
17191749 () => selectedProviderEntry?.snapshot ?? null,
@@ -1938,7 +1968,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 218 unmodified lines
19381968 * and checked before sending or compacting so an image cannot move into
19391969 * the next draft.
19401970 */
1941− const pendingImageCompressionsRef = useRef<Map<ThreadId, number>>(new Map());
1971+ const pendingImageCompressionsRef = useRef<Map<string, number>>(new Map());
19421972
19431973 // ------------------------------------------------------------------
19441974 // Derived: composer send state
@@ -2127,7 +2157,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 182 unmodified lines
21272157 (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null);
21282158 const showCollapsedMobilePromptRow =
21292159 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
2130− const showComposerAttachAction = fileStagingLimit !== null && pendingUserInputs.length === 0;
2160+ const showComposerAttachAction =
2161+ fileStagingLimit !== null &&
2162+ (!activePendingProgress ||
2163+ (supportsQuestionAttachments &&
2164+ activePendingProgress.activeQuestion?.allowCustomAnswer !== false));
21312165 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
21322166 const composerFooterActionLayoutKey = useMemo(() => {
21332167 if (activePendingProgress) {
@@ -2219,12 +2253,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 85 unmodified lines
22192253 ? {
22202254 questionIndex: activePendingProgress.questionIndex,
22212255 isLastQuestion: activePendingProgress.isLastQuestion,
2222− canAdvance: activePendingProgress.canAdvance,
2256+ canAdvance:
2257+ activePendingProgress.canAdvance &&
2258+ !attachmentBlockReason &&
2259+ !(questionPreparations[attachmentTargetKey] ?? 0),
22232260 isResponding: activePendingIsResponding,
22242261 isComplete: Boolean(activePendingResolvedAnswers),
22252262 }
22262263 : null,
2227− [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
2264+ [
2265+ activePendingIsResponding,
2266+ activePendingProgress,
2267+ activePendingResolvedAnswers,
2268+ attachmentBlockReason,
2269+ questionPreparations,
2270+ attachmentTargetKey,
2271+ ],
22282272 );
22292273 const collapsedComposerPrimaryActionDisabled =
22302274 phase === "running" ||
@@ -2251,35 +2295,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 20 unmodified lines
22512295
22522296 const addComposerImage = useCallback(
22532297 (image: ComposerImageAttachment) => {
2254− addComposerDraftImage(composerDraftTarget, image);
2298+ addComposerDraftImages(attachmentDraftTarget, [image]);
22552299 },
2256− [composerDraftTarget, addComposerDraftImage],
2300+ [attachmentDraftTarget, addComposerDraftImages],
22572301 );
22582302
22592303 const addComposerImagesToDraft = useCallback(
22602304 (images: ComposerImageAttachment[]) => {
2261− addComposerDraftImages(composerDraftTarget, images);
2305+ addComposerDraftImages(attachmentDraftTarget, images);
22622306 },
2263− [composerDraftTarget, addComposerDraftImages],
2307+ [attachmentDraftTarget, addComposerDraftImages],
22642308 );
22652309
22662310 const addComposerFilesToDraft = useCallback(
22672311 (files: ComposerFileAttachment[]) => {
2268− addComposerDraftFiles(composerDraftTarget, files);
2312+ addComposerDraftFiles(attachmentDraftTarget, files);
22692313 },
2270− [addComposerDraftFiles, composerDraftTarget],
2314+ [addComposerDraftFiles, attachmentDraftTarget],
22712315 );
22722316
22732317 const removeComposerImageFromDraft = useCallback(
22742318 (imageId: string) => {
2319+ if (questionAttachmentTarget && activePendingIsResponding) return;
22752320 releaseAttachmentUpload(imageId);
2276− removeComposerDraftImage(composerDraftTarget, imageId);
2321+ removeComposerDraftImage(attachmentDraftTarget, imageId);
22772322 },
2278− [composerDraftTarget, removeComposerDraftImage],
2323+ [
2324+ attachmentDraftTarget,
2325+ questionAttachmentTarget,
2326+ activePendingIsResponding,
2327+ removeComposerDraftImage,
2328+ ],
22792329 );
22802330
22812331 const removeComposerFileFromDraft = useCallback(
22822332 (fileId: string) => {
2333+ if (questionAttachmentTarget && activePendingIsResponding) return;
22832334 // Release by the draft attachment, not the bare queue key: a hydrated
22842335 // file's upload lives server-side under its persisted attachment id.
22852336 const file = composerFilesRef.current.find((candidate) => candidate.id === fileId);
@@ -2288,9 +2339,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2 unmodified lines
22882339 } else {
22892340 releaseAttachmentUpload(fileId);
22902341 }
2291− removeComposerDraftFile(composerDraftTarget, fileId);
2342+ removeComposerDraftFile(attachmentDraftTarget, fileId);
22922343 },
2293− [composerDraftTarget, composerFilesRef, removeComposerDraftFile],
2344+ [
2345+ attachmentDraftTarget,
2346+ questionAttachmentTarget,
2347+ activePendingIsResponding,
2348+ composerFilesRef,
2349+ removeComposerDraftFile,
2350+ ],
22942351 );
22952352
22962353 const removeComposerTerminalContextFromDraft = useCallback(
@@ -2525,11 +2582,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 228 unmodified lines
25252582 let cancelled = false;
25262583 void (async () => {
25272584 if (composerImages.length === 0) {
2528− clearComposerDraftPersistedAttachments(composerDraftTarget);
2585+ clearComposerDraftPersistedAttachments(attachmentDraftTarget);
25292586 return;
25302587 }
25312588 const getPersistedAttachmentsForThread = () =>
2532− getComposerDraft(composerDraftTarget)?.persistedAttachments ?? [];
2589+ getComposerDraft(attachmentDraftTarget)?.persistedAttachments ?? [];
25332590 try {
25342591 const currentPersistedAttachments = getPersistedAttachmentsForThread();
25352592 const existingPersistedById = new Map(
@@ -2558,7 +2615,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 22 unmodified lines
25582615 );
25592616 const serialized = Array.from(stagedAttachmentById.values());
25602617 if (cancelled) return;
2561− syncComposerDraftPersistedAttachments(composerDraftTarget, serialized);
2618+ syncComposerDraftPersistedAttachments(attachmentDraftTarget, serialized);
25622619 } catch {
25632620 const currentImageIds = new Set(composerImages.map((image) => image.id));
25642621 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
@@ -2573,14 +2630,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 8 unmodified lines
25732630 fallbackPersistedIdSet.has(attachment.id),
25742631 );
25752632 if (cancelled) return;
2576− syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments);
2633+ syncComposerDraftPersistedAttachments(attachmentDraftTarget, fallbackAttachments);
25772634 }
25782635 })();
25792636 return () => {
25802637 cancelled = true;
25812638 };
25822639 }, [
2583− composerDraftTarget,
2640+ attachmentDraftTarget,
25842641 clearComposerDraftPersistedAttachments,
25852642 composerImages,
25862643 getComposerDraft,
@@ -2946,7 +3003,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 359 unmodified lines
29463003 // image: the turn snapshot wouldn't include it, and it would surface
29473004 // in the *next* draft instead. Only oversized images hit this — small
29483005 // files clear the pending counter within a microtask.
2949− if (activeThreadId && (pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0) {
3006+ if (
3007+ activeThreadId &&
3008+ (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0
3009+ ) {
29503010 event?.preventDefault();
29513011 toastManager.add({
29523012 type: "info",
@@ -2976,6 +3036,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 23 unmodified lines
29763036 [
29773037 activeThreadId,
29783038 activePendingProgress,
3039+ attachmentTargetKey,
29793040 blurMobileComposerAfterSend,
29803041 isSendDisabled,
29813042 noProviderAvailable,
@@ -3009,7 +3070,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 27 unmodified lines
30093070 // The compact buttons cannot see the compression counter (it lives in
30103071 // a ref), so they render enabled during a paste; toast instead of
30113072 // silently ignoring the click.
3012− if ((pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0) {
3073+ if ((pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0) {
30133074 toastManager.add({
30143075 type: "info",
30153076 title: "Still compressing a pasted image.",
@@ -4255,10 +4316,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1239 unmodified lines
42554316 // ------------------------------------------------------------------
42564317 const addComposerAttachments = async (files: File[]) => {
42574318 if (!activeThreadId || files.length === 0) return;
4258− if (pendingUserInputs.length > 0) {
4319+ if (
4320+ pendingUserInputs.length > 0 &&
4321+ (!supportsQuestionAttachments ||
4322+ activePendingProgress?.activeQuestion?.allowCustomAnswer === false ||
4323+ activePendingIsResponding)
4324+ ) {
42594325 toastManager.add({
42604326 type: "error",
4261− title: "Attach files after answering pending questions.",
4327+ title: "This question cannot accept attachments.",
42624328 });
42634329 return;
42644330 }
@@ -4270,9 +4336,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 5 unmodified lines
42704336 // Validation happens synchronously so concurrent pastes see each other:
42714337 // accepted files reserve their attachment slots (via the pending counter)
42724338 // before the first await, keeping the total under the limit.
4273− const pendingCount = pendingImageCompressionsRef.current.get(threadId) ?? 0;
4339+ const pendingCount = pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0;
4340+ const otherQuestionAttachments =
4341+ questionAttachmentTarget && pendingUserInputs[0]
4342+ ? pendingUserInputs[0].questions.reduce((count, question) => {
4343+ const target = questionAttachmentDraftId(
4344+ environmentId,
4345+ threadId,
4346+ pendingUserInputs[0]!.requestId,
4347+ question.id,
4348+ );
4349+ if (target === questionAttachmentTarget) return count;
4350+ const draft = getComposerDraft(target);
4351+ return (
4352+ count +
4353+ (draft?.images.length ?? 0) +
4354+ (draft?.files.length ?? 0) +
4355+ (useQuestionAttachmentPreparation.getState().counts[target] ?? 0)
4356+ );
4357+ }, 0)
4358+ : 0;
42744359 let reservedCount =
4275− composerImagesRef.current.length + composerFilesRef.current.length + pendingCount;
4360+ composerImagesRef.current.length +
4361+ composerFilesRef.current.length +
4362+ pendingCount +
4363+ otherQuestionAttachments;
42764364 // A pick that matches a needs-reattach marker replaces it in the draft, so
42774365 // it must not consume a slot; a draft full of markers would otherwise hit
42784366 // the capacity error before the replacement path could run.
@@ -4351,7 +4439,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 72 unmodified lines
43514439 }
43524440 if (acceptedImages.length === 0) return;
43534441
4354− pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedImages.length);
4442+ pendingImageCompressionsRef.current.set(
4443+ attachmentTargetKey,
4444+ pendingCount + acceptedImages.length,
4445+ );
4446+ if (questionAttachmentTarget)
4447+ changeQuestionAttachmentPreparation(questionAttachmentTarget, acceptedImages.length);
43554448 try {
43564449 const nextImages: ComposerImageAttachment[] = [];
43574450 let compressionError: string | null = null;
@@ -4381,6 +4474,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 23 unmodified lines
43814474 file: attachmentFile,
43824475 });
43834476 }
4477+ if (
4478+ questionAttachmentTarget &&
4479+ !useQuestionAttachmentPreparation.getState().counts[questionAttachmentTarget]
4480+ ) {
4481+ for (const image of nextImages) URL.revokeObjectURL(image.previewUrl);
4482+ return;
4483+ }
43844484 if (nextImages.length === 1 && nextImages[0]) {
43854485 addComposerImage(nextImages[0]);
43864486 } else if (nextImages.length > 1) {
@@ -4394,12 +4494,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 7 unmodified lines
43944494 setThreadError(threadId, compressionError);
43954495 }
43964496 } finally {
4497+ if (questionAttachmentTarget)
4498+ changeQuestionAttachmentPreparation(questionAttachmentTarget, -acceptedImages.length);
43974499 const remaining =
4398− (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedImages.length;
4500+ (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) - acceptedImages.length;
43994501 if (remaining > 0) {
4400− pendingImageCompressionsRef.current.set(threadId, remaining);
4502+ pendingImageCompressionsRef.current.set(attachmentTargetKey, remaining);
44014503 } else {
4402− pendingImageCompressionsRef.current.delete(threadId);
4504+ pendingImageCompressionsRef.current.delete(attachmentTargetKey);
44034505 }
44044506 }
44054507 };
@@ -4909,7 +5011,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 503 unmodified lines
49095011 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
49105012 <ComposerPendingUserInputPanel
49115013 pendingUserInputs={pendingUserInputs}
4912− respondingRequestIds={respondingRequestIds}
5014+ respondingRequestIds={
5015+ activePendingIsResponding && activePendingUserInput
5016+ ? [...respondingRequestIds, activePendingUserInput.requestId]
5017+ : respondingRequestIds
5018+ }
49135019 answers={activePendingDraftAnswers}
49145020 questionIndex={activePendingQuestionIndex}
49155021 onToggleOption={onSelectActivePendingUserInputOption}
@@ -4925,7 +5031,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 9 unmodified lines
49255031 <div data-chat-composer-collapsed-controls="true">
49265032 <ComposerPendingUserInputPanel
49275033 pendingUserInputs={pendingUserInputs}
4928− respondingRequestIds={respondingRequestIds}
5034+ respondingRequestIds={
5035+ activePendingIsResponding && activePendingUserInput
5036+ ? [...respondingRequestIds, activePendingUserInput.requestId]
5037+ : respondingRequestIds
5038+ }
49295039 answers={activePendingDraftAnswers}
49305040 questionIndex={activePendingQuestionIndex}
49315041 onToggleOption={onSelectActivePendingUserInputOption}
@@ -5148,7 +5258,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 216 unmodified lines
51485258 retryAttachmentUpload({
51495259 environmentId,
51505260 image,
5151− draftTarget: composerDraftTarget,
5261+ draftTarget: attachmentDraftTarget,
51525262 }),
51535263 }
51545264 : {})}
@@ -5192,7 +5302,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 37 unmodified lines
51925302
51935303 {!isComposerCollapsedMobile &&
51945304 !isComposerApprovalState &&
5195− pendingUserInputs.length === 0 &&
51965305 (uncommittedSnapShotIds.length > 0 ||
51975306 composerVideos.length > 0 ||
51985307 expandedComposerImages.length > 0) && (
@@ -5310,7 +5419,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 111 unmodified lines
53105419 retryAttachmentUpload({
53115420 environmentId,
53125421 image,
5313− draftTarget: composerDraftTarget,
5422+ draftTarget: attachmentDraftTarget,
53145423 })
53155424 }
53165425 aria-label={`Retry upload for ${image.name}`}
@@ -5410,7 +5519,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 93 unmodified lines
54105519 retryAttachmentUpload({
54115520 environmentId,
54125521 image: file,
5413− draftTarget: composerDraftTarget,
5522+ draftTarget: attachmentDraftTarget,
54145523 })
54155524 }
54165525 aria-label={`Retry upload for ${file.name}`}
@@ -5444,7 +5553,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 27 unmodified lines
54445553
54455554 {!isComposerCollapsedMobile &&
54465555 !isComposerApprovalState &&
5447− pendingUserInputs.length === 0 &&
54485556 composerOtherFiles.length > 0 && (
54495557 <div className="mb-3 flex flex-col gap-1">
54505558 {composerOtherFiles.map((file) => {
@@ -5487,7 +5595,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 36 unmodified lines
54875595 retryAttachmentUpload({
54885596 environmentId,
54895597 image: file,
5490− draftTarget: composerDraftTarget,
5598+ draftTarget: attachmentDraftTarget,
54915599 })
54925600 }
54935601 aria-label={`Retry upload for ${file.name}`}
@@ -5587,7 +5695,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 93 unmodified lines
55875695 isConnecting ||
55885696 isComposerApprovalState ||
55895697 projectSelectionRequired ||
5590− isChoiceOnlyPendingQuestion
5698+ isChoiceOnlyPendingQuestion ||
5699+ activePendingIsResponding
55915700 }
55925701 />
55935702 {isComposerResting ? collapsedComposerImagePreviews : null}
apps/web/src/components/chat/MessagesTimeline.test.tsx+76−1
@@ -1,4 +1,10 @@
1−import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";
1+import {
2+ ApprovalRequestId,
3+ CheckpointRef,
4+ EnvironmentId,
5+ MessageId,
6+ TurnId,
7+} from "@t3tools/contracts";
28 import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react";
39 import { renderToStaticMarkup } from "react-dom/server";
410 import { create, type ReactTestRenderer } from "react-test-renderer";
@@ -266,6 +272,75 @@ function buildSnapShotTimelineEntry(previewUrl?: string) { · 261 unmodified lines
266272 }
267273
268274 describe("MessagesTimeline", () => {
275+ // Expanding history uses this suite's existing test renderer, deprecated in
276+ // React 19. Migrate these interaction tests together when a DOM test setup is added.
277+ it.each([{}, { text: "Text-only answer", file: "Answer with a file" }])(
278+ "renders attachment-only question history alongside text answers: %j",
279+ async (answers) => {
280+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
281+ vi.stubGlobal("requestAnimationFrame", () => 0);
282+ vi.stubGlobal("cancelAnimationFrame", () => {});
283+ let renderer: ReactTestRenderer | undefined;
284+ try {
285+ await act(() => {
286+ renderer = create(
287+ <MessagesTimeline
288+ {...buildProps()}
289+ timelineEntries={[
290+ {
291+ id: "answer-entry",
292+ kind: "work",
293+ createdAt: MESSAGE_CREATED_AT,
294+ entry: {
295+ id: "answer-work",
296+ createdAt: MESSAGE_CREATED_AT,
297+ label: "Question answer submitted",
298+ tone: "info",
299+ questionAnswer: {
300+ requestId: ApprovalRequestId.make("question-request"),
301+ answers,
302+ questionTextById: { file: "Provide a spec", image: "Provide a screenshot" },
303+ attachmentsByQuestionId: {
304+ file: [
305+ {
306+ type: "file",
307+ id: "spec",
308+ name: "spec.txt",
309+ mimeType: "text/plain",
310+ sizeBytes: 4,
311+ },
312+ ],
313+ image: [
314+ {
315+ type: "image",
316+ id: "shot",
317+ name: "shot.png",
318+ mimeType: "image/png",
319+ sizeBytes: 4,
320+ },
321+ ],
322+ },
323+ },
324+ },
325+ },
326+ ]}
327+ />,
328+ );
329+ });
330+ const toggle = renderer!.root.findByProps({ "aria-expanded": false });
331+ await act(() => toggle.props.onClick());
332+ const markup = JSON.stringify(renderer!.toJSON());
333+ expect(markup.match(/Provide a spec/g)).toHaveLength(1);
334+ expect(markup.match(/spec\.txt/g)).toHaveLength(1);
335+ expect(markup).toContain("Provide a screenshot");
336+ expect(markup).toContain("shot.png");
337+ for (const answer of Object.values(answers)) expect(markup).toContain(answer);
338+ } finally {
339+ await act(() => renderer?.unmount());
340+ }
341+ },
342+ );
343+
269344 it.each([
270345 { toolLifecycleStatus: "inProgress", isAtEnd: true },
271346 { toolLifecycleStatus: "inProgress", isAtEnd: false },
apps/web/src/components/chat/MessagesTimeline.tsx+70−1
@@ -3216,7 +3216,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { · 3215 unmodified lines
32163216 showWarningIndicator || showDestructiveRowStyle
32173217 ? undefined
32183218 : (workEntry.toolIcon ?? workEntry.toolSource?.icon);
3219− const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot);
3219+ const previewText = workEntry.questionAnswer
3220+ ? "Question answer submitted"
3221+ : (displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot));
32203222 const viewedImagePath = workEntryViewedImagePath(workEntry);
32213223 const viewedImage =
32223224 viewedImagePath && threadRef
@@ -3362,6 +3364,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { · 139 unmodified lines
33623364 />
33633365 </div>
33643366 ) : null}
3367+ {workEntry.questionAnswer ? (
3368+ <QuestionAnswerHistory answer={workEntry.questionAnswer} />
3369+ ) : null}
33653370 {expanded && canExpand && expandedBody ? (
33663371 <div
33673372 className="mt-1 ms-7 cursor-default rounded-md bg-muted/40 px-3 py-2"
@@ -3374,3 +3379,67 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { · 6 unmodified lines
33743379 </div>
33753380 );
33763381 });
3382+
3383+function QuestionAnswerHistory({
3384+ answer,
3385+}: {
3386+ answer: import("@t3tools/contracts").UserInputAttachmentAnswerPayload;
3387+}) {
3388+ const { activeThreadEnvironmentId } = use(TimelineRowCtx);
3389+ const attachments = useMemo(() => Object.values(answer.attachmentsByQuestionId).flat(), [answer]);
3390+ const resources = useMemo(
3391+ () =>
3392+ attachments.map((attachment) => ({
3393+ _tag: "attachment" as const,
3394+ attachmentId: attachment.id,
3395+ })),
3396+ [attachments],
3397+ );
3398+ const urls = useAssetUrls(activeThreadEnvironmentId, resources);
3399+ return (
3400+ <div className="ms-7 mt-2 space-y-2" onClick={stopRowToggle}>
3401+ {[
3402+ ...new Set([
3403+ ...Object.keys(answer.answers),
3404+ ...Object.keys(answer.attachmentsByQuestionId),
3405+ ]),
3406+ ].map((questionId) => (
3407+ <div key={questionId} className="space-y-1">
3408+ {answer.questionTextById?.[questionId] ? (
3409+ <p className="text-sm text-muted-foreground">{answer.questionTextById[questionId]}</p>
3410+ ) : null}
3411+ <p className="whitespace-pre-wrap text-sm">
3412+ {[answer.answers[questionId]]
3413+ .flat()
3414+ .filter((value): value is string => typeof value === "string")
3415+ .join(", ")}
3416+ </p>
3417+ <div className="flex flex-wrap gap-2">
3418+ {(answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => {
3419+ const url = urls[attachments.indexOf(attachment)];
3420+ return (
3421+ <a
3422+ key={attachment.id}
3423+ href={url ?? undefined}
3424+ target="_blank"
3425+ rel="noreferrer"
3426+ className="text-sm underline"
3427+ >
3428+ {attachment.type === "image" && url ? (
3429+ <img
3430+ src={url}
3431+ alt={attachment.name}
3432+ className="h-20 max-w-32 rounded object-contain"
3433+ />
3434+ ) : (
3435+ attachment.name
3436+ )}
3437+ </a>
3438+ );
3439+ })}
3440+ </div>
3441+ </div>
3442+ ))}
3443+ </div>
3444+ );
3445+}
apps/web/src/lib/attachmentUploadQueue.test.ts+26−0
@@ -339,6 +339,32 @@ describe("attachmentUploadQueue", () => { · 338 unmodified lines
339339 }
340340 });
341341
342+ it("persists a retried question upload without a mounted composer", async () => {
343+ const draftId = DraftId.make("question-retry-upload");
344+ const file = makeFile("question-retry");
345+ const store = useComposerDraftStore.getState();
346+ store.addFiles(draftId, [file]);
347+ try {
348+ startAttachmentUpload({ environmentId: firstEnvironment, image: file, draftTarget: draftId });
349+ await Promise.resolve();
350+ let settled = awaitAttachmentUploads([file.id]);
351+ TestXmlHttpRequest.requests[0]!.complete(500);
352+ await settled;
353+ expect(store.getComposerDraft(draftId)?.files[0]?.uploadedAttachmentId).toBeUndefined();
354+ retryAttachmentUpload({ environmentId: firstEnvironment, image: file, draftTarget: draftId });
355+ await Promise.resolve();
356+ settled = awaitAttachmentUploads([file.id]);
357+ TestXmlHttpRequest.requests[1]!.complete();
358+ await settled;
359+ expect(store.getComposerDraft(draftId)?.files[0]).toMatchObject({
360+ uploadedAttachmentId: "pending-environment-1-question-retry.pdf",
361+ uploadEnvironmentId: firstEnvironment,
362+ });
363+ } finally {
364+ store.clearComposerContent(draftId);
365+ }
366+ });
367+
342368 it("verifies an uploaded file reference before restoring it", async () => {
343369 const file: ComposerFileAttachment = {
344370 ...makeFile("restored"),
apps/web/src/pendingUserInput.test.ts+19−0
@@ -302,3 +302,22 @@ describe("pending user input question progress", () => { · 301 unmodified lines
302302 });
303303 });
304304 });
305+
306+it("accepts attachment-only answers after every upload finishes", () => {
307+ const questions = [
308+ { id: "spec", header: "Spec", question: "Provide a spec", options: [], multiSelect: false },
309+ ];
310+ expect(buildPendingUserInputAnswers(questions, { spec: { attachmentCount: 1 } })).toEqual({
311+ spec: "",
312+ });
313+ expect(
314+ buildPendingUserInputAnswers(questions, {
315+ spec: { attachmentCount: 1, attachmentsBlocked: true },
316+ }),
317+ ).toBeNull();
318+ expect(
319+ buildPendingUserInputAnswers([{ ...questions[0]!, allowCustomAnswer: false }], {
320+ spec: { attachmentCount: 1 },
321+ }),
322+ ).toBeNull();
323+});
apps/web/src/pendingUserInput.ts+12−2
@@ -3,6 +3,8 @@ import type { UserInputQuestion } from "@t3tools/contracts"; · 2 unmodified lines
33 export interface PendingUserInputDraftAnswer {
44 selectedOptionValues?: string[];
55 customAnswer?: string;
6+ attachmentCount?: number;
7+ attachmentsBlocked?: boolean;
68 }
79
810 export interface PendingUserInputProgress {
@@ -41,6 +43,7 @@ export function resolvePendingUserInputAnswer( · 32 unmodified lines
4143 question: UserInputQuestion,
4244 draft: PendingUserInputDraftAnswer | undefined,
4345 ): string | string[] | null {
46+ if (draft?.attachmentsBlocked) return null;
4447 const customAnswer =
4548 question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);
4649 if (customAnswer) {
@@ -51,10 +54,17 @@ export function resolvePendingUserInputAnswer( · 4 unmodified lines
5154 (value) => question.options.some((option) => (option.value ?? option.label) === value),
5255 );
5356 if (question.multiSelect) {
54− return selectedOptionValues.length > 0 ? selectedOptionValues : null;
57+ return selectedOptionValues.length > 0
58+ ? selectedOptionValues
59+ : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0
60+ ? ""
61+ : null;
5562 }
5663
57− return selectedOptionValues[0] ?? null;
64+ return (
65+ selectedOptionValues[0] ??
66+ (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)
67+ );
5868 }
5969
6070 export function setPendingUserInputCustomAnswer(
apps/web/src/questionAttachments.test.ts+100−0
@@ -0,0 +1,100 @@
1+import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
2+import { beforeEach, expect, it, vi } from "vite-plus/test";
3+import { useComposerDraftStore } from "./composerDraftStore";
4+import {
5+ questionAttachmentDraftId,
6+ questionAttachmentDraftPrefix,
7+ changeQuestionAttachmentPreparation,
8+ clearQuestionAttachmentDraft,
9+ useQuestionAttachmentPreparation,
10+} from "./questionAttachments";
11+
12+const release = vi.hoisted(() => vi.fn());
13+vi.mock("./lib/attachmentUploadQueue", () => ({ releaseDraftAttachments: release }));
14+const environmentId = EnvironmentId.make("environment-1");
15+const threadId = ThreadId.make("thread-1");
16+const requestId = ApprovalRequestId.make("request-1");
17+beforeEach(() => {
18+ useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} });
19+ useQuestionAttachmentPreparation.setState({ counts: {} });
20+ release.mockClear();
21+});
22+it("keeps question files separate from the normal draft and other questions", () => {
23+ const revoke = vi.spyOn(URL, "revokeObjectURL");
24+ const store = useComposerDraftStore.getState();
25+ const first = questionAttachmentDraftId(environmentId, threadId, requestId, "first");
26+ const second = questionAttachmentDraftId(environmentId, threadId, requestId, "second");
27+ store.setPrompt({ environmentId, threadId }, "Unsent prompt");
28+ const image = {
29+ type: "image" as const,
30+ id: "image-1",
31+ name: "image.png",
32+ mimeType: "image/png",
33+ sizeBytes: 5,
34+ previewUrl: "blob:question-image",
35+ file: new File(["image"], "image.png", { type: "image/png" }),
36+ };
37+ store.addImages(first, [image]);
38+ store.addFiles(second, [
39+ {
40+ type: "file",
41+ id: "file-1",
42+ name: "spec.txt",
43+ mimeType: "text/plain",
44+ sizeBytes: 4,
45+ file: new File(["spec"], "spec.txt", { type: "text/plain" }),
46+ },
47+ ]);
48+ changeQuestionAttachmentPreparation(first, 1);
49+ expect(store.getComposerDraft(first)?.images).toHaveLength(1);
50+ expect(store.getComposerDraft(second)?.files).toHaveLength(1);
51+ clearQuestionAttachmentDraft(first);
52+ expect(store.getComposerDraft(first)).toBeNull();
53+ expect(store.getComposerDraft(second)?.files).toHaveLength(1);
54+ expect(store.getComposerDraft({ environmentId, threadId })?.prompt).toBe("Unsent prompt");
55+ expect(useQuestionAttachmentPreparation.getState().counts[first]).toBeUndefined();
56+ expect(release).toHaveBeenCalledWith([image]);
57+ expect(revoke).toHaveBeenCalledWith(image.previewUrl);
58+ revoke.mockRestore();
59+});
60+it("scopes provider request ids to their environment and thread", () => {
61+ const keys = [
62+ questionAttachmentDraftId(environmentId, threadId, requestId, "q"),
63+ questionAttachmentDraftId(EnvironmentId.make("environment-2"), threadId, requestId, "q"),
64+ questionAttachmentDraftId(environmentId, ThreadId.make("thread-2"), requestId, "q"),
65+ ];
66+ expect(new Set(keys).size).toBe(3);
67+});
68+it("does not match drafts belonging to threads with a shared id prefix", () => {
69+ const prefix = questionAttachmentDraftPrefix(environmentId, threadId);
70+ expect(
71+ questionAttachmentDraftId(environmentId, threadId, requestId, "q").startsWith(prefix),
72+ ).toBe(true);
73+ for (const suffix of ["-extra", ":extra", "/extra", "%extra"]) {
74+ expect(
75+ questionAttachmentDraftId(
76+ environmentId,
77+ ThreadId.make(`${threadId}${suffix}`),
78+ requestId,
79+ "q",
80+ ).startsWith(prefix),
81+ ).toBe(false);
82+ }
83+});
84+
85+it("does not clear another environment whose id contains a question prefix", () => {
86+ const otherEnvironment = EnvironmentId.make(
87+ `${environmentId}:question-${encodeURIComponent(JSON.stringify(threadId))}-nested`,
88+ );
89+ const otherKey = questionAttachmentDraftId(otherEnvironment, threadId, requestId, "q");
90+ const ownKey = questionAttachmentDraftId(environmentId, threadId, requestId, "q");
91+ const store = useComposerDraftStore.getState();
92+ store.setPrompt(otherKey, "Keep this answer");
93+ store.setPrompt(ownKey, "Discard this answer");
94+ const prefix = questionAttachmentDraftPrefix(environmentId, threadId);
95+ for (const key of [ownKey, otherKey]) {
96+ if (key.startsWith(prefix)) clearQuestionAttachmentDraft(key);
97+ }
98+ expect(store.getComposerDraft(ownKey)).toBeNull();
99+ expect(store.getComposerDraft(otherKey)?.prompt).toBe("Keep this answer");
100+});
apps/web/src/questionAttachments.ts+53−0
@@ -0,0 +1,53 @@
1+import type { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
2+import { create } from "zustand";
3+import { DraftId, useComposerDraftStore } from "./composerDraftStore";
4+import { releaseDraftAttachments } from "./lib/attachmentUploadQueue";
5+
6+export function questionAttachmentDraftPrefix(
7+ environmentId: EnvironmentId,
8+ threadId: ThreadId,
9+): string {
10+ return `${encodeURIComponent(JSON.stringify(environmentId))}:question-${encodeURIComponent(JSON.stringify(threadId))}-`;
11+}
12+
13+export function questionAttachmentDraftId(
14+ environmentId: EnvironmentId,
15+ threadId: ThreadId,
16+ requestId: ApprovalRequestId,
17+ questionId: string,
18+): DraftId {
19+ return DraftId.make(
20+ `${questionAttachmentDraftPrefix(environmentId, threadId)}${encodeURIComponent(JSON.stringify([requestId, questionId]))}`,
21+ );
22+}
23+
24+export const useQuestionAttachmentPreparation = create<{ counts: Record<string, number> }>(() => ({
25+ counts: {},
26+}));
27+
28+export function changeQuestionAttachmentPreparation(key: DraftId, delta: number): void {
29+ useQuestionAttachmentPreparation.setState((state) =>
30+ delta < 0 && !(key in state.counts)
31+ ? state
32+ : {
33+ counts: { ...state.counts, [key]: Math.max(0, (state.counts[key] ?? 0) + delta) },
34+ },
35+ );
36+}
37+
38+export function clearQuestionAttachmentDraft(key: DraftId): void {
39+ const store = useComposerDraftStore.getState();
40+ const draft = store.getComposerDraft(key);
41+ if (draft) {
42+ releaseDraftAttachments([...draft.images, ...draft.files]);
43+ for (const image of draft.images) {
44+ if (image.previewUrl.startsWith("blob:")) URL.revokeObjectURL(image.previewUrl);
45+ }
46+ }
47+ store.clearComposerContent(key);
48+ useQuestionAttachmentPreparation.setState(({ counts }) => {
49+ const next = { ...counts };
50+ delete next[key];
51+ return { counts: next };
52+ });
53+}
apps/web/src/session-logic.ts+9−0
@@ -2,7 +2,9 @@ import { · 1 unmodified line
22 requestKindFromRequestType,
33 type PendingApproval,
44 } from "@t3tools/client-runtime/pending-requests";
5+import { UserInputAttachmentAnswerPayload } from "@t3tools/contracts";
56 import * as Option from "effect/Option";
7+import * as Schema from "effect/Schema";
68 import * as Arr from "effect/Array";
79 import { shallow } from "zustand/vanilla/shallow";
810 import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
@@ -51,6 +53,7 @@ export { · 42 unmodified lines
5153 } from "@t3tools/client-runtime/work-log/presentation";
5254
5355 export interface WorkLogEntry {
56+ questionAnswer?: UserInputAttachmentAnswerPayload;
5457 id: string;
5558 createdAt: string;
5659 turnId?: TurnId | null;
@@ -492,6 +495,8 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool · 435 unmodified lines
492495 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
493496 }
494497
498+const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload);
499+
495500 function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
496501 const cachedEntry = derivedWorkLogEntryByActivity.get(activity);
497502 if (cachedEntry) {
@@ -543,6 +548,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo · 45 unmodified lines
543548 : activity.tone,
544549 sourceActivityKind: activity.kind,
545550 };
551+ if (activity.kind === "user-input.answer-submitted") {
552+ const answer = decodeQuestionAttachmentAnswer(payload);
553+ if (Option.isSome(answer)) entry.questionAnswer = answer.value;
554+ }
546555 const itemType = extractWorkLogItemType(payload);
547556 const requestKind = extractWorkLogRequestKind(payload);
548557 const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath);
docs/user/question-attachments.md+9−0
@@ -0,0 +1,9 @@
1+# Files in question answers
2+
3+When an agent asks a question that accepts a custom answer, use **Attach files** or paste an image into the answer field. On mobile, use the attachment button to choose photos or files. You can send a file by itself, with a selected option, or with a typed answer.
4+
5+Each question keeps its own attachments as you move between questions. Your normal prompt draft stays separate. Wait for uploads to finish before submitting; retry or remove any failed upload. A failed response keeps the draft available to try again.
6+
7+Files upload to the environment running the thread, including remote environments. The agent receives paths to those saved files with your answer and can open them with its available tools. Questions that only accept predefined choices do not offer attachments. Older servers need updating before this option appears.
8+
9+Submitted attachments remain in the thread history. Reverting a thread removes attachments belonging to discarded answers; deleting the thread uses the same file cleanup as other attachments. You can attach up to eight files across one set of answers, using the same file size limits as the normal composer.
packages/contracts/src/environment.ts+2−0
@@ -80,6 +80,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ · 79 unmodified lines
8080 connectionProbe: Schema.optionalKey(Schema.Boolean),
8181 /** Missing on older servers, which still accept inline image attachments. */
8282 attachmentUploads: Schema.optionalKey(Schema.Boolean),
83+ /** Uploaded files may accompany question answers. */
84+ questionAttachments: Schema.optionalKey(Schema.Boolean),
8385 /** Missing on servers that only accept image attachments. */
8486 fileAttachments: Schema.optionalKey(
8587 Schema.Struct({
packages/contracts/src/orchestration.ts+18−0
@@ -354,6 +354,22 @@ export const ChatAttachment = Schema.Union([ · 353 unmodified lines
354354 ChatUnknownAttachment,
355355 ]);
356356 export type ChatAttachment = typeof ChatAttachment.Type;
357+
358+export const UserInputAttachments = Schema.Record(
359+ Schema.String,
360+ Schema.Array(Schema.Union([ChatImageAttachment, ChatFileAttachment])).pipe(
361+ Schema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
362+ ),
363+);
364+export type UserInputAttachments = typeof UserInputAttachments.Type;
365+
366+export const UserInputAttachmentAnswerPayload = Schema.Struct({
367+ requestId: ApprovalRequestId,
368+ questionTextById: Schema.optional(Schema.Record(Schema.String, Schema.String)),
369+ answers: ProviderUserInputAnswers,
370+ attachmentsByQuestionId: UserInputAttachments,
371+});
372+export type UserInputAttachmentAnswerPayload = typeof UserInputAttachmentAnswerPayload.Type;
357373 const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]);
358374 export type UploadChatAttachment = typeof UploadChatAttachment.Type;
359375
@@ -1139,6 +1155,7 @@ const ThreadUserInputRespondCommand = Schema.Struct({ · 779 unmodified lines
11391155 threadId: ThreadId,
11401156 requestId: ApprovalRequestId,
11411157 answers: ProviderUserInputAnswers,
1158+ attachmentsByQuestionId: Schema.optional(UserInputAttachments),
11421159 createdAt: IsoDateTime,
11431160 });
11441161
@@ -1585,6 +1602,7 @@ const ThreadUserInputResponseRequestedPayload = Schema.Struct({ · 440 unmodified lines
15851602 threadId: ThreadId,
15861603 requestId: ApprovalRequestId,
15871604 answers: ProviderUserInputAnswers,
1605+ attachmentsByQuestionId: Schema.optional(UserInputAttachments),
15881606 createdAt: IsoDateTime,
15891607 });
15901608
packages/contracts/src/provider.ts+2−0
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
1919 ProviderRequestKind,
2020 ProviderSandboxMode,
2121 ProviderUserInputAnswers,
22+ UserInputAttachments,
2223 RuntimeMode,
2324 } from "./orchestration.ts";
2425 import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";
@@ -110,6 +111,7 @@ export const ProviderRespondToUserInputInput = Schema.Struct({ · 85 unmodified lines
110111 threadId: ThreadId,
111112 requestId: ApprovalRequestId,
112113 answers: ProviderUserInputAnswers,
114+ attachmentsByQuestionId: Schema.optional(UserInputAttachments),
113115 });
114116 export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type;
115117
pnpm-lock.yaml+3−0
@@ -473,6 +473,9 @@ importers: · 472 unmodified lines
473473 '@types/react':
474474 specifier: ~19.2.0
475475 version: 19.2.16
476+ '@types/react-dom':
477+ specifier: ~19.2.3
478+ version: 19.2.3(@types/react@19.2.16)
476479 babel-preset-expo:
477480 specifier: ~57.0.9
478481 version: 57.0.9(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@57.0.15)(expo@57.0.18)(react-refresh@0.14.2)