authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-09-08 12:13:38+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 12:13:38+05:30
log7220dfe2c949476eaa7d21eccbcd3a0ce0eddb49
treeec8f6c6fba758f61a36606fa123b53837d4edd84
parent349ce3014233352e073a5fc1b3f12bd786160913
signature Signed by PGP key B5690EEEBB952194

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


40 files changed, 2315 insertions(+), 153 deletions(-)

apps/mobile/package.json+1
...@@ -128,6 +128,7 @@...@@ -128,6 +128,7 @@
128 "@effect/vitest": "catalog:",128 "@effect/vitest": "catalog:",
129 "@pierre/trees": "1.0.0-beta.4",129 "@pierre/trees": "1.0.0-beta.4",
130 "@types/react": "~19.2.0",130 "@types/react": "~19.2.0",
131 "@types/react-dom": "~19.2.3",
131 "babel-preset-expo": "~57.0.9",132 "babel-preset-expo": "~57.0.9",
132 "tailwindcss": "^4.0.0",133 "tailwindcss": "^4.0.0",
133 "typescript": "catalog:"134 "typescript": "catalog:"
apps/mobile/src/features/threads/PendingUserInputCard.tsx+13-13
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { QuestionAttachments } from "./QuestionAttachments";
1import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts";2import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts";
2import { useCallback, useRef } from "react";3import { useCallback, useRef } from "react";
3import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native";4import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native";
...@@ -15,7 +16,7 @@ import Animated, {...@@ -15,7 +16,7 @@ import Animated, {
15import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout";16import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout";
1617
17import { SymbolView } from "../../components/AppSymbol";18import { SymbolView } from "../../components/AppSymbol";
18import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";19import { AppText as Text } from "../../components/AppText";
19import { ControlPill } from "../../components/ControlPill";20import { ControlPill } from "../../components/ControlPill";
20import { cn } from "../../lib/cn";21import { cn } from "../../lib/cn";
21import {22import {
...@@ -305,18 +306,17 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {...@@ -305,18 +306,17 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
305 );306 );
306 })}307 })}
307 </View>308 </View>
308 {question.allowCustomAnswer !== false ? (309 <QuestionAttachments
309 <TextInput310 requestId={props.pendingUserInput.requestId}
310 value={draft?.customAnswer ?? ""}311 question={question}
311 onChangeText={(value) =>312 questions={props.pendingUserInput.questions}
312 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)313 disabled={props.respondingUserInputId === props.pendingUserInput.requestId}
313 }314 value={draft?.customAnswer ?? ""}
314 onFocus={() => props.onInputFocusChange?.(true)}315 onChangeText={(value) =>
315 onBlur={() => props.onInputFocusChange?.(false)}316 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
316 placeholder="Or type a custom answer"317 }
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 onInputFocusChange={props.onInputFocusChange}
318 />319 />
319 ) : null}
320 </View>320 </View>
321 );321 );
322 })}322 })}
apps/mobile/src/features/threads/QuestionAnswerHistory.test.tsx created+58
...@@ -0,0 +1,58 @@
1import { ApprovalRequestId, EnvironmentId } from "@t3tools/contracts";
2import type { ReactNode } from "react";
3import { renderToStaticMarkup } from "react-dom/server";
4import { describe, expect, it, vi } from "vite-plus/test";
5
6vi.mock("react-native", () => ({
7 View: "div",
8 Pressable: ({ children }: { children: ReactNode }) => <button>{children}</button>,
9 Image: "img",
10 Linking: { openURL: vi.fn() },
11}));
12vi.mock("../../components/AppText", () => ({ AppText: "span" }));
13vi.mock("../../state/assets", () => ({ useAssetUrl: () => null }));
14
15import { QuestionAnswerHistory } from "./QuestionAnswerHistory";
16
17describe("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 created+71
...@@ -0,0 +1,71 @@
1import type {
2 EnvironmentId,
3 UserInputAttachmentAnswerPayload,
4 UserInputAttachments,
5} from "@t3tools/contracts";
6import { Image, Linking, Pressable, View } from "react-native";
7import { AppText as Text } from "../../components/AppText";
8import { useAssetUrl } from "../../state/assets";
9
10function 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
36export 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 created+183
...@@ -0,0 +1,183 @@
1import { TextInputWrapper } from "expo-paste-input";
2import { AppTextInput as TextInput } from "../../components/AppText";
3import { useNativePaste } from "../../lib/useNativePaste";
4import { convertPastedImagesToAttachments } from "../../lib/composerImages";
5import {
6 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
7 type ApprovalRequestId,
8 type UserInputQuestion,
9} from "@t3tools/contracts";
10import { useAtomValue } from "@effect/atom-react";
11import { Alert, View } from "react-native";
12import { useEffect, useRef } from "react";
13import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
14import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
15import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages";
16import { useThreadSelection } from "../../state/use-thread-selection";
17import { useServerConfigs } from "../../state/entities";
18import { appAtomRegistry } from "../../state/atom-registry";
19import {
20 appendComposerDraftAttachments,
21 composerDraftsAtom,
22 removeComposerDraftAttachment,
23 releaseUnusedComposerAttachmentFiles,
24} from "../../state/use-composer-drafts";
25import {
26 changeQuestionAttachmentPreparation,
27 questionAttachmentDraftKey,
28 questionAttachmentPreparationAtom,
29} from "../../state/question-attachments";
30
31export 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,3 +1,4 @@
1import { QuestionAnswerHistory } from "./QuestionAnswerHistory";
1import * as Haptics from "expo-haptics";2import * as Haptics from "expo-haptics";
2import { Image } from "expo-image";3import { Image } from "expo-image";
3import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";4import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
...@@ -849,13 +850,19 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -849,13 +850,19 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
849 </View>850 </View>
850 </Pressable>851 </Pressable>
851852
852 {expanded && (fullDetail || viewedImagePath) ? (853 {expanded && (fullDetail || viewedImagePath || row.workEntry.questionAnswer) ? (
853 <Animated.View854 <Animated.View
854 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}855 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
855 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}856 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
856 layout={WORK_LOG_LAYOUT_TRANSITION}857 layout={WORK_LOG_LAYOUT_TRANSITION}
857 className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5"858 className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5"
858 >859 >
860 {row.workEntry.questionAnswer ? (
861 <QuestionAnswerHistory
862 environmentId={props.environmentId}
863 answer={row.workEntry.questionAnswer}
864 />
865 ) : null}
859 {viewedImagePath ? (866 {viewedImagePath ? (
860 <View className="pb-1.5">867 <View className="pb-1.5">
861 {props.renderImage({ href: viewedImagePath, alt: null, title: null })}868 {props.renderImage({ href: viewedImagePath, alt: null, title: null })}
apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts+26-1
...@@ -1,5 +1,10 @@...@@ -1,5 +1,10 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";2import { describe, expect, it, vi } from "vite-plus/test";
3import {
4 questionAttachmentDraftKey,
5 questionAttachmentDraftPrefix,
6} from "../state/question-attachments";
7vi.mock("../state/atom-registry", () => ({ appAtomRegistry: {} }));
38
4import {9import {
5 composerAttachmentUploadBlockReason,10 composerAttachmentUploadBlockReason,
...@@ -217,6 +222,26 @@ describe("composer attachment upload queue", () => {...@@ -217,6 +222,26 @@ describe("composer attachment upload queue", () => {
217});222});
218223
219describe("draft upload scope and offline submission", () => {224describe("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 });
220 it("resolves thread, new-task, and queued-task drafts without crossing environments", () => {245 it("resolves thread, new-task, and queued-task drafts without crossing environments", () => {
221 expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId);246 expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId);
222 expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId);247 expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId);
apps/mobile/src/lib/threadActivity.test.ts+70
...@@ -2,6 +2,7 @@ import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"...@@ -2,6 +2,7 @@ import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import {4import {
5 ApprovalRequestId,
5 EventId,6 EventId,
6 MessageId,7 MessageId,
7 ProjectId,8 ProjectId,
...@@ -3314,3 +3315,72 @@ describe("quiet timeline: nested agents", () => {...@@ -3314,3 +3315,72 @@ describe("quiet timeline: nested agents", () => {
3314 ]);3315 ]);
3315 });3316 });
3316});3317});
3318
3319it("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
3347it("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,8 +1,10 @@
1import * as Option from "effect/Option";
2import * as Schema from "effect/Schema";
1import {3import {
2 requestKindFromRequestType,4 requestKindFromRequestType,
3 type PendingApproval,5 type PendingApproval,
4} from "@t3tools/client-runtime/pending-requests";6} from "@t3tools/client-runtime/pending-requests";
5import { isToolLifecycleItemType } from "@t3tools/contracts";7import { UserInputAttachmentAnswerPayload, isToolLifecycleItemType } from "@t3tools/contracts";
6import type {8import type {
7 OrchestrationLatestTurn,9 OrchestrationLatestTurn,
8 OrchestrationThread,10 OrchestrationThread,
...@@ -41,6 +43,8 @@ export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/...@@ -41,6 +43,8 @@ export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/
41export interface PendingUserInputDraftAnswer {43export interface PendingUserInputDraftAnswer {
42 readonly selectedOptionValues?: ReadonlyArray<string>;44 readonly selectedOptionValues?: ReadonlyArray<string>;
43 readonly customAnswer?: string;45 readonly customAnswer?: string;
46 readonly attachmentCount?: number;
47 readonly attachmentsBlocked?: boolean;
44}48}
4549
46export interface ThreadFeedActivity {50export interface ThreadFeedActivity {
...@@ -76,6 +80,7 @@ export interface ThreadFeedActivity {...@@ -76,6 +80,7 @@ export interface ThreadFeedActivity {
76}80}
7781
78export interface WorkLogEntry {82export interface WorkLogEntry {
83 readonly questionAnswer?: UserInputAttachmentAnswerPayload;
79 id: string;84 id: string;
80 createdAt: string;85 createdAt: string;
81 turnId: TurnId | null;86 turnId: TurnId | null;
...@@ -302,6 +307,7 @@ function resolvePendingUserInputAnswer(...@@ -302,6 +307,7 @@ function resolvePendingUserInputAnswer(
302 question: UserInputQuestion,307 question: UserInputQuestion,
303 draft: PendingUserInputDraftAnswer | undefined,308 draft: PendingUserInputDraftAnswer | undefined,
304): string | ReadonlyArray<string> | null {309): string | ReadonlyArray<string> | null {
310 if (draft?.attachmentsBlocked) return null;
305 const customAnswer =311 const customAnswer =
306 question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);312 question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);
307 if (customAnswer) {313 if (customAnswer) {
...@@ -310,9 +316,16 @@ function resolvePendingUserInputAnswer(...@@ -310,9 +316,16 @@ function resolvePendingUserInputAnswer(
310316
311 const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues);317 const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
312 if (question.multiSelect) {318 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;
314 }324 }
315 return selectedOptionValues[0] ?? null;325 return (
326 selectedOptionValues[0] ??
327 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)
328 );
316}329}
317330
318/** Some providers settle agents through task.updated instead of task.completed. */331/** Some providers settle agents through task.updated instead of task.completed. */
...@@ -435,6 +448,8 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool...@@ -435,6 +448,8 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool
435 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");448 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
436}449}
437450
451const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload);
452
438function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {453function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
439 const payload =454 const payload =
440 activity.payload && typeof activity.payload === "object"455 activity.payload && typeof activity.payload === "object"
...@@ -480,6 +495,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -480,6 +495,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
480 ? "info"495 ? "info"
481 : activity.tone,496 : activity.tone,
482 sourceActivityKind: activity.kind,497 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 })(),
483 };503 };
484 const toolCallId =504 const toolCallId =
485 asTrimmedString(payload?.toolCallId) ?? asTrimmedString(asRecord(payload?.data)?.toolCallId);505 asTrimmedString(payload?.toolCallId) ?? asTrimmedString(asRecord(payload?.data)?.toolCallId);
...@@ -969,6 +989,7 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {...@@ -969,6 +989,7 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
969 * for every row (see the deferred-expansion test).989 * for every row (see the deferred-expansion test).
970 */990 */
971function workEntryCanExpand(entry: WorkLogEntry): boolean {991function workEntryCanExpand(entry: WorkLogEntry): boolean {
992 if (entry.questionAnswer) return true;
972 if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0;993 if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0;
973 if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true;994 if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true;
974 if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true;995 if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true;
apps/mobile/src/state/question-attachments.ts created+29
...@@ -0,0 +1,29 @@
1import type { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";
3import { appAtomRegistry } from "./atom-registry";
4
5export function questionAttachmentDraftPrefix(
6 environmentId: EnvironmentId,
7 threadId: ThreadId,
8): string {
9 return `${environmentId}:question-${encodeURIComponent(JSON.stringify(threadId))}-`;
10}
11export 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}
19export const questionAttachmentPreparationAtom = Atom.make<Record<string, number>>({}).pipe(
20 Atom.keepAlive,
21);
22export 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 {...@@ -925,7 +925,7 @@ export function appendComposerDraftText(draftKey: string, value: string): void {
925export function appendComposerDraftAttachments(925export function appendComposerDraftAttachments(
926 draftKey: string,926 draftKey: string,
927 attachments: ReadonlyArray<DraftComposerAttachment>,927 attachments: ReadonlyArray<DraftComposerAttachment>,
928 options?: { readonly allowOverflow?: boolean },928 options?: { readonly allowOverflow?: boolean; readonly maxAttachments?: number },
929): number {929): number {
930 if (attachments.length === 0) {930 if (attachments.length === 0) {
931 return 0;931 return 0;
...@@ -935,7 +935,13 @@ export function appendComposerDraftAttachments(...@@ -935,7 +935,13 @@ export function appendComposerDraftAttachments(
935 const existing = normalizeDraft(current[draftKey]);935 const existing = normalizeDraft(current[draftKey]);
936 const remaining = options?.allowOverflow936 const remaining = options?.allowOverflow
937 ? attachments.length937 ? 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 );
939 const accepted = attachments.slice(0, remaining);945 const accepted = attachments.slice(0, remaining);
940 rejected = attachments.slice(remaining);946 rejected = attachments.slice(remaining);
941 if (accepted.length === 0) {947 if (accepted.length === 0) {
apps/mobile/src/state/use-selected-thread-requests.test.tsx created+140
...@@ -0,0 +1,140 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4const 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}));
10vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
11vi.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}));
21vi.mock("./use-composer-drafts", () => ({
22 composerDraftsAtom: "drafts",
23 clearComposerDraft: vi.fn(),
24}));
25vi.mock("./composer-attachment-uploads", async () => ({
26 ...(await import("../lib/composerAttachmentUploadQueue")),
27 composerAttachmentUploadsAtom: "uploads",
28}));
29vi.mock("./question-attachments", async (importOriginal) => ({
30 ...(await importOriginal<typeof import("./question-attachments")>()),
31 questionAttachmentPreparationAtom: fixture.preparationAtom,
32}));
33vi.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}));
50vi.mock("./threads", () => ({ threadEnvironment: {} }));
51vi.mock("./use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
52vi.mock("./use-thread-selection", () => ({
53 useThreadSelection: () => ({
54 selectedThread: { environmentId: "environment-1", id: "thread-1" },
55 }),
56}));
57vi.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
79import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
80import { questionAttachmentDraftKey } from "./question-attachments";
81import { useSelectedThreadRequests } from "./use-selected-thread-requests";
82
83const environmentId = EnvironmentId.make("environment-1");
84const key = (question: string) =>
85 questionAttachmentDraftKey(
86 environmentId,
87 ThreadId.make("thread-1"),
88 ApprovalRequestId.make("request-1"),
89 question,
90 );
91function submitButtonMarkup() {
92 function Probe() {
93 const { activePendingUserInputAnswers } = useSelectedThreadRequests();
94 return <button disabled={activePendingUserInputAnswers === null}>Submit answers</button>;
95 }
96 return renderToStaticMarkup(<Probe />);
97}
98beforeEach(() => {
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});
119describe("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 @@...@@ -1,6 +1,19 @@
1import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";1import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
2import { useServerConfigs } from "./entities";
3import { Alert } from "react-native";
4import {
5 questionAttachmentDraftKey,
6 questionAttachmentDraftPrefix,
7 questionAttachmentPreparationAtom,
8} from "./question-attachments";
9import { composerDraftsAtom, clearComposerDraft } from "./use-composer-drafts";
10import {
11 composerAttachmentUploadsAtom,
12 composerAttachmentUploadBlockReason,
13 composerAttachmentsStillUploading,
14} from "./composer-attachment-uploads";
2import { useAtomValue } from "@effect/atom-react";15import { useAtomValue } from "@effect/atom-react";
3import { useCallback, useMemo, useState } from "react";16import { useCallback, useEffect, useMemo, useRef, useState } from "react";
417
5import {18import {
6 ApprovalRequestId,19 ApprovalRequestId,
...@@ -81,6 +94,7 @@ export function useSelectedThreadRequests() {...@@ -81,6 +94,7 @@ export function useSelectedThreadRequests() {
81 const selectedThread = useSelectedThreadDetail();94 const selectedThread = useSelectedThreadDetail();
82 const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom);95 const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom);
83 const [respondingApprovalId, setRespondingApprovalId] = useState<ApprovalRequestId | null>(null);96 const [respondingApprovalId, setRespondingApprovalId] = useState<ApprovalRequestId | null>(null);
97 const userInputResponsesInFlight = useRef(new Set<string>());
84 const [respondingUserInputId, setRespondingUserInputId] = useState<ApprovalRequestId | null>(98 const [respondingUserInputId, setRespondingUserInputId] = useState<ApprovalRequestId | null>(
85 null,99 null,
86 );100 );
...@@ -91,11 +105,81 @@ export function useSelectedThreadRequests() {...@@ -91,11 +105,81 @@ export function useSelectedThreadRequests() {
91 );105 );
92 const activePendingApproval = activePendingApprovals[0] ?? null;106 const activePendingApproval = activePendingApprovals[0] ?? null;
93 const activePendingUserInput = activePendingUserInputs[0] ?? null;107 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]);
94 const activePendingUserInputDrafts =142 const activePendingUserInputDrafts =
95 activePendingUserInput && selectedThreadShell143 activePendingUserInput && selectedThreadShell
96 ? (userInputDraftsByRequestKey[144 ? Object.fromEntries(
97 scopedRequestKey(selectedThreadShell.environmentId, activePendingUserInput.requestId)145 activePendingUserInput.questions.map((question) => {
98 ] ?? {})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 )
99 : {};183 : {};
100 const activePendingUserInputAnswers = activePendingUserInput184 const activePendingUserInputAnswers = activePendingUserInput
101 ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingUserInputDrafts)185 ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingUserInputDrafts)
...@@ -154,6 +238,52 @@ export function useSelectedThreadRequests() {...@@ -154,6 +238,52 @@ export function useSelectedThreadRequests() {
154 return;238 return;
155 }239 }
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);
157 setRespondingUserInputId(activePendingUserInput.requestId);287 setRespondingUserInputId(activePendingUserInput.requestId);
158 const result = await respondToUserInput({288 const result = await respondToUserInput({
159 environmentId: selectedThreadShell.environmentId,289 environmentId: selectedThreadShell.environmentId,
...@@ -161,8 +291,12 @@ export function useSelectedThreadRequests() {...@@ -161,8 +291,12 @@ export function useSelectedThreadRequests() {
161 threadId: selectedThreadShell.id,291 threadId: selectedThreadShell.id,
162 requestId: activePendingUserInput.requestId,292 requestId: activePendingUserInput.requestId,
163 answers: activePendingUserInputAnswers,293 answers: activePendingUserInputAnswers,
294 ...(attachmentsByQuestionId.size > 0
295 ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }
296 : {}),
164 },297 },
165 });298 });
299 userInputResponsesInFlight.current.delete(responseKey);
166 setRespondingUserInputId((current) =>300 setRespondingUserInputId((current) =>
167 current === activePendingUserInput.requestId ? null : current,301 current === activePendingUserInput.requestId ? null : current,
168 );302 );
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -215,6 +215,7 @@ export const make = Effect.gen(function* () {...@@ -215,6 +215,7 @@ export const make = Effect.gen(function* () {
215 repositoryIdentity: true,215 repositoryIdentity: true,
216 connectionProbe: true,216 connectionProbe: true,
217 attachmentUploads: true,217 attachmentUploads: true,
218 questionAttachments: true,
218 fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },219 fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
219 pullRequests: true,220 pullRequests: true,
220 threadSettlement: true,221 threadSettlement: true,
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+13-1
...@@ -239,6 +239,17 @@ describe("OrchestrationEngine", () => {...@@ -239,6 +239,17 @@ describe("OrchestrationEngine", () => {
239 threadId,239 threadId,
240 requestId,240 requestId,
241 answers: { "0": "pnpm", "1": "Example" },241 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 },
242 createdAt: "2026-01-01T00:00:02.000Z",253 createdAt: "2026-01-01T00:00:02.000Z",
243 };254 };
244 await expect(255 await expect(
...@@ -256,8 +267,9 @@ describe("OrchestrationEngine", () => {...@@ -256,8 +267,9 @@ describe("OrchestrationEngine", () => {
256 (message) => message.role === "user",267 (message) => message.role === "user",
257 );268 );
258 expect(userMessages).toHaveLength(1);269 expect(userMessages).toHaveLength(1);
270 expect(userMessages?.[0]?.attachments).toEqual(response.attachmentsByQuestionId["1"]);
259 expect(userMessages?.[0]?.text).toBe(271 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)",
261 );273 );
262 expect(274 expect(
263 after.threads[0]?.activities.find((activity) => activity.kind === "user-input.resolved")275 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) => {...@@ -411,7 +411,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
411 FROM projection_state411 FROM projection_state
412 ORDER BY projector ASC412 ORDER BY projector ASC
413 `;413 `;
414 assert.equal(stateRows.length, Object.keys(ORCHESTRATION_PROJECTOR_NAMES).length);414 assert.equal(stateRows.length, Object.keys(ORCHESTRATION_PROJECTOR_NAMES).length + 1);
415 for (const row of stateRows) {415 for (const row of stateRows) {
416 assert.equal(row.lastAppliedSequence, 3);416 assert.equal(row.lastAppliedSequence, 3);
417 }417 }
...@@ -1164,14 +1164,18 @@ it.layer(...@@ -1164,14 +1164,18 @@ it.layer(
11641164
1165 yield* projectionPipeline.bootstrap;1165 yield* projectionPipeline.bootstrap;
1166 yield* projectionPipeline.bootstrap;1166 yield* projectionPipeline.bootstrap;
1167 assert.deepEqual(1167 assert.deepEqual(yield* projectionState.listAll(), [
1168 yield* projectionState.listAll(),1168 {
1169 cursorsBeforeFailure.map((cursor) => ({1169 projector: "projection.attachment-cleanup",
1170 lastAppliedSequence: pendingEvent.sequence,
1171 updatedAt: pendingEvent.occurredAt,
1172 },
1173 ...cursorsBeforeFailure.map((cursor) => ({
1170 ...cursor,1174 ...cursor,
1171 lastAppliedSequence: pendingEvent.sequence,1175 lastAppliedSequence: pendingEvent.sequence,
1172 updatedAt: pendingEvent.occurredAt,1176 updatedAt: pendingEvent.occurredAt,
1173 })),1177 })),
1174 );1178 ]);
1175 const replayedMessages = yield* sql<{ readonly text: string }>`1179 const replayedMessages = yield* sql<{ readonly text: string }>`
1176 SELECT text FROM projection_thread_messages WHERE message_id = 'message-rollback'1180 SELECT text FROM projection_thread_messages WHERE message_id = 'message-rollback'
1177 `;1181 `;
...@@ -1364,10 +1368,53 @@ it.layer(...@@ -1364,10 +1368,53 @@ it.layer(
1364 },1368 },
1365 });1369 });
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 }
1367 const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`);1409 const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`);
1368 const keepFilePath = path.join(attachmentsDir, `${keepFileAttachmentId}.pdf`);1410 const keepFilePath = path.join(attachmentsDir, `${keepFileAttachmentId}.pdf`);
1369 const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`);1411 const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`);
1370 yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });1412 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 );
1371 yield* fileSystem.writeFileString(keepPath, "keep");1418 yield* fileSystem.writeFileString(keepPath, "keep");
1372 yield* fileSystem.writeFileString(keepFilePath, "keep");1419 yield* fileSystem.writeFileString(keepFilePath, "keep");
1373 yield* fileSystem.writeFileString(removePath, "remove");1420 yield* fileSystem.writeFileString(removePath, "remove");
...@@ -1460,9 +1507,33 @@ it.layer(...@@ -1460,9 +1507,33 @@ it.layer(
14601507
1461 assert.isTrue(yield* exists(keepPath));1508 assert.isTrue(yield* exists(keepPath));
1462 assert.isTrue(yield* exists(keepFilePath));1509 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`)));
1463 assert.isFalse(yield* exists(removePath));1512 assert.isFalse(yield* exists(removePath));
1464 assert.isTrue(yield* exists(laterPath));1513 assert.isTrue(yield* exists(laterPath));
1465 assert.isTrue(yield* exists(otherThreadPath));1514 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`)));
1466 }),1537 }),
1467 );1538 );
1468});1539});
...@@ -1934,6 +2005,15 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -1934,6 +2005,15 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
1934 },2005 },
1935 });2006 });
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`;
1937 yield* projectionPipeline.bootstrap;2017 yield* projectionPipeline.bootstrap;
1938 yield* projectionPipeline.bootstrap;2018 yield* projectionPipeline.bootstrap;
19392019
...@@ -3884,7 +3964,7 @@ const engineLayer = it.layer(...@@ -3884,7 +3964,7 @@ const engineLayer = it.layer(
3884 Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),3964 Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
3885 Layer.provide(ThreadBackgroundLiveness.layer),3965 Layer.provide(ThreadBackgroundLiveness.layer),
3886 Layer.provide(ThreadPlanProgress.layer),3966 Layer.provide(ThreadPlanProgress.layer),
3887 Layer.provide(OrchestrationProjectionPipelineLive),3967 Layer.provideMerge(OrchestrationProjectionPipelineLive),
3888 Layer.provide(OrchestrationEventStoreLive),3968 Layer.provide(OrchestrationEventStoreLive),
3889 Layer.provide(OrchestrationCommandReceiptRepositoryLive),3969 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
3890 Layer.provide(RepositoryIdentityResolver.layer),3970 Layer.provide(RepositoryIdentityResolver.layer),
...@@ -4236,7 +4316,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -4236,7 +4316,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
4236 yield* readCursors,4316 yield* readCursors,
4237 cursorsBeforeFailure.map((cursor) => ({4317 cursorsBeforeFailure.map((cursor) => ({
4238 ...cursor,4318 ...cursor,
4239 lastAppliedSequence: result.sequence,4319 lastAppliedSequence:
4320 cursor.projector === "projection.attachment-cleanup"
4321 ? cursor.lastAppliedSequence
4322 : result.sequence,
4240 })),4323 })),
4241 );4324 );
4242 assert.isFalse(yield* exists(attachmentPath));4325 assert.isFalse(yield* exists(attachmentPath));
...@@ -4266,6 +4349,25 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -4266,6 +4349,25 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
4266 WHERE command_id = ${cleanupFailureCommandId}4349 WHERE command_id = ${cleanupFailureCommandId}
4267 `;4350 `;
4268 assert.deepEqual(cleanupFailureReceipts, [{ status: "accepted" }]);4351 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 );
4269 }),4371 }),
4270 );4372 );
4271});4373});
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+89-31
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 ApprovalRequestId,2 ApprovalRequestId,
3 isImportedAgentSessionMessageId,3 isImportedAgentSessionMessageId,
4 UserInputAttachmentAnswerPayload,
4 type ChatAttachment,5 type ChatAttachment,
5 type OrchestrationEvent,6 type OrchestrationEvent,
6 type OrchestrationSessionStatus,7 type OrchestrationSessionStatus,
...@@ -12,6 +13,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -12,6 +13,7 @@ import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";13import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";14import * as Option from "effect/Option";
14import * as Path from "effect/Path";15import * as Path from "effect/Path";
16import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";17import * as Stream from "effect/Stream";
16import * as SqlClient from "effect/unstable/sql/SqlClient";18import * as SqlClient from "effect/unstable/sql/SqlClient";
1719
...@@ -327,6 +329,8 @@ function retainProjectionProposedPlansAfterRevert(...@@ -327,6 +329,8 @@ function retainProjectionProposedPlansAfterRevert(
327 );329 );
328}330}
329331
332const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload);
333
330function collectThreadAttachmentRelativePaths(334function collectThreadAttachmentRelativePaths(
331 threadId: string,335 threadId: string,
332 messages: ReadonlyArray<ProjectionThreadMessage>,336 messages: ReadonlyArray<ProjectionThreadMessage>,
...@@ -359,9 +363,9 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function*...@@ -359,9 +363,9 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function*
359 const path = yield* Effect.service(Path.Path);363 const path = yield* Effect.service(Path.Path);
360364
361 const attachmentsRootDir = serverConfig.attachmentsDir;365 const attachmentsRootDir = serverConfig.attachmentsDir;
362 const readAttachmentRootEntries = fileSystem366 const readAttachmentRootEntries = fileSystem.readDirectory(attachmentsRootDir, {
363 .readDirectory(attachmentsRootDir, { recursive: false })367 recursive: false,
364 .pipe(Effect.orElseSucceed(() => [] as Array<string>));368 });
365369
366 const removeDeletedThreadAttachmentEntry = Effect.fn("removeDeletedThreadAttachmentEntry")(370 const removeDeletedThreadAttachmentEntry = Effect.fn("removeDeletedThreadAttachmentEntry")(
367 function* (threadSegment: string, entry: string) {371 function* (threadSegment: string, entry: string) {
...@@ -423,7 +427,7 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function*...@@ -423,7 +427,7 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function*
423 }427 }
424428
425 const absolutePath = path.join(attachmentsRootDir, relativePath);429 const absolutePath = path.join(attachmentsRootDir, relativePath);
426 const fileInfo = yield* fileSystem.stat(absolutePath).pipe(Effect.orElseSucceed(() => null));430 const fileInfo = yield* fileSystem.stat(absolutePath);
427 if (!fileInfo || fileInfo.type !== "File") {431 if (!fileInfo || fileInfo.type !== "File") {
428 return;432 return;
429 }433 }
...@@ -1156,7 +1160,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1156,7 +1160,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
11561160
1157 const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = Effect.fn(1161 const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = Effect.fn(
1158 "applyThreadActivitiesProjection",1162 "applyThreadActivitiesProjection",
1159 )(function* (event, _attachmentSideEffects) {1163 )(function* (event, attachmentSideEffects) {
1160 switch (event.type) {1164 switch (event.type) {
1161 case "thread.created":1165 case "thread.created":
1162 yield* projectionThreadActivityRepository.deleteByThreadId({1166 yield* projectionThreadActivityRepository.deleteByThreadId({
...@@ -1204,6 +1208,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1204,6 +1208,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1204 yield* Effect.forEach(keptRows, projectionThreadActivityRepository.upsert, {1208 yield* Effect.forEach(keptRows, projectionThreadActivityRepository.upsert, {
1205 concurrency: 1,1209 concurrency: 1,
1206 }).pipe(Effect.asVoid);1210 }).pipe(Effect.asVoid);
1211 attachmentSideEffects.prunedThreadRelativePaths.set(event.payload.threadId, new Set());
1207 return;1212 return;
1208 }1213 }
12091214
...@@ -1866,10 +1871,20 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1866,10 +1871,20 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1866 const messages = yield* projectionThreadMessageRepository.listByThreadId({1871 const messages = yield* projectionThreadMessageRepository.listByThreadId({
1867 threadId: ThreadId.make(threadId),1872 threadId: ThreadId.make(threadId),
1868 });1873 });
1869 prunedThreadRelativePaths.set(1874 const retainedPaths = collectThreadAttachmentRelativePaths(threadId, messages);
1870 threadId,1875 const activities = yield* projectionThreadActivityRepository.listByThreadId({
1871 collectThreadAttachmentRelativePaths(threadId, messages),1876 threadId: ThreadId.make(threadId),
1872 );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);
1873 }1888 }
18741889
1875 yield* runAttachmentSideEffects({ deletedThreadIds, prunedThreadRelativePaths });1890 yield* runAttachmentSideEffects({ deletedThreadIds, prunedThreadRelativePaths });
...@@ -1879,29 +1894,17 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1879,29 +1894,17 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1879 Effect.provideService(ServerConfig, serverConfig),1894 Effect.provideService(ServerConfig, serverConfig),
1880 (effect, event) =>1895 (effect, event) =>
1881 effect.pipe(1896 effect.pipe(
1897 Effect.as(true),
1882 Effect.catch((cause) =>1898 Effect.catch((cause) =>
1883 Effect.logWarning("failed to apply projected attachment side-effects", {1899 Effect.logWarning("failed to apply projected attachment side-effects", {
1884 sequence: event.sequence,1900 sequence: event.sequence,
1885 eventType: event.type,1901 eventType: event.type,
1886 cause,1902 cause,
1887 }),1903 }).pipe(Effect.as(false)),
1888 ),1904 ),
1889 ),1905 ),
1890 );1906 );
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
1905 const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* (1908 const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* (
1906 projector: ProjectorDefinition,1909 projector: ProjectorDefinition,
1907 event: OrchestrationEvent,1910 event: OrchestrationEvent,
...@@ -1911,8 +1914,16 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1911,8 +1914,16 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1911 prunedThreadRelativePaths: new Map<string, Set<string>>(),1914 prunedThreadRelativePaths: new Map<string, Set<string>>(),
1912 };1915 };
19131916
1914 yield* sql.withTransaction(applyProjectorForEvent(projector, event, attachmentSideEffects));1917 yield* sql.withTransaction(
1915 yield* applyAttachmentSideEffects(event, attachmentSideEffects);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 );
1916 });1927 });
19171928
1918 const bootstrapProjector = (projector: ProjectorDefinition) =>1929 const bootstrapProjector = (projector: ProjectorDefinition) =>
...@@ -1958,7 +1969,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1958,7 +1969,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1958 );1969 );
1959 // Return the cleanup effect so the caller runs it after the outer transaction commits.1970 // Return the cleanup effect so the caller runs it after the outer transaction commits.
1960 // @effect-diagnostics-next-line returnEffectInGen:off1971 // @effect-diagnostics-next-line returnEffectInGen:off
1961 return applyAttachmentSideEffects(event, attachmentSideEffects);1972 return applyAttachmentSideEffects(event, attachmentSideEffects).pipe(Effect.asVoid);
1962 },1973 },
1963 Effect.provideService(FileSystem.FileSystem, fileSystem),1974 Effect.provideService(FileSystem.FileSystem, fileSystem),
1964 Effect.provideService(Path.Path, path),1975 Effect.provideService(Path.Path, path),
...@@ -1975,11 +1986,58 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1975,11 +1986,58 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1975 yield* cleanup;1986 yield* cleanup;
1976 });1987 });
19771988
1978 const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach(1989 const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.gen(function* () {
1979 projectors,1990 const cleanupProjector = "projection.attachment-cleanup";
1980 bootstrapProjector,1991 const states = yield* projectionStateRepository.listAll();
1981 { concurrency: 1 },1992 const byProjector = new Map(states.map((state) => [state.projector, state]));
1982 ).pipe(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(
1983 Effect.provideService(FileSystem.FileSystem, fileSystem),2041 Effect.provideService(FileSystem.FileSystem, fileSystem),
1984 Effect.provideService(Path.Path, path),2042 Effect.provideService(Path.Path, path),
1985 Effect.provideService(ServerConfig, serverConfig),2043 Effect.provideService(ServerConfig, serverConfig),
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+3
...@@ -1609,6 +1609,9 @@ const make = Effect.gen(function* () {...@@ -1609,6 +1609,9 @@ const make = Effect.gen(function* () {
1609 threadId: event.payload.threadId,1609 threadId: event.payload.threadId,
1610 requestId: event.payload.requestId,1610 requestId: event.payload.requestId,
1611 answers: event.payload.answers,1611 answers: event.payload.answers,
1612 ...(event.payload.attachmentsByQuestionId
1613 ? { attachmentsByQuestionId: event.payload.attachmentsByQuestionId }
1614 : {}),
1612 })1615 })
1613 .pipe(1616 .pipe(
1614 Effect.catchCause((cause) =>1617 Effect.catchCause((cause) =>
apps/server/src/orchestration/Normalizer.attachments.test.ts+202-1
...@@ -5,13 +5,15 @@ import * as NodePath from "node:path";...@@ -5,13 +5,15 @@ import * as NodePath from "node:path";
5import * as NodeServices from "@effect/platform-node/NodeServices";5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
7import {7import {
8 type ClientOrchestrationCommand,8 ClientOrchestrationCommand,
9 CommandId,9 CommandId,
10 ApprovalRequestId,
10 MessageId,11 MessageId,
11 ThreadId,12 ThreadId,
12} from "@t3tools/contracts";13} from "@t3tools/contracts";
13import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
16import * as Schema from "effect/Schema";
1517
16import * as ServerConfig from "../config.ts";18import * as ServerConfig from "../config.ts";
17import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";19import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
...@@ -23,6 +25,7 @@ const testLayer = Layer.mergeAll(...@@ -23,6 +25,7 @@ const testLayer = Layer.mergeAll(
23).pipe(Layer.provideMerge(NodeServices.layer));25).pipe(Layer.provideMerge(NodeServices.layer));
2426
25const attachmentUuid = "00000000-0000-4000-8000-0000000000aa";27const attachmentUuid = "00000000-0000-4000-8000-0000000000aa";
28const isClientCommand = Schema.is(ClientOrchestrationCommand);
2629
27function turnStartCommand(input: {30function turnStartCommand(input: {
28 readonly threadId?: string;31 readonly threadId?: string;
...@@ -358,3 +361,201 @@ describe("normalizeDispatchCommand attachments", () => {...@@ -358,3 +361,201 @@ describe("normalizeDispatchCommand attachments", () => {
358 }).pipe(Effect.provide(testLayer)),361 }).pipe(Effect.provide(testLayer)),
359 );362 );
360});363});
364
365describe("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";...@@ -4,6 +4,8 @@ import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";4import * as Path from "effect/Path";
5import {5import {
6 type ClientOrchestrationCommand,6 type ClientOrchestrationCommand,
7 type UserInputAttachments,
8 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
7 type IsoDateTime,9 type IsoDateTime,
8 type OrchestrationCommand,10 type OrchestrationCommand,
9 OrchestrationDispatchCommandError,11 OrchestrationDispatchCommandError,
...@@ -129,13 +131,28 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>...@@ -129,13 +131,28 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
129 } satisfies OrchestrationCommand;131 } satisfies OrchestrationCommand;
130 }132 }
131133
132 if (canonicalCommand.type !== "thread.turn.start") {134 if (
135 canonicalCommand.type !== "thread.turn.start" &&
136 canonicalCommand.type !== "thread.user-input.respond"
137 ) {
133 return canonicalCommand as OrchestrationCommand;138 return canonicalCommand as OrchestrationCommand;
134 }139 }
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 }
136 const claimedAttachmentPaths: string[] = [];153 const claimedAttachmentPaths: string[] = [];
137 const normalizedAttachments = yield* Effect.forEach(154 const normalizedAttachments = yield* Effect.forEach(
138 canonicalCommand.message.attachments,155 attachments,
139 (attachment) =>156 (attachment) =>
140 Effect.gen(function* () {157 Effect.gen(function* () {
141 if (!("dataUrl" in attachment)) {158 if (!("dataUrl" in attachment)) {
...@@ -260,6 +277,25 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>...@@ -260,6 +277,25 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
260 { concurrency: 1 },277 { concurrency: 1 },
261 ).pipe(Effect.tapError(() => removeClaimedAttachmentPaths(claimedAttachmentPaths)));278 ).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 }
263 return {299 return {
264 ...canonicalCommand,300 ...canonicalCommand,
265 message: {301 message: {
...@@ -272,14 +308,24 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>...@@ -272,14 +308,24 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
272export const cleanupFailedUploadedAttachments = Effect.fn(308export const cleanupFailedUploadedAttachments = Effect.fn(
273 "Normalizer.cleanupFailedUploadedAttachments",309 "Normalizer.cleanupFailedUploadedAttachments",
274)(function* (command: ClientOrchestrationCommand, normalizedCommand: OrchestrationCommand) {310)(function* (command: ClientOrchestrationCommand, normalizedCommand: OrchestrationCommand) {
275 if (command.type !== "thread.turn.start" || normalizedCommand.type !== "thread.turn.start") {311 const originalAttachments =
276 return;312 command.type === "thread.turn.start"
277 }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
279 const serverConfig = yield* ServerConfig;325 const serverConfig = yield* ServerConfig;
280 const claimedPaths: string[] = [];326 const claimedPaths: string[] = [];
281 for (const [index, attachment] of normalizedCommand.message.attachments.entries()) {327 for (const [index, attachment] of normalizedAttachments.entries()) {
282 const original = command.message.attachments[index];328 const original = originalAttachments[index];
283 if (329 if (
284 !original ||330 !original ||
285 "dataUrl" in original ||331 "dataUrl" in original ||
apps/server/src/orchestration/decider.questionAttachments.test.ts created+130
...@@ -0,0 +1,130 @@
1import {
2 ApprovalRequestId,
3 EventId,
4 CommandId,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8 type OrchestrationReadModel,
9} from "@t3tools/contracts";
10import * as NodeServices from "@effect/platform-node/NodeServices";
11import { expect, it } from "@effect/vitest";
12import * as Effect from "effect/Effect";
13
14import { decideOrchestrationCommand } from "./decider.ts";
15
16const UPDATED_AT = "2026-01-01T00:00:00.000Z";
17
18const 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
50const requestId = ApprovalRequestId.make("question-request");
51const 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};
70const 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};
84it.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"...@@ -1222,6 +1222,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1222 threadId: command.threadId,1222 threadId: command.threadId,
1223 });1223 });
1224 const request = userInputActivity;1224 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 }
1225 if (1254 if (
1226 request &&1255 request &&
1227 Predicate.isObject(request.payload) &&1256 Predicate.isObject(request.payload) &&
...@@ -1237,13 +1266,22 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -1237,13 +1266,22 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1237 const replies: string[] = [];1266 const replies: string[] = [];
1238 for (const question of payload.value.questions) {1267 for (const question of payload.value.questions) {
1239 const answer = command.answers[question.id];1268 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 ) {
1241 return yield* new OrchestrationCommandInvariantError({1273 return yield* new OrchestrationCommandInvariantError({
1242 commandType: command.type,1274 commandType: command.type,
1243 detail: "Answer each question before sending.",1275 detail: "Answer each question before sending.",
1244 });1276 });
1245 }1277 }
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 );
1247 }1285 }
1248 // Commit the answer and its message together. The normal turn path1286 // Commit the answer and its message together. The normal turn path
1249 // steers a running agent or resumes an idle session.1287 // steers a running agent or resumes an idle session.
...@@ -1266,6 +1304,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -1266,6 +1304,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1266 requestId: command.requestId,1304 requestId: command.requestId,
1267 responseMode: "message",1305 responseMode: "message",
1268 answers: command.answers,1306 answers: command.answers,
1307 ...(command.attachmentsByQuestionId
1308 ? { attachmentsByQuestionId: command.attachmentsByQuestionId }
1309 : {}),
1269 },1310 },
1270 },1311 },
1271 },1312 },
...@@ -1280,30 +1321,57 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -1280,30 +1321,57 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1280 messageId: MessageId.make(`async-answer:${command.requestId}`),1321 messageId: MessageId.make(`async-answer:${command.requestId}`),
1281 role: "user",1322 role: "user",
1282 text: replies.join("\n\n"),1323 text: replies.join("\n\n"),
1283 attachments: [],1324 attachments,
1284 },1325 },
1285 },1326 },
1286 ],1327 ],
1287 });1328 });
1288 }1329 }
1289 return {1330 const responseEvent = {
1290 ...(yield* withEventBase({1331 ...(yield* withEventBase({
1291 aggregateKind: "thread",1332 aggregateKind: "thread",
1292 aggregateId: command.threadId,1333 aggregateId: command.threadId,
1293 occurredAt: command.createdAt,1334 occurredAt: command.createdAt,
1294 commandId: command.commandId,1335 commandId: command.commandId,
1295 metadata: {1336 metadata: { requestId: command.requestId },
1296 requestId: command.requestId,
1297 },
1298 })),1337 })),
1299 type: "thread.user-input-response-requested",1338 type: "thread.user-input-response-requested" as const,
1300 payload: {1339 payload: {
1301 threadId: command.threadId,1340 threadId: command.threadId,
1302 requestId: command.requestId,1341 requestId: command.requestId,
1303 answers: command.answers,1342 answers: command.answers,
1343 ...(command.attachmentsByQuestionId
1344 ? { attachmentsByQuestionId: command.attachmentsByQuestionId }
1345 : {}),
1304 createdAt: command.createdAt,1346 createdAt: command.createdAt,
1305 },1347 },
1306 };1348 };
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];
1307 }1375 }
13081376
1309 case "thread.user-input.dismiss": {1377 case "thread.user-input.dismiss": {
apps/server/src/provider/Layers/ProviderService.ts+6-1
...@@ -49,6 +49,7 @@ import * as Schema from "effect/Schema";...@@ -49,6 +49,7 @@ import * as Schema from "effect/Schema";
49import * as SchemaIssue from "effect/SchemaIssue";49import * as SchemaIssue from "effect/SchemaIssue";
50import * as Stream from "effect/Stream";50import * as Stream from "effect/Stream";
5151
52import { appendUserInputAttachmentPaths } from "../userInputAttachments.ts";
52import { resolveAttachmentPath } from "../../attachmentStore.ts";53import { resolveAttachmentPath } from "../../attachmentStore.ts";
53import * as ServerConfig from "../../config.ts";54import * as ServerConfig from "../../config.ts";
54import {55import {
...@@ -1919,7 +1920,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1919,7 +1920,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1919 "provider.thread_id": input.threadId,1920 "provider.thread_id": input.threadId,
1920 "provider.request_id": input.requestId,1921 "provider.request_id": input.requestId,
1921 });1922 });
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);
1923 }).pipe(1928 }).pipe(
1924 withMetrics({1929 withMetrics({
1925 counter: providerTurnsTotal,1930 counter: providerTurnsTotal,
apps/server/src/provider/userInputAttachments.test.ts created+61
...@@ -0,0 +1,61 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { describe, expect, it } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as ServerConfig from "../config.ts";
9import { appendUserInputAttachmentPaths } from "./userInputAttachments.ts";
10
11const layer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-answer-paths-" }).pipe(
12 Layer.provideMerge(NodeServices.layer),
13);
14const 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};
21describe("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 created+59
...@@ -0,0 +1,59 @@
1import type { ProviderUserInputAnswers, UserInputAttachments } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import { resolveAttachmentPath } from "../attachmentStore.ts";
6import { ProviderValidationError } from "./Errors.ts";
7
8const quoteReference = Schema.encodeSync(Schema.fromJsonString(Schema.String));
9
10/** Keep provider answer protocols unchanged; paths refer to files on the provider's server. */
11export 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 {...@@ -8,6 +8,13 @@ import {
8import { feedbackBannerItem } from "./chat/ComposerFeedback";8import { feedbackBannerItem } from "./chat/ComposerFeedback";
9import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";9import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";
10import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";10import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
11import {
12 questionAttachmentDraftId,
13 questionAttachmentDraftPrefix,
14 clearQuestionAttachmentDraft,
15 useQuestionAttachmentPreparation,
16} from "../questionAttachments";
17import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue";
11import {18import {
12 type AssistantCitation,19 type AssistantCitation,
13 type ApprovalRequestId,20 type ApprovalRequestId,
...@@ -264,7 +271,7 @@ import {...@@ -264,7 +271,7 @@ import {
264 finalizePromotedDraftThreadByRef,271 finalizePromotedDraftThreadByRef,
265 markPromotedDraftThreadByRef,272 markPromotedDraftThreadByRef,
266 useComposerDraftStore,273 useComposerDraftStore,
267 type DraftId,274 DraftId,
268} from "../composerDraftStore";275} from "../composerDraftStore";
269import {276import {
270 appendTerminalContextsToPrompt,277 appendTerminalContextsToPrompt,
...@@ -1610,6 +1617,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -1610,6 +1617,7 @@ export default function ChatView(props: ChatViewProps) {
1610 null,1617 null,
1611 );1618 );
1612 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);1619 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
1620 const userInputResponsesInFlight = useRef(new Set<string>());
1613 const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<1621 const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<
1614 ApprovalRequestId[]1622 ApprovalRequestId[]
1615 >([]);1623 >([]);
...@@ -2310,6 +2318,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -2310,6 +2318,8 @@ export default function ChatView(props: ChatViewProps) {
2310 const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true;2318 const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true;
2311 const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null;2319 const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null;
2312 const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null;2320 const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null;
2321 const supportsQuestionAttachments =
2322 attachmentEnvironmentConfig?.environment.capabilities.questionAttachments === true;
2313 const supportsAttachmentUploads =2323 const supportsAttachmentUploads =
2314 attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true;2324 attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true;
2315 const advertisedFileAttachmentBytes =2325 const advertisedFileAttachmentBytes =
...@@ -2627,16 +2637,120 @@ export default function ChatView(props: ChatViewProps) {...@@ -2627,16 +2637,120 @@ export default function ChatView(props: ChatViewProps) {
2627 [threadActivities],2637 [threadActivities],
2628 );2638 );
2629 const activePendingUserInput = pendingUserInputs[0] ?? null;2639 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(
2631 () =>2646 () =>
2632 activePendingUserInput2647 activeThreadId
2633 ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ??2648 ? pendingUserInputs.flatMap((request) =>
2634 EMPTY_PENDING_USER_INPUT_ANSWERS)2649 request.questions.map((question) =>
2635 : EMPTY_PENDING_USER_INPUT_ANSWERS,2650 questionAttachmentDraftId(
2636 [activePendingUserInput, pendingUserInputAnswersByRequestId],2651 environmentId,
2652 activeThreadId,
2653 request.requestId,
2654 question.id,
2655 ),
2656 ),
2657 )
2658 : [],
2659 [activeThreadId, environmentId, pendingUserInputs],
2637 );2660 );
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 ]);
2638 const activePendingQuestionIndex = activePendingUserInput2752 const activePendingQuestionIndex = activePendingUserInput
2639 ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0)2753 ? (pendingUserInputQuestionIndexByRequestId[activePendingRequestKey] ?? 0)
2640 : 0;2754 : 0;
2641 const activePendingProgress = useMemo(2755 const activePendingProgress = useMemo(
2642 () =>2756 () =>
...@@ -7061,8 +7175,38 @@ export default function ChatView(props: ChatViewProps) {...@@ -7061,8 +7175,38 @@ export default function ChatView(props: ChatViewProps) {
70617175
7062 const onRespondToUserInput = useCallback(7176 const onRespondToUserInput = useCallback(
7063 async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => {7177 async (requestId: ApprovalRequestId, answers: Record<string, unknown>) => {
7064 if (!activeThreadId) return;7178 if (!activeThreadId || !activePendingUserInput || activePendingIsResponding) return;
70657179 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);
7066 setRespondingUserInputRequestIds((existing) =>7210 setRespondingUserInputRequestIds((existing) =>
7067 existing.includes(requestId) ? existing : [...existing, requestId],7211 existing.includes(requestId) ? existing : [...existing, requestId],
7068 );7212 );
...@@ -7072,6 +7216,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -7072,6 +7216,9 @@ export default function ChatView(props: ChatViewProps) {
7072 threadId: activeThreadId,7216 threadId: activeThreadId,
7073 requestId,7217 requestId,
7074 answers,7218 answers,
7219 ...(attachmentsByQuestionId.size > 0
7220 ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }
7221 : {}),
7075 },7222 },
7076 });7223 });
7077 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {7224 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
...@@ -7081,10 +7228,18 @@ export default function ChatView(props: ChatViewProps) {...@@ -7081,10 +7228,18 @@ export default function ChatView(props: ChatViewProps) {
7081 error instanceof Error ? error.message : "Failed to submit user input.",7228 error instanceof Error ? error.message : "Failed to submit user input.",
7082 );7229 );
7083 }7230 }
7231 userInputResponsesInFlight.current.delete(responseKey);
7084 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));7232 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
7085 return result;7233 return result;
7086 },7234 },
7087 [activeThreadId, environmentId, respondToThreadUserInput, setThreadError],7235 [
7236 activeThreadId,
7237 activePendingUserInput,
7238 activePendingIsResponding,
7239 environmentId,
7240 respondToThreadUserInput,
7241 setThreadError,
7242 ],
7088 );7243 );
70897244
7090 // Closes an async question without messaging the agent. The server records7245 // Closes an async question without messaging the agent. The server records
...@@ -7120,10 +7275,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -7120,10 +7275,10 @@ export default function ChatView(props: ChatViewProps) {
7120 }7275 }
7121 setPendingUserInputQuestionIndexByRequestId((existing) => ({7276 setPendingUserInputQuestionIndexByRequestId((existing) => ({
7122 ...existing,7277 ...existing,
7123 [activePendingUserInput.requestId]: nextQuestionIndex,7278 [activePendingRequestKey]: nextQuestionIndex,
7124 }));7279 }));
7125 },7280 },
7126 [activePendingUserInput],7281 [activePendingUserInput, activePendingRequestKey],
7127 );7282 );
71287283
7129 const onSelectActivePendingUserInputOption = useCallback(7284 const onSelectActivePendingUserInputOption = useCallback(
...@@ -7143,11 +7298,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -7143,11 +7298,11 @@ export default function ChatView(props: ChatViewProps) {
71437298
7144 return {7299 return {
7145 ...existing,7300 ...existing,
7146 [activePendingUserInput.requestId]: {7301 [activePendingRequestKey]: {
7147 ...existing[activePendingUserInput.requestId],7302 ...existing[activePendingRequestKey],
7148 [questionId]: togglePendingUserInputOptionSelection(7303 [questionId]: togglePendingUserInputOptionSelection(
7149 question,7304 question,
7150 existing[activePendingUserInput.requestId]?.[questionId],7305 existing[activePendingRequestKey]?.[questionId],
7151 optionValue,7306 optionValue,
7152 ),7307 ),
7153 },7308 },
...@@ -7156,7 +7311,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -7156,7 +7311,12 @@ export default function ChatView(props: ChatViewProps) {
7156 promptRef.current = "";7311 promptRef.current = "";
7157 composerRef.current?.resetCursorState({ cursor: 0 });7312 composerRef.current?.resetCursorState({ cursor: 0 });
7158 },7313 },
7159 [activePendingProgress?.activeQuestion, activePendingUserInput, composerRef],7314 [
7315 activePendingProgress?.activeQuestion,
7316 activePendingUserInput,
7317 activePendingRequestKey,
7318 composerRef,
7319 ],
7160 );7320 );
71617321
7162 const onChangeActivePendingUserInputCustomAnswer = useCallback(7322 const onChangeActivePendingUserInputCustomAnswer = useCallback(
...@@ -7177,10 +7337,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -7177,10 +7337,10 @@ export default function ChatView(props: ChatViewProps) {
7177 promptRef.current = value;7337 promptRef.current = value;
7178 setPendingUserInputAnswersByRequestId((existing) => ({7338 setPendingUserInputAnswersByRequestId((existing) => ({
7179 ...existing,7339 ...existing,
7180 [activePendingUserInput.requestId]: {7340 [activePendingRequestKey]: {
7181 ...existing[activePendingUserInput.requestId],7341 ...existing[activePendingRequestKey],
7182 [questionId]: setPendingUserInputCustomAnswer(7342 [questionId]: setPendingUserInputCustomAnswer(
7183 existing[activePendingUserInput.requestId]?.[questionId],7343 existing[activePendingRequestKey]?.[questionId],
7184 value,7344 value,
7185 ),7345 ),
7186 },7346 },
...@@ -7194,11 +7354,16 @@ export default function ChatView(props: ChatViewProps) {...@@ -7194,11 +7354,16 @@ export default function ChatView(props: ChatViewProps) {
7194 composerRef.current?.focusAt(nextCursor);7354 composerRef.current?.focusAt(nextCursor);
7195 }7355 }
7196 },7356 },
7197 [activePendingUserInput, composerRef],7357 [activePendingUserInput, activePendingRequestKey, composerRef],
7198 );7358 );
71997359
7200 const onAdvanceActivePendingUserInput = useCallback(() => {7360 const onAdvanceActivePendingUserInput = useCallback(() => {
7201 if (!activePendingUserInput || !activePendingProgress) {7361 if (
7362 !activePendingUserInput ||
7363 !activePendingProgress ||
7364 !activePendingProgress.canAdvance ||
7365 activePendingIsResponding
7366 ) {
7202 return;7367 return;
7203 }7368 }
7204 if (activePendingProgress.isLastQuestion) {7369 if (activePendingProgress.isLastQuestion) {
...@@ -7212,6 +7377,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7212,6 +7377,7 @@ export default function ChatView(props: ChatViewProps) {
7212 activePendingProgress,7377 activePendingProgress,
7213 activePendingResolvedAnswers,7378 activePendingResolvedAnswers,
7214 activePendingUserInput,7379 activePendingUserInput,
7380 activePendingIsResponding,
7215 onRespondToUserInput,7381 onRespondToUserInput,
7216 setActivePendingUserInputQuestionIndex,7382 setActivePendingUserInputQuestionIndex,
7217 ]);7383 ]);
...@@ -8119,6 +8285,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8119,6 +8285,7 @@ export default function ChatView(props: ChatViewProps) {
8119 environmentId={environmentId}8285 environmentId={environmentId}
8120 attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown}8286 attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown}
8121 supportsAttachmentUploads={supportsAttachmentUploads}8287 supportsAttachmentUploads={supportsAttachmentUploads}
8288 supportsQuestionAttachments={supportsQuestionAttachments}
8122 maxFileAttachmentBytes={maxFileAttachmentBytes}8289 maxFileAttachmentBytes={maxFileAttachmentBytes}
8123 routeKind={routeKind}8290 routeKind={routeKind}
8124 routeThreadRef={routeThreadRef}8291 routeThreadRef={routeThreadRef}
apps/web/src/components/chat/ChatComposer.tsx+156-47
...@@ -1,4 +1,9 @@...@@ -1,4 +1,9 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import {
3 questionAttachmentDraftId,
4 useQuestionAttachmentPreparation,
5 changeQuestionAttachmentPreparation,
6} from "../../questionAttachments";
2import type {7import type {
3 ApprovalRequestId,8 ApprovalRequestId,
4 AssistantCitation,9 AssistantCitation,
...@@ -1227,6 +1232,7 @@ export interface ChatComposerProps {...@@ -1227,6 +1232,7 @@ export interface ChatComposerProps {
1227 environmentId: EnvironmentId;1232 environmentId: EnvironmentId;
1228 attachmentUploadsCapabilityKnown: boolean;1233 attachmentUploadsCapabilityKnown: boolean;
1229 supportsAttachmentUploads: boolean;1234 supportsAttachmentUploads: boolean;
1235 supportsQuestionAttachments: boolean;
1230 maxFileAttachmentBytes: number | null;1236 maxFileAttachmentBytes: number | null;
1231 routeKind: "server" | "draft";1237 routeKind: "server" | "draft";
1232 routeThreadRef: ScopedThreadRef;1238 routeThreadRef: ScopedThreadRef;
...@@ -1376,6 +1382,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1376,6 +1382,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1376 environmentId,1382 environmentId,
1377 attachmentUploadsCapabilityKnown,1383 attachmentUploadsCapabilityKnown,
1378 supportsAttachmentUploads,1384 supportsAttachmentUploads,
1385 supportsQuestionAttachments,
1379 maxFileAttachmentBytes,1386 maxFileAttachmentBytes,
1380 routeKind,1387 routeKind,
1381 routeThreadRef,1388 routeThreadRef,
...@@ -1468,9 +1475,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1468,9 +1475,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1468 // happened while they awaited.1475 // happened while they awaited.
1469 const composerDraftTargetKeyRef = useRef("");1476 const composerDraftTargetKeyRef = useRef("");
1470 composerDraftTargetKeyRef.current = composerTargetKey(composerDraftTarget);1477 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);
1471 const prompt = composerDraft.prompt;1491 const prompt = composerDraft.prompt;
1472 const composerImages = composerDraft.images;1492 const composerImages = attachmentDraft.images;
1473 const composerFiles = composerDraft.files;1493 const composerFiles = attachmentDraft.files;
1474 const composerVideos = composerFiles.filter((file) =>1494 const composerVideos = composerFiles.filter((file) =>
1475 isPreviewableComposerVideo(file, environmentId),1495 isPreviewableComposerVideo(file, environmentId),
1476 );1496 );
...@@ -1500,7 +1520,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1500,7 +1520,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1500 );1520 );
1501 return composerImages.filter((image) => !previewAnnotationIds.has(image.id));1521 return composerImages.filter((image) => !previewAnnotationIds.has(image.id));
1502 }, [composerImages, composerPreviewAnnotations]);1522 }, [composerImages, composerPreviewAnnotations]);
1503 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;1523 const nonPersistedComposerImageIds = attachmentDraft.nonPersistedImageIds;
1504 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);1524 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
1505 const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length;1525 const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length;
1506 const fileStagingLimit = fileAttachmentStagingLimit({1526 const fileStagingLimit = fileAttachmentStagingLimit({
...@@ -1515,6 +1535,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1515,6 +1535,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1515 maxFileAttachmentBytes,1535 maxFileAttachmentBytes,
1516 });1536 });
1517 const attachmentBlockReason =1537 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) ??
1518 fileCapabilityBlockReason ??1543 fileCapabilityBlockReason ??
1519 (supportsAttachmentUploads1544 (supportsAttachmentUploads
1520 ? needsReattachFileCount > 01545 ? needsReattachFileCount > 0
...@@ -1528,7 +1553,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1528,7 +1553,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1528 })1553 })
1529 : null);1554 : null);
1530 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);1555 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
1531 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
1532 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);1556 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
1533 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);1557 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
1534 const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);1558 const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);
...@@ -1596,11 +1620,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1596,11 +1620,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1596 if (attachment.type === "file" && composerFileNeedsReattach(attachment)) {1620 if (attachment.type === "file" && composerFileNeedsReattach(attachment)) {
1597 continue;1621 continue;
1598 }1622 }
1599 startAttachmentUpload({ environmentId, image: attachment, draftTarget: composerDraftTarget });1623 startAttachmentUpload({
1624 environmentId,
1625 image: attachment,
1626 draftTarget: attachmentDraftTarget,
1627 });
1600 }1628 }
1601 }, [1629 }, [
1602 attachmentUploadsCapabilityKnown,1630 attachmentUploadsCapabilityKnown,
1603 composerDraftTarget,1631 attachmentDraftTarget,
1604 composerFiles,1632 composerFiles,
1605 composerImages,1633 composerImages,
1606 environmentId,1634 environmentId,
...@@ -1621,7 +1649,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1621,7 +1649,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1621 const upload = uploadsByImageId[file.id];1649 const upload = uploadsByImageId[file.id];
1622 if (upload?.status === "ready" && upload.environmentId === environmentId) {1650 if (upload?.status === "ready" && upload.environmentId === environmentId) {
1623 setComposerDraftFileUpload(1651 setComposerDraftFileUpload(
1624 composerDraftTarget,1652 attachmentDraftTarget,
1625 file.id,1653 file.id,
1626 environmentId,1654 environmentId,
1627 upload.attachmentId,1655 upload.attachmentId,
...@@ -1630,7 +1658,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1630,7 +1658,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1630 }1658 }
1631 }, [1659 }, [
1632 attachmentUploadsCapabilityKnown,1660 attachmentUploadsCapabilityKnown,
1633 composerDraftTarget,1661 attachmentDraftTarget,
1634 composerFiles,1662 composerFiles,
1635 environmentId,1663 environmentId,
1636 maxFileAttachmentBytes,1664 maxFileAttachmentBytes,
...@@ -1713,7 +1741,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1713,7 +1741,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1713 );1741 );
1714 const sendDisabledReason =1742 const sendDisabledReason =
1715 externalSendDisabledReason ??1743 externalSendDisabledReason ??
1716 (activePendingProgress ? null : (attachmentBlockReason ?? providerSendBlockReason));1744 (activePendingProgress
1745 ? attachmentBlockReason
1746 : (attachmentBlockReason ?? providerSendBlockReason));
1717 const isSendDisabled = sendDisabledReason !== null;1747 const isSendDisabled = sendDisabledReason !== null;
1718 const selectedProviderStatus = useMemo(1748 const selectedProviderStatus = useMemo(
1719 () => selectedProviderEntry?.snapshot ?? null,1749 () => selectedProviderEntry?.snapshot ?? null,
...@@ -1938,7 +1968,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1938,7 +1968,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1938 * and checked before sending or compacting so an image cannot move into1968 * and checked before sending or compacting so an image cannot move into
1939 * the next draft.1969 * the next draft.
1940 */1970 */
1941 const pendingImageCompressionsRef = useRef<Map<ThreadId, number>>(new Map());1971 const pendingImageCompressionsRef = useRef<Map<string, number>>(new Map());
19421972
1943 // ------------------------------------------------------------------1973 // ------------------------------------------------------------------
1944 // Derived: composer send state1974 // Derived: composer send state
...@@ -2127,7 +2157,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2127,7 +2157,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2127 (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null);2157 (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null);
2128 const showCollapsedMobilePromptRow =2158 const showCollapsedMobilePromptRow =
2129 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;2159 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));
2131 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;2165 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
2132 const composerFooterActionLayoutKey = useMemo(() => {2166 const composerFooterActionLayoutKey = useMemo(() => {
2133 if (activePendingProgress) {2167 if (activePendingProgress) {
...@@ -2219,12 +2253,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2219,12 +2253,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2219 ? {2253 ? {
2220 questionIndex: activePendingProgress.questionIndex,2254 questionIndex: activePendingProgress.questionIndex,
2221 isLastQuestion: activePendingProgress.isLastQuestion,2255 isLastQuestion: activePendingProgress.isLastQuestion,
2222 canAdvance: activePendingProgress.canAdvance,2256 canAdvance:
2257 activePendingProgress.canAdvance &&
2258 !attachmentBlockReason &&
2259 !(questionPreparations[attachmentTargetKey] ?? 0),
2223 isResponding: activePendingIsResponding,2260 isResponding: activePendingIsResponding,
2224 isComplete: Boolean(activePendingResolvedAnswers),2261 isComplete: Boolean(activePendingResolvedAnswers),
2225 }2262 }
2226 : null,2263 : null,
2227 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],2264 [
2265 activePendingIsResponding,
2266 activePendingProgress,
2267 activePendingResolvedAnswers,
2268 attachmentBlockReason,
2269 questionPreparations,
2270 attachmentTargetKey,
2271 ],
2228 );2272 );
2229 const collapsedComposerPrimaryActionDisabled =2273 const collapsedComposerPrimaryActionDisabled =
2230 phase === "running" ||2274 phase === "running" ||
...@@ -2251,35 +2295,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2251,35 +2295,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22512295
2252 const addComposerImage = useCallback(2296 const addComposerImage = useCallback(
2253 (image: ComposerImageAttachment) => {2297 (image: ComposerImageAttachment) => {
2254 addComposerDraftImage(composerDraftTarget, image);2298 addComposerDraftImages(attachmentDraftTarget, [image]);
2255 },2299 },
2256 [composerDraftTarget, addComposerDraftImage],2300 [attachmentDraftTarget, addComposerDraftImages],
2257 );2301 );
22582302
2259 const addComposerImagesToDraft = useCallback(2303 const addComposerImagesToDraft = useCallback(
2260 (images: ComposerImageAttachment[]) => {2304 (images: ComposerImageAttachment[]) => {
2261 addComposerDraftImages(composerDraftTarget, images);2305 addComposerDraftImages(attachmentDraftTarget, images);
2262 },2306 },
2263 [composerDraftTarget, addComposerDraftImages],2307 [attachmentDraftTarget, addComposerDraftImages],
2264 );2308 );
22652309
2266 const addComposerFilesToDraft = useCallback(2310 const addComposerFilesToDraft = useCallback(
2267 (files: ComposerFileAttachment[]) => {2311 (files: ComposerFileAttachment[]) => {
2268 addComposerDraftFiles(composerDraftTarget, files);2312 addComposerDraftFiles(attachmentDraftTarget, files);
2269 },2313 },
2270 [addComposerDraftFiles, composerDraftTarget],2314 [addComposerDraftFiles, attachmentDraftTarget],
2271 );2315 );
22722316
2273 const removeComposerImageFromDraft = useCallback(2317 const removeComposerImageFromDraft = useCallback(
2274 (imageId: string) => {2318 (imageId: string) => {
2319 if (questionAttachmentTarget && activePendingIsResponding) return;
2275 releaseAttachmentUpload(imageId);2320 releaseAttachmentUpload(imageId);
2276 removeComposerDraftImage(composerDraftTarget, imageId);2321 removeComposerDraftImage(attachmentDraftTarget, imageId);
2277 },2322 },
2278 [composerDraftTarget, removeComposerDraftImage],2323 [
2324 attachmentDraftTarget,
2325 questionAttachmentTarget,
2326 activePendingIsResponding,
2327 removeComposerDraftImage,
2328 ],
2279 );2329 );
22802330
2281 const removeComposerFileFromDraft = useCallback(2331 const removeComposerFileFromDraft = useCallback(
2282 (fileId: string) => {2332 (fileId: string) => {
2333 if (questionAttachmentTarget && activePendingIsResponding) return;
2283 // Release by the draft attachment, not the bare queue key: a hydrated2334 // Release by the draft attachment, not the bare queue key: a hydrated
2284 // file's upload lives server-side under its persisted attachment id.2335 // file's upload lives server-side under its persisted attachment id.
2285 const file = composerFilesRef.current.find((candidate) => candidate.id === fileId);2336 const file = composerFilesRef.current.find((candidate) => candidate.id === fileId);
...@@ -2288,9 +2339,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2288,9 +2339,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2288 } else {2339 } else {
2289 releaseAttachmentUpload(fileId);2340 releaseAttachmentUpload(fileId);
2290 }2341 }
2291 removeComposerDraftFile(composerDraftTarget, fileId);2342 removeComposerDraftFile(attachmentDraftTarget, fileId);
2292 },2343 },
2293 [composerDraftTarget, composerFilesRef, removeComposerDraftFile],2344 [
2345 attachmentDraftTarget,
2346 questionAttachmentTarget,
2347 activePendingIsResponding,
2348 composerFilesRef,
2349 removeComposerDraftFile,
2350 ],
2294 );2351 );
22952352
2296 const removeComposerTerminalContextFromDraft = useCallback(2353 const removeComposerTerminalContextFromDraft = useCallback(
...@@ -2525,11 +2582,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2525,11 +2582,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2525 let cancelled = false;2582 let cancelled = false;
2526 void (async () => {2583 void (async () => {
2527 if (composerImages.length === 0) {2584 if (composerImages.length === 0) {
2528 clearComposerDraftPersistedAttachments(composerDraftTarget);2585 clearComposerDraftPersistedAttachments(attachmentDraftTarget);
2529 return;2586 return;
2530 }2587 }
2531 const getPersistedAttachmentsForThread = () =>2588 const getPersistedAttachmentsForThread = () =>
2532 getComposerDraft(composerDraftTarget)?.persistedAttachments ?? [];2589 getComposerDraft(attachmentDraftTarget)?.persistedAttachments ?? [];
2533 try {2590 try {
2534 const currentPersistedAttachments = getPersistedAttachmentsForThread();2591 const currentPersistedAttachments = getPersistedAttachmentsForThread();
2535 const existingPersistedById = new Map(2592 const existingPersistedById = new Map(
...@@ -2558,7 +2615,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2558,7 +2615,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2558 );2615 );
2559 const serialized = Array.from(stagedAttachmentById.values());2616 const serialized = Array.from(stagedAttachmentById.values());
2560 if (cancelled) return;2617 if (cancelled) return;
2561 syncComposerDraftPersistedAttachments(composerDraftTarget, serialized);2618 syncComposerDraftPersistedAttachments(attachmentDraftTarget, serialized);
2562 } catch {2619 } catch {
2563 const currentImageIds = new Set(composerImages.map((image) => image.id));2620 const currentImageIds = new Set(composerImages.map((image) => image.id));
2564 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();2621 const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
...@@ -2573,14 +2630,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2573,14 +2630,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2573 fallbackPersistedIdSet.has(attachment.id),2630 fallbackPersistedIdSet.has(attachment.id),
2574 );2631 );
2575 if (cancelled) return;2632 if (cancelled) return;
2576 syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments);2633 syncComposerDraftPersistedAttachments(attachmentDraftTarget, fallbackAttachments);
2577 }2634 }
2578 })();2635 })();
2579 return () => {2636 return () => {
2580 cancelled = true;2637 cancelled = true;
2581 };2638 };
2582 }, [2639 }, [
2583 composerDraftTarget,2640 attachmentDraftTarget,
2584 clearComposerDraftPersistedAttachments,2641 clearComposerDraftPersistedAttachments,
2585 composerImages,2642 composerImages,
2586 getComposerDraft,2643 getComposerDraft,
...@@ -2946,7 +3003,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2946,7 +3003,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2946 // image: the turn snapshot wouldn't include it, and it would surface3003 // image: the turn snapshot wouldn't include it, and it would surface
2947 // in the *next* draft instead. Only oversized images hit this — small3004 // in the *next* draft instead. Only oversized images hit this — small
2948 // files clear the pending counter within a microtask.3005 // 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 ) {
2950 event?.preventDefault();3010 event?.preventDefault();
2951 toastManager.add({3011 toastManager.add({
2952 type: "info",3012 type: "info",
...@@ -2976,6 +3036,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2976,6 +3036,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2976 [3036 [
2977 activeThreadId,3037 activeThreadId,
2978 activePendingProgress,3038 activePendingProgress,
3039 attachmentTargetKey,
2979 blurMobileComposerAfterSend,3040 blurMobileComposerAfterSend,
2980 isSendDisabled,3041 isSendDisabled,
2981 noProviderAvailable,3042 noProviderAvailable,
...@@ -3009,7 +3070,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3009,7 +3070,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3009 // The compact buttons cannot see the compression counter (it lives in3070 // The compact buttons cannot see the compression counter (it lives in
3010 // a ref), so they render enabled during a paste; toast instead of3071 // a ref), so they render enabled during a paste; toast instead of
3011 // silently ignoring the click.3072 // silently ignoring the click.
3012 if ((pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0) {3073 if ((pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0) {
3013 toastManager.add({3074 toastManager.add({
3014 type: "info",3075 type: "info",
3015 title: "Still compressing a pasted image.",3076 title: "Still compressing a pasted image.",
...@@ -4255,10 +4316,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4255,10 +4316,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4255 // ------------------------------------------------------------------4316 // ------------------------------------------------------------------
4256 const addComposerAttachments = async (files: File[]) => {4317 const addComposerAttachments = async (files: File[]) => {
4257 if (!activeThreadId || files.length === 0) return;4318 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 ) {
4259 toastManager.add({4325 toastManager.add({
4260 type: "error",4326 type: "error",
4261 title: "Attach files after answering pending questions.",4327 title: "This question cannot accept attachments.",
4262 });4328 });
4263 return;4329 return;
4264 }4330 }
...@@ -4270,9 +4336,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4270,9 +4336,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4270 // Validation happens synchronously so concurrent pastes see each other:4336 // Validation happens synchronously so concurrent pastes see each other:
4271 // accepted files reserve their attachment slots (via the pending counter)4337 // accepted files reserve their attachment slots (via the pending counter)
4272 // before the first await, keeping the total under the limit.4338 // 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;
4274 let reservedCount =4359 let reservedCount =
4275 composerImagesRef.current.length + composerFilesRef.current.length + pendingCount;4360 composerImagesRef.current.length +
4361 composerFilesRef.current.length +
4362 pendingCount +
4363 otherQuestionAttachments;
4276 // A pick that matches a needs-reattach marker replaces it in the draft, so4364 // A pick that matches a needs-reattach marker replaces it in the draft, so
4277 // it must not consume a slot; a draft full of markers would otherwise hit4365 // it must not consume a slot; a draft full of markers would otherwise hit
4278 // the capacity error before the replacement path could run.4366 // the capacity error before the replacement path could run.
...@@ -4351,7 +4439,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4351,7 +4439,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4351 }4439 }
4352 if (acceptedImages.length === 0) return;4440 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);
4355 try {4448 try {
4356 const nextImages: ComposerImageAttachment[] = [];4449 const nextImages: ComposerImageAttachment[] = [];
4357 let compressionError: string | null = null;4450 let compressionError: string | null = null;
...@@ -4381,6 +4474,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4381,6 +4474,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4381 file: attachmentFile,4474 file: attachmentFile,
4382 });4475 });
4383 }4476 }
4477 if (
4478 questionAttachmentTarget &&
4479 !useQuestionAttachmentPreparation.getState().counts[questionAttachmentTarget]
4480 ) {
4481 for (const image of nextImages) URL.revokeObjectURL(image.previewUrl);
4482 return;
4483 }
4384 if (nextImages.length === 1 && nextImages[0]) {4484 if (nextImages.length === 1 && nextImages[0]) {
4385 addComposerImage(nextImages[0]);4485 addComposerImage(nextImages[0]);
4386 } else if (nextImages.length > 1) {4486 } else if (nextImages.length > 1) {
...@@ -4394,12 +4494,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4394,12 +4494,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4394 setThreadError(threadId, compressionError);4494 setThreadError(threadId, compressionError);
4395 }4495 }
4396 } finally {4496 } finally {
4497 if (questionAttachmentTarget)
4498 changeQuestionAttachmentPreparation(questionAttachmentTarget, -acceptedImages.length);
4397 const remaining =4499 const remaining =
4398 (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedImages.length;4500 (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) - acceptedImages.length;
4399 if (remaining > 0) {4501 if (remaining > 0) {
4400 pendingImageCompressionsRef.current.set(threadId, remaining);4502 pendingImageCompressionsRef.current.set(attachmentTargetKey, remaining);
4401 } else {4503 } else {
4402 pendingImageCompressionsRef.current.delete(threadId);4504 pendingImageCompressionsRef.current.delete(attachmentTargetKey);
4403 }4505 }
4404 }4506 }
4405 };4507 };
...@@ -4909,7 +5011,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4909,7 +5011,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4909 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (5011 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
4910 <ComposerPendingUserInputPanel5012 <ComposerPendingUserInputPanel
4911 pendingUserInputs={pendingUserInputs}5013 pendingUserInputs={pendingUserInputs}
4912 respondingRequestIds={respondingRequestIds}5014 respondingRequestIds={
5015 activePendingIsResponding && activePendingUserInput
5016 ? [...respondingRequestIds, activePendingUserInput.requestId]
5017 : respondingRequestIds
5018 }
4913 answers={activePendingDraftAnswers}5019 answers={activePendingDraftAnswers}
4914 questionIndex={activePendingQuestionIndex}5020 questionIndex={activePendingQuestionIndex}
4915 onToggleOption={onSelectActivePendingUserInputOption}5021 onToggleOption={onSelectActivePendingUserInputOption}
...@@ -4925,7 +5031,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4925,7 +5031,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4925 <div data-chat-composer-collapsed-controls="true">5031 <div data-chat-composer-collapsed-controls="true">
4926 <ComposerPendingUserInputPanel5032 <ComposerPendingUserInputPanel
4927 pendingUserInputs={pendingUserInputs}5033 pendingUserInputs={pendingUserInputs}
4928 respondingRequestIds={respondingRequestIds}5034 respondingRequestIds={
5035 activePendingIsResponding && activePendingUserInput
5036 ? [...respondingRequestIds, activePendingUserInput.requestId]
5037 : respondingRequestIds
5038 }
4929 answers={activePendingDraftAnswers}5039 answers={activePendingDraftAnswers}
4930 questionIndex={activePendingQuestionIndex}5040 questionIndex={activePendingQuestionIndex}
4931 onToggleOption={onSelectActivePendingUserInputOption}5041 onToggleOption={onSelectActivePendingUserInputOption}
...@@ -5148,7 +5258,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5148,7 +5258,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5148 retryAttachmentUpload({5258 retryAttachmentUpload({
5149 environmentId,5259 environmentId,
5150 image,5260 image,
5151 draftTarget: composerDraftTarget,5261 draftTarget: attachmentDraftTarget,
5152 }),5262 }),
5153 }5263 }
5154 : {})}5264 : {})}
...@@ -5192,7 +5302,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5192,7 +5302,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
51925302
5193 {!isComposerCollapsedMobile &&5303 {!isComposerCollapsedMobile &&
5194 !isComposerApprovalState &&5304 !isComposerApprovalState &&
5195 pendingUserInputs.length === 0 &&
5196 (uncommittedSnapShotIds.length > 0 ||5305 (uncommittedSnapShotIds.length > 0 ||
5197 composerVideos.length > 0 ||5306 composerVideos.length > 0 ||
5198 expandedComposerImages.length > 0) && (5307 expandedComposerImages.length > 0) && (
...@@ -5310,7 +5419,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5310,7 +5419,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5310 retryAttachmentUpload({5419 retryAttachmentUpload({
5311 environmentId,5420 environmentId,
5312 image,5421 image,
5313 draftTarget: composerDraftTarget,5422 draftTarget: attachmentDraftTarget,
5314 })5423 })
5315 }5424 }
5316 aria-label={`Retry upload for ${image.name}`}5425 aria-label={`Retry upload for ${image.name}`}
...@@ -5410,7 +5519,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5410,7 +5519,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5410 retryAttachmentUpload({5519 retryAttachmentUpload({
5411 environmentId,5520 environmentId,
5412 image: file,5521 image: file,
5413 draftTarget: composerDraftTarget,5522 draftTarget: attachmentDraftTarget,
5414 })5523 })
5415 }5524 }
5416 aria-label={`Retry upload for ${file.name}`}5525 aria-label={`Retry upload for ${file.name}`}
...@@ -5444,7 +5553,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5444,7 +5553,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
54445553
5445 {!isComposerCollapsedMobile &&5554 {!isComposerCollapsedMobile &&
5446 !isComposerApprovalState &&5555 !isComposerApprovalState &&
5447 pendingUserInputs.length === 0 &&
5448 composerOtherFiles.length > 0 && (5556 composerOtherFiles.length > 0 && (
5449 <div className="mb-3 flex flex-col gap-1">5557 <div className="mb-3 flex flex-col gap-1">
5450 {composerOtherFiles.map((file) => {5558 {composerOtherFiles.map((file) => {
...@@ -5487,7 +5595,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5487,7 +5595,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5487 retryAttachmentUpload({5595 retryAttachmentUpload({
5488 environmentId,5596 environmentId,
5489 image: file,5597 image: file,
5490 draftTarget: composerDraftTarget,5598 draftTarget: attachmentDraftTarget,
5491 })5599 })
5492 }5600 }
5493 aria-label={`Retry upload for ${file.name}`}5601 aria-label={`Retry upload for ${file.name}`}
...@@ -5587,7 +5695,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5587,7 +5695,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5587 isConnecting ||5695 isConnecting ||
5588 isComposerApprovalState ||5696 isComposerApprovalState ||
5589 projectSelectionRequired ||5697 projectSelectionRequired ||
5590 isChoiceOnlyPendingQuestion5698 isChoiceOnlyPendingQuestion ||
5699 activePendingIsResponding
5591 }5700 }
5592 />5701 />
5593 {isComposerResting ? collapsedComposerImagePreviews : null}5702 {isComposerResting ? collapsedComposerImagePreviews : null}
apps/web/src/components/chat/MessagesTimeline.test.tsx+76-1
...@@ -1,4 +1,10 @@...@@ -1,4 +1,10 @@
1import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";1import {
2 ApprovalRequestId,
3 CheckpointRef,
4 EnvironmentId,
5 MessageId,
6 TurnId,
7} from "@t3tools/contracts";
2import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react";8import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react";
3import { renderToStaticMarkup } from "react-dom/server";9import { renderToStaticMarkup } from "react-dom/server";
4import { create, type ReactTestRenderer } from "react-test-renderer";10import { create, type ReactTestRenderer } from "react-test-renderer";
...@@ -266,6 +272,75 @@ function buildSnapShotTimelineEntry(previewUrl?: string) {...@@ -266,6 +272,75 @@ function buildSnapShotTimelineEntry(previewUrl?: string) {
266}272}
267273
268describe("MessagesTimeline", () => {274describe("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
269 it.each([344 it.each([
270 { toolLifecycleStatus: "inProgress", isAtEnd: true },345 { toolLifecycleStatus: "inProgress", isAtEnd: true },
271 { toolLifecycleStatus: "inProgress", isAtEnd: false },346 { toolLifecycleStatus: "inProgress", isAtEnd: false },
apps/web/src/components/chat/MessagesTimeline.tsx+70-1
...@@ -3216,7 +3216,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -3216,7 +3216,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
3216 showWarningIndicator || showDestructiveRowStyle3216 showWarningIndicator || showDestructiveRowStyle
3217 ? undefined3217 ? undefined
3218 : (workEntry.toolIcon ?? workEntry.toolSource?.icon);3218 : (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));
3220 const viewedImagePath = workEntryViewedImagePath(workEntry);3222 const viewedImagePath = workEntryViewedImagePath(workEntry);
3221 const viewedImage =3223 const viewedImage =
3222 viewedImagePath && threadRef3224 viewedImagePath && threadRef
...@@ -3362,6 +3364,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -3362,6 +3364,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
3362 />3364 />
3363 </div>3365 </div>
3364 ) : null}3366 ) : null}
3367 {workEntry.questionAnswer ? (
3368 <QuestionAnswerHistory answer={workEntry.questionAnswer} />
3369 ) : null}
3365 {expanded && canExpand && expandedBody ? (3370 {expanded && canExpand && expandedBody ? (
3366 <div3371 <div
3367 className="mt-1 ms-7 cursor-default rounded-md bg-muted/40 px-3 py-2"3372 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: {...@@ -3374,3 +3379,67 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
3374 </div>3379 </div>
3375 );3380 );
3376});3381});
3382
3383function 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
...@@ -339,6 +339,32 @@ describe("attachmentUploadQueue", () => {...@@ -339,6 +339,32 @@ describe("attachmentUploadQueue", () => {
339 }339 }
340 });340 });
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
342 it("verifies an uploaded file reference before restoring it", async () => {368 it("verifies an uploaded file reference before restoring it", async () => {
343 const file: ComposerFileAttachment = {369 const file: ComposerFileAttachment = {
344 ...makeFile("restored"),370 ...makeFile("restored"),
apps/web/src/pendingUserInput.test.ts+19
...@@ -302,3 +302,22 @@ describe("pending user input question progress", () => {...@@ -302,3 +302,22 @@ describe("pending user input question progress", () => {
302 });302 });
303 });303 });
304});304});
305
306it("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";...@@ -3,6 +3,8 @@ import type { UserInputQuestion } from "@t3tools/contracts";
3export interface PendingUserInputDraftAnswer {3export interface PendingUserInputDraftAnswer {
4 selectedOptionValues?: string[];4 selectedOptionValues?: string[];
5 customAnswer?: string;5 customAnswer?: string;
6 attachmentCount?: number;
7 attachmentsBlocked?: boolean;
6}8}
79
8export interface PendingUserInputProgress {10export interface PendingUserInputProgress {
...@@ -41,6 +43,7 @@ export function resolvePendingUserInputAnswer(...@@ -41,6 +43,7 @@ export function resolvePendingUserInputAnswer(
41 question: UserInputQuestion,43 question: UserInputQuestion,
42 draft: PendingUserInputDraftAnswer | undefined,44 draft: PendingUserInputDraftAnswer | undefined,
43): string | string[] | null {45): string | string[] | null {
46 if (draft?.attachmentsBlocked) return null;
44 const customAnswer =47 const customAnswer =
45 question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);48 question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);
46 if (customAnswer) {49 if (customAnswer) {
...@@ -51,10 +54,17 @@ export function resolvePendingUserInputAnswer(...@@ -51,10 +54,17 @@ export function resolvePendingUserInputAnswer(
51 (value) => question.options.some((option) => (option.value ?? option.label) === value),54 (value) => question.options.some((option) => (option.value ?? option.label) === value),
52 );55 );
53 if (question.multiSelect) {56 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;
55 }62 }
5663
57 return selectedOptionValues[0] ?? null;64 return (
65 selectedOptionValues[0] ??
66 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)
67 );
58}68}
5969
60export function setPendingUserInputCustomAnswer(70export function setPendingUserInputCustomAnswer(
apps/web/src/questionAttachments.test.ts created+100
...@@ -0,0 +1,100 @@
1import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { beforeEach, expect, it, vi } from "vite-plus/test";
3import { useComposerDraftStore } from "./composerDraftStore";
4import {
5 questionAttachmentDraftId,
6 questionAttachmentDraftPrefix,
7 changeQuestionAttachmentPreparation,
8 clearQuestionAttachmentDraft,
9 useQuestionAttachmentPreparation,
10} from "./questionAttachments";
11
12const release = vi.hoisted(() => vi.fn());
13vi.mock("./lib/attachmentUploadQueue", () => ({ releaseDraftAttachments: release }));
14const environmentId = EnvironmentId.make("environment-1");
15const threadId = ThreadId.make("thread-1");
16const requestId = ApprovalRequestId.make("request-1");
17beforeEach(() => {
18 useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} });
19 useQuestionAttachmentPreparation.setState({ counts: {} });
20 release.mockClear();
21});
22it("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});
60it("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});
68it("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
85it("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 created+53
...@@ -0,0 +1,53 @@
1import type { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { create } from "zustand";
3import { DraftId, useComposerDraftStore } from "./composerDraftStore";
4import { releaseDraftAttachments } from "./lib/attachmentUploadQueue";
5
6export function questionAttachmentDraftPrefix(
7 environmentId: EnvironmentId,
8 threadId: ThreadId,
9): string {
10 return `${encodeURIComponent(JSON.stringify(environmentId))}:question-${encodeURIComponent(JSON.stringify(threadId))}-`;
11}
12
13export 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
24export const useQuestionAttachmentPreparation = create<{ counts: Record<string, number> }>(() => ({
25 counts: {},
26}));
27
28export 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
38export 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
...@@ -2,7 +2,9 @@ import {...@@ -2,7 +2,9 @@ import {
2 requestKindFromRequestType,2 requestKindFromRequestType,
3 type PendingApproval,3 type PendingApproval,
4} from "@t3tools/client-runtime/pending-requests";4} from "@t3tools/client-runtime/pending-requests";
5import { UserInputAttachmentAnswerPayload } from "@t3tools/contracts";
5import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
6import * as Arr from "effect/Array";8import * as Arr from "effect/Array";
7import { shallow } from "zustand/vanilla/shallow";9import { shallow } from "zustand/vanilla/shallow";
8import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";10import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
...@@ -51,6 +53,7 @@ export {...@@ -51,6 +53,7 @@ export {
51} from "@t3tools/client-runtime/work-log/presentation";53} from "@t3tools/client-runtime/work-log/presentation";
5254
53export interface WorkLogEntry {55export interface WorkLogEntry {
56 questionAnswer?: UserInputAttachmentAnswerPayload;
54 id: string;57 id: string;
55 createdAt: string;58 createdAt: string;
56 turnId?: TurnId | null;59 turnId?: TurnId | null;
...@@ -492,6 +495,8 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool...@@ -492,6 +495,8 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool
492 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");495 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
493}496}
494497
498const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload);
499
495function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {500function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
496 const cachedEntry = derivedWorkLogEntryByActivity.get(activity);501 const cachedEntry = derivedWorkLogEntryByActivity.get(activity);
497 if (cachedEntry) {502 if (cachedEntry) {
...@@ -543,6 +548,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -543,6 +548,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
543 : activity.tone,548 : activity.tone,
544 sourceActivityKind: activity.kind,549 sourceActivityKind: activity.kind,
545 };550 };
551 if (activity.kind === "user-input.answer-submitted") {
552 const answer = decodeQuestionAttachmentAnswer(payload);
553 if (Option.isSome(answer)) entry.questionAnswer = answer.value;
554 }
546 const itemType = extractWorkLogItemType(payload);555 const itemType = extractWorkLogItemType(payload);
547 const requestKind = extractWorkLogRequestKind(payload);556 const requestKind = extractWorkLogRequestKind(payload);
548 const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath);557 const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath);
docs/user/question-attachments.md created+9
...@@ -0,0 +1,9 @@
1# Files in question answers
2
3When 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
5Each 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
7Files 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
9Submitted 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
...@@ -80,6 +80,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -80,6 +80,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
80 connectionProbe: Schema.optionalKey(Schema.Boolean),80 connectionProbe: Schema.optionalKey(Schema.Boolean),
81 /** Missing on older servers, which still accept inline image attachments. */81 /** Missing on older servers, which still accept inline image attachments. */
82 attachmentUploads: Schema.optionalKey(Schema.Boolean),82 attachmentUploads: Schema.optionalKey(Schema.Boolean),
83 /** Uploaded files may accompany question answers. */
84 questionAttachments: Schema.optionalKey(Schema.Boolean),
83 /** Missing on servers that only accept image attachments. */85 /** Missing on servers that only accept image attachments. */
84 fileAttachments: Schema.optionalKey(86 fileAttachments: Schema.optionalKey(
85 Schema.Struct({87 Schema.Struct({
packages/contracts/src/orchestration.ts+18
...@@ -354,6 +354,22 @@ export const ChatAttachment = Schema.Union([...@@ -354,6 +354,22 @@ export const ChatAttachment = Schema.Union([
354 ChatUnknownAttachment,354 ChatUnknownAttachment,
355]);355]);
356export type ChatAttachment = typeof ChatAttachment.Type;356export type ChatAttachment = typeof ChatAttachment.Type;
357
358export 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);
364export type UserInputAttachments = typeof UserInputAttachments.Type;
365
366export const UserInputAttachmentAnswerPayload = Schema.Struct({
367 requestId: ApprovalRequestId,
368 questionTextById: Schema.optional(Schema.Record(Schema.String, Schema.String)),
369 answers: ProviderUserInputAnswers,
370 attachmentsByQuestionId: UserInputAttachments,
371});
372export type UserInputAttachmentAnswerPayload = typeof UserInputAttachmentAnswerPayload.Type;
357const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]);373const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]);
358export type UploadChatAttachment = typeof UploadChatAttachment.Type;374export type UploadChatAttachment = typeof UploadChatAttachment.Type;
359375
...@@ -1139,6 +1155,7 @@ const ThreadUserInputRespondCommand = Schema.Struct({...@@ -1139,6 +1155,7 @@ const ThreadUserInputRespondCommand = Schema.Struct({
1139 threadId: ThreadId,1155 threadId: ThreadId,
1140 requestId: ApprovalRequestId,1156 requestId: ApprovalRequestId,
1141 answers: ProviderUserInputAnswers,1157 answers: ProviderUserInputAnswers,
1158 attachmentsByQuestionId: Schema.optional(UserInputAttachments),
1142 createdAt: IsoDateTime,1159 createdAt: IsoDateTime,
1143});1160});
11441161
...@@ -1585,6 +1602,7 @@ const ThreadUserInputResponseRequestedPayload = Schema.Struct({...@@ -1585,6 +1602,7 @@ const ThreadUserInputResponseRequestedPayload = Schema.Struct({
1585 threadId: ThreadId,1602 threadId: ThreadId,
1586 requestId: ApprovalRequestId,1603 requestId: ApprovalRequestId,
1587 answers: ProviderUserInputAnswers,1604 answers: ProviderUserInputAnswers,
1605 attachmentsByQuestionId: Schema.optional(UserInputAttachments),
1588 createdAt: IsoDateTime,1606 createdAt: IsoDateTime,
1589});1607});
15901608
packages/contracts/src/provider.ts+2
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 ProviderRequestKind,19 ProviderRequestKind,
20 ProviderSandboxMode,20 ProviderSandboxMode,
21 ProviderUserInputAnswers,21 ProviderUserInputAnswers,
22 UserInputAttachments,
22 RuntimeMode,23 RuntimeMode,
23} from "./orchestration.ts";24} from "./orchestration.ts";
24import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";25import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";
...@@ -110,6 +111,7 @@ export const ProviderRespondToUserInputInput = Schema.Struct({...@@ -110,6 +111,7 @@ export const ProviderRespondToUserInputInput = Schema.Struct({
110 threadId: ThreadId,111 threadId: ThreadId,
111 requestId: ApprovalRequestId,112 requestId: ApprovalRequestId,
112 answers: ProviderUserInputAnswers,113 answers: ProviderUserInputAnswers,
114 attachmentsByQuestionId: Schema.optional(UserInputAttachments),
113});115});
114export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type;116export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type;
115117
pnpm-lock.yaml+3
...@@ -473,6 +473,9 @@ importers:...@@ -473,6 +473,9 @@ importers:
473 '@types/react':473 '@types/react':
474 specifier: ~19.2.0474 specifier: ~19.2.0
475 version: 19.2.16475 version: 19.2.16
476 '@types/react-dom':
477 specifier: ~19.2.3
478 version: 19.2.3(@types/react@19.2.16)
476 babel-preset-expo:479 babel-preset-expo:
477 specifier: ~57.0.9480 specifier: ~57.0.9
478 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)481 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)