authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 21:43:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 21:43:46-07:00
log2d162d157dfb0eea7e205c508f47d663ab4e8bf9
tree3705eab234773877c8064eb410f300e9260136fb
parentefeeb7737bed89bbfc6dc81662058f8ce7fba2ea
signature Signed by PGP key B5690EEEBB952194

fix(contracts): trimmed IDs round-trip (#16300)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

8 files changed, 193 insertions(+), 26 deletions(-)

apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx+9-2
...@@ -54,10 +54,17 @@ type DevicePreviewRouteScreenProps = StaticScreenProps<{...@@ -54,10 +54,17 @@ type DevicePreviewRouteScreenProps = StaticScreenProps<{
54export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) {54export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) {
55 const navigation = useNavigation();55 const navigation = useNavigation();
56 const onClose = useCallback(() => navigation.goBack(), [navigation]);56 const onClose = useCallback(() => navigation.goBack(), [navigation]);
57 const { environmentId, threadId } = route.params;
58 // A hand-typed deep link can carry a blank ID, which the branded IDs reject.
59 const isBlankLink = environmentId.trim().length === 0 || threadId.trim().length === 0;
60 useEffect(() => {
61 if (isBlankLink) onClose();
62 }, [isBlankLink, onClose]);
63 if (isBlankLink) return null;
57 return (64 return (
58 <DevicePreviewScreen65 <DevicePreviewScreen
59 environmentId={EnvironmentId.make(route.params.environmentId)}66 environmentId={EnvironmentId.make(environmentId)}
60 threadId={ThreadId.make(route.params.threadId)}67 threadId={ThreadId.make(threadId)}
61 onClose={onClose}68 onClose={onClose}
62 />69 />
63 );70 );
apps/mobile/src/features/files/AttachmentFileScreen.tsx+3-1
...@@ -172,7 +172,9 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) {...@@ -172,7 +172,9 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) {
172 const iconColor = useUniwindTheme()["--color-icon"];172 const iconColor = useUniwindTheme()["--color-icon"];
173 const isAndroid = Platform.OS === "android";173 const isAndroid = Platform.OS === "android";
174 const params = props.route.params;174 const params = props.route.params;
175 const environmentId = params.environmentId ? EnvironmentId.make(params.environmentId) : null;175 const environmentId = params.environmentId?.trim()
176 ? EnvironmentId.make(params.environmentId)
177 : null;
176 const sizeBytes = Number.parseInt(params.sizeBytes, 10) || 0;178 const sizeBytes = Number.parseInt(params.sizeBytes, 10) || 0;
177 const draftKey = params.draftKey ?? null;179 const draftKey = params.draftKey ?? null;
178 const draft = useComposerDraft(draftKey);180 const draft = useComposerDraft(draftKey);
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+4-5
...@@ -171,12 +171,11 @@ function FileHeader(props: {...@@ -171,12 +171,11 @@ function FileHeader(props: {
171171
172type FileViewMode = "preview" | "source";172type FileViewMode = "preview" | "source";
173173
174// A blank param (a hand-typed deep link) is treated as missing, since branded
175// IDs reject whitespace-only values.
174function firstRouteParam(value: string | string[] | undefined): string | null {176function firstRouteParam(value: string | string[] | undefined): string | null {
175 if (Array.isArray(value)) {177 const first = Array.isArray(value) ? value[0] : value;
176 return value[0] ?? null;178 return first === undefined || first.trim().length === 0 ? null : first;
177 }
178
179 return value ?? null;
180}179}
181180
182function normalizeRoutePath(value: string | string[] | undefined): string | null {181function normalizeRoutePath(value: string | string[] | undefined): string | null {
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+4-5
...@@ -188,12 +188,11 @@ type TerminalToolbarAction =...@@ -188,12 +188,11 @@ type TerminalToolbarAction =
188 readonly modifier: PendingModifier;188 readonly modifier: PendingModifier;
189 };189 };
190190
191// A blank param (a hand-typed deep link) is treated as missing, since branded
192// IDs reject whitespace-only values.
191function firstRouteParam(value: string | string[] | undefined): string | null {193function firstRouteParam(value: string | string[] | undefined): string | null {
192 if (Array.isArray(value)) {194 const first = Array.isArray(value) ? value[0] : value;
193 return value[0] ?? null;195 return first === undefined || first.trim().length === 0 ? null : first;
194 }
195
196 return value ?? null;
197}196}
198197
199function inferHostPlatform(environmentLabel: string | null): HostPlatform {198function inferHostPlatform(environmentLabel: string | null): HostPlatform {
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+4-5
...@@ -200,12 +200,11 @@ function InspectorPaneRoleActivation() {...@@ -200,12 +200,11 @@ function InspectorPaneRoleActivation() {
200 return null;200 return null;
201}201}
202202
203// A blank param (a hand-typed deep link) is treated as missing, since branded
204// IDs reject whitespace-only values.
203function firstRouteParam(value: string | string[] | undefined): string | null {205function firstRouteParam(value: string | string[] | undefined): string | null {
204 if (Array.isArray(value)) {206 const first = Array.isArray(value) ? value[0] : value;
205 return value[0] ?? null;207 return first === undefined || first.trim().length === 0 ? null : first;
206 }
207
208 return value ?? null;
209}208}
210209
211function OpeningThreadLoadingScreen() {210function OpeningThreadLoadingScreen() {
apps/mobile/src/state/use-thread-selection.ts+4-5
...@@ -38,12 +38,11 @@ type ThreadSelectionRouteParams = {...@@ -38,12 +38,11 @@ type ThreadSelectionRouteParams = {
38 readonly threadId?: string | string[];38 readonly threadId?: string | string[];
39};39};
4040
41// A blank param (a hand-typed deep link) is treated as missing, since branded
42// IDs reject whitespace-only values.
41function firstRouteParam(value: string | string[] | undefined): string | null {43function firstRouteParam(value: string | string[] | undefined): string | null {
42 if (Array.isArray(value)) {44 const first = Array.isArray(value) ? value[0] : value;
43 return value[0] ?? null;45 return first === undefined || first.trim().length === 0 ? null : first;
44 }
45
46 return value ?? null;
47}46}
4847
49function latestUserMessageAt(48function latestUserMessageAt(
packages/contracts/src/baseSchemas.test.ts+144
...@@ -1,13 +1,17 @@...@@ -1,13 +1,17 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import * as Arbitrary from "effect/Arbitrary";
2import * as DateTime from "effect/DateTime";3import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
4import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
57
8import * as BaseSchemas from "./baseSchemas.ts";
6import {9import {
7 ForwardCompatibleArray,10 ForwardCompatibleArray,
8 ForwardCompatibleUnion,11 ForwardCompatibleUnion,
9 ForwardCompatibleUnionArray,12 ForwardCompatibleUnionArray,
10 isUnknownUnionMember,13 isUnknownUnionMember,
14 TrimmedNonEmptyString,
11 UnknownUnionMember,15 UnknownUnionMember,
12} from "./baseSchemas.ts";16} from "./baseSchemas.ts";
1317
...@@ -43,6 +47,21 @@ describe("ForwardCompatibleArray", () => {...@@ -43,6 +47,21 @@ describe("ForwardCompatibleArray", () => {
43 { name: "a", count: 7 },47 { name: "a", count: 7 },
44 ]);48 ]);
45 });49 });
50
51 it("sends an element it cannot encode as a hole instead of failing the array", () => {
52 const Named = ForwardCompatibleArray(Schema.Struct({ name: TrimmedNonEmptyString }));
53 const wire = JSON.parse(
54 JSON.stringify(
55 Schema.encodeUnknownSync(Schema.toCodecJson(Named))([
56 { name: "a" },
57 { name: " " },
58 { name: "b" },
59 ]),
60 ),
61 );
62 expect(wire).toEqual([{ name: "a" }, null, { name: "b" }]);
63 expect(fromWire(Named)(wire)).toEqual([{ name: "a" }, { name: "b" }]);
64 });
46});65});
4766
48describe("ForwardCompatibleUnion", () => {67describe("ForwardCompatibleUnion", () => {
...@@ -105,3 +124,128 @@ describe("ForwardCompatibleUnionArray", () => {...@@ -105,3 +124,128 @@ describe("ForwardCompatibleUnionArray", () => {
105 expect(encode([square])).toEqual([square]);124 expect(encode([square])).toEqual([square]);
106 });125 });
107});126});
127
128describe("trimmed non-empty strings", () => {
129 const entityIds = [
130 "ThreadId",
131 "ProjectId",
132 "EnvironmentId",
133 "CommandId",
134 "EventId",
135 "MessageId",
136 "TurnId",
137 "RunId",
138 "RunAttemptId",
139 "NodeId",
140 "AuthSessionId",
141 "ProviderItemId",
142 "ProviderSessionId",
143 "ProviderThreadId",
144 "ProviderTurnId",
145 "RuntimeSessionId",
146 "RuntimeItemId",
147 "TurnItemId",
148 "RuntimeRequestId",
149 "RuntimeTaskId",
150 "ScheduledTaskId",
151 "ApprovalRequestId",
152 "CheckpointRef",
153 "CheckpointId",
154 "CheckpointScopeId",
155 "ContextHandoffId",
156 "ContextTransferId",
157 "RawEventId",
158 "PlanId",
159 ] as const;
160 const schemas = [
161 ["TrimmedNonEmptyString", TrimmedNonEmptyString],
162 ...entityIds.map((name) => [name, BaseSchemas[name]] as const),
163 ] as const;
164
165 // Schema-derived strings are rarely padded, so pad them with every kind of
166 // whitespace `trim()` removes, and include whitespace-only values.
167 const whitespace = Arbitrary.schema(
168 Schema.Literals([" ", "\t", "\n", "\r\n", "\v", "\f", "\u00a0", "\u2028", "\u3000", "\ufeff"]),
169 );
170 const padding = Arbitrary.array(whitespace, { maxLength: 4 }).pipe(
171 Arbitrary.map((parts) => parts.join("")),
172 );
173 const paddedString = Arbitrary.all([padding, Arbitrary.schema(Schema.String), padding]).pipe(
174 Arbitrary.map(([before, value, after]) => before + value + after),
175 );
176 const options = { arbitrary: { runs: 500 } };
177
178 for (const [name, schema] of schemas) {
179 const make = schema.makeOption;
180 const encode = Schema.encodeUnknownExit(schema);
181 const decode = Schema.decodeExit(schema);
182
183 it.prop(
184 `${name}: whatever make accepts encodes to something that decodes back`,
185 [paddedString],
186 ([input]) => {
187 const made = make(input);
188 if (made._tag === "None") {
189 expect(input.trim()).toBe("");
190 return;
191 }
192 const encoded = encode(made.value);
193 expect(encoded).toStrictEqual(Exit.succeed(input.trim()));
194 expect(decode(input.trim())).toStrictEqual(Exit.succeed(input.trim()));
195 },
196 options,
197 );
198
199 it.prop(
200 `${name}: decoding then encoding is stable`,
201 [paddedString],
202 ([input]) => {
203 const decoded = decode(input);
204 if (Exit.isFailure(decoded)) {
205 expect(input.trim()).toBe("");
206 expect(Exit.isFailure(encode(input))).toBe(true);
207 return;
208 }
209 expect(decoded.value).toBe(input.trim());
210 expect(encode(decoded.value)).toStrictEqual(Exit.succeed(decoded.value));
211 },
212 options,
213 );
214
215 it.prop(
216 `${name}: generated values round-trip`,
217 [schema],
218 ([value]) => {
219 const encoded = encode(value);
220 expect(Exit.isSuccess(encoded)).toBe(true);
221 if (Exit.isSuccess(encoded))
222 expect(decode(encoded.value)).toStrictEqual(Exit.succeed(value.trim()));
223 },
224 options,
225 );
226 }
227
228 const isThreadId = Schema.is(BaseSchemas.ThreadId);
229 const encodeThreadId = Schema.encodeUnknownExit(BaseSchemas.ThreadId);
230 const decodeThreadId = Schema.decodeExit(BaseSchemas.ThreadId);
231
232 it("rejects whitespace-only values everywhere", () => {
233 for (const value of [" ", "\t\n", "\u00a0\u3000"]) {
234 expect(() => BaseSchemas.ThreadId.make(value)).toThrow();
235 expect(isThreadId(value)).toBe(false);
236 expect(Exit.isFailure(encodeThreadId(value))).toBe(true);
237 expect(Exit.isFailure(decodeThreadId(value))).toBe(true);
238 }
239 });
240
241 it("keeps the encoded form and JSON Schema of valid values", () => {
242 expect(encodeThreadId(BaseSchemas.ThreadId.make("thread-1"))).toStrictEqual(
243 Exit.succeed("thread-1"),
244 );
245 expect(encodeThreadId(" a b ")).toStrictEqual(Exit.succeed("a b"));
246 expect(Schema.toJsonSchemaDocument(Schema.toType(BaseSchemas.ThreadId)).schema).toEqual({
247 type: "string",
248 minLength: 1,
249 });
250 });
251});
packages/contracts/src/baseSchemas.ts+21-3
...@@ -13,7 +13,18 @@ export const TrimmedString = Schema.String.pipe(...@@ -13,7 +13,18 @@ export const TrimmedString = Schema.String.pipe(
13 }),13 }),
14 ),14 ),
15);15);
16export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty());16/**
17 * Non-empty once trimmed. A `TrimmedString` only trims when decoding or
18 * encoding, so `make` and encode see the untrimmed value: a plain
19 * `isNonEmpty` would accept `" "` there and encode it to `""`, which no
20 * longer decodes.
21 */
22const isNonBlank = Schema.makeFilter((value: string) => value.trim().length > 0, {
23 expected: "a non-blank string",
24 toJsonSchema: () => [{ minLength: 1 }, true],
25 arbitraryConstraint: { minLength: 1 },
26});
27export const TrimmedNonEmptyString = TrimmedString.check(isNonBlank);
1728
18export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));29export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
19export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));30export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
...@@ -108,7 +119,7 @@ export const OmittedWhenNull = <Value extends Schema.Top>(value: Value) => {...@@ -108,7 +119,7 @@ export const OmittedWhenNull = <Value extends Schema.Top>(value: Value) => {
108 *119 *
109 * Decoding runs each value through its own schema, so transformations (dates,120 * Decoding runs each value through its own schema, so transformations (dates,
110 * trimming, decoding defaults) apply as usual. Only values that schema121 * trimming, decoding defaults) apply as usual. Only values that schema
111 * rejects are dropped; encoding is the plain encoding.122 * rejects are dropped, on either side.
112 *123 *
113 * For a tagged union, prefer {@link ForwardCompatibleUnion}: it drops only124 * For a tagged union, prefer {@link ForwardCompatibleUnion}: it drops only
114 * values whose tag this build does not know, so a known member with a broken125 * values whose tag this build does not know, so a known member with a broken
...@@ -119,10 +130,17 @@ export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Elem...@@ -119,10 +130,17 @@ export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Elem
119 Schema.UndefinedOr(element).pipe(130 Schema.UndefinedOr(element).pipe(
120 // An element this build cannot read becomes a hole, filtered out below.131 // An element this build cannot read becomes a hole, filtered out below.
121 Schema.catchDecoding(() => Effect.succeedSome(undefined)),132 Schema.catchDecoding(() => Effect.succeedSome(undefined)),
133 // Likewise an element that cannot be encoded is sent as a hole, so one
134 // bad element costs only itself rather than the whole payload.
135 Schema.catchEncoding(() => Effect.succeedSome(undefined)),
122 ),136 ),
123 ).pipe(137 ).pipe(
124 Schema.decodeTo(138 Schema.decodeTo(
125 Schema.Array(Schema.toType(element)),139 Schema.Array(
140 Schema.UndefinedOr(Schema.toType(element)).pipe(
141 Schema.catchEncoding(() => Effect.succeedSome(undefined)),
142 ),
143 ),
126 SchemaTransformation.transform<144 SchemaTransformation.transform<
127 ReadonlyArray<Element["Type"]>,145 ReadonlyArray<Element["Type"]>,
128 ReadonlyArray<Element["Type"] | undefined>146 ReadonlyArray<Element["Type"] | undefined>