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<{
5454export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) {
5555 const navigation = useNavigation();
5656 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;
5764 return (
5865 <DevicePreviewScreen
59 environmentId={EnvironmentId.make(route.params.environmentId)}
60 threadId={ThreadId.make(route.params.threadId)}
66 environmentId={EnvironmentId.make(environmentId)}
67 threadId={ThreadId.make(threadId)}
6168 onClose={onClose}
6269 />
6370 );
apps/mobile/src/features/files/AttachmentFileScreen.tsx+3-1
......@@ -172,7 +172,9 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) {
172172 const iconColor = useUniwindTheme()["--color-icon"];
173173 const isAndroid = Platform.OS === "android";
174174 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;
176178 const sizeBytes = Number.parseInt(params.sizeBytes, 10) || 0;
177179 const draftKey = params.draftKey ?? null;
178180 const draft = useComposerDraft(draftKey);
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+4-5
......@@ -171,12 +171,11 @@ function FileHeader(props: {
171171
172172type 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.
174176function firstRouteParam(value: string | string[] | undefined): string | null {
175 if (Array.isArray(value)) {
176 return value[0] ?? null;
177 }
178
179 return value ?? null;
177 const first = Array.isArray(value) ? value[0] : value;
178 return first === undefined || first.trim().length === 0 ? null : first;
180179}
181180
182181function normalizeRoutePath(value: string | string[] | undefined): string | null {
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+4-5
......@@ -188,12 +188,11 @@ type TerminalToolbarAction =
188188 readonly modifier: PendingModifier;
189189 };
190190
191// A blank param (a hand-typed deep link) is treated as missing, since branded
192// IDs reject whitespace-only values.
191193function firstRouteParam(value: string | string[] | undefined): string | null {
192 if (Array.isArray(value)) {
193 return value[0] ?? null;
194 }
195
196 return value ?? null;
194 const first = Array.isArray(value) ? value[0] : value;
195 return first === undefined || first.trim().length === 0 ? null : first;
197196}
198197
199198function inferHostPlatform(environmentLabel: string | null): HostPlatform {
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+4-5
......@@ -200,12 +200,11 @@ function InspectorPaneRoleActivation() {
200200 return null;
201201}
202202
203// A blank param (a hand-typed deep link) is treated as missing, since branded
204// IDs reject whitespace-only values.
203205function firstRouteParam(value: string | string[] | undefined): string | null {
204 if (Array.isArray(value)) {
205 return value[0] ?? null;
206 }
207
208 return value ?? null;
206 const first = Array.isArray(value) ? value[0] : value;
207 return first === undefined || first.trim().length === 0 ? null : first;
209208}
210209
211210function OpeningThreadLoadingScreen() {
apps/mobile/src/state/use-thread-selection.ts+4-5
......@@ -38,12 +38,11 @@ type ThreadSelectionRouteParams = {
3838 readonly threadId?: string | string[];
3939};
4040
41// A blank param (a hand-typed deep link) is treated as missing, since branded
42// IDs reject whitespace-only values.
4143function firstRouteParam(value: string | string[] | undefined): string | null {
42 if (Array.isArray(value)) {
43 return value[0] ?? null;
44 }
45
46 return value ?? null;
44 const first = Array.isArray(value) ? value[0] : value;
45 return first === undefined || first.trim().length === 0 ? null : first;
4746}
4847
4948function latestUserMessageAt(
packages/contracts/src/baseSchemas.test.ts+144
......@@ -1,13 +1,17 @@
11import { describe, expect, it } from "@effect/vitest";
2import * as Arbitrary from "effect/Arbitrary";
23import * as DateTime from "effect/DateTime";
34import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
46import * as Schema from "effect/Schema";
57
8import * as BaseSchemas from "./baseSchemas.ts";
69import {
710 ForwardCompatibleArray,
811 ForwardCompatibleUnion,
912 ForwardCompatibleUnionArray,
1013 isUnknownUnionMember,
14 TrimmedNonEmptyString,
1115 UnknownUnionMember,
1216} from "./baseSchemas.ts";
1317
......@@ -43,6 +47,21 @@ describe("ForwardCompatibleArray", () => {
4347 { name: "a", count: 7 },
4448 ]);
4549 });
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 });
4665});
4766
4867describe("ForwardCompatibleUnion", () => {
......@@ -105,3 +124,128 @@ describe("ForwardCompatibleUnionArray", () => {
105124 expect(encode([square])).toEqual([square]);
106125 });
107126});
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(
1313 }),
1414 ),
1515);
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
1829export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
1930export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
......@@ -108,7 +119,7 @@ export const OmittedWhenNull = <Value extends Schema.Top>(value: Value) => {
108119 *
109120 * Decoding runs each value through its own schema, so transformations (dates,
110121 * 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.
112123 *
113124 * For a tagged union, prefer {@link ForwardCompatibleUnion}: it drops only
114125 * 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
119130 Schema.UndefinedOr(element).pipe(
120131 // An element this build cannot read becomes a hole, filtered out below.
121132 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)),
122136 ),
123137 ).pipe(
124138 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 ),
126144 SchemaTransformation.transform<
127145 ReadonlyArray<Element["Type"]>,
128146 ReadonlyArray<Element["Type"] | undefined>