| author | |
| committer | |
| log | 2d162d157dfb0eea7e205c508f47d663ab4e8bf9 |
| tree | 3705eab234773877c8064eb410f300e9260136fb |
| parent | efeeb7737bed89bbfc6dc81662058f8ce7fba2ea |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 54 | export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) { |
| 55 | 55 | const navigation = useNavigation(); |
| 56 | 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 | 64 | return ( |
| 58 | 65 | <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)} | |
| 61 | 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 | 172 | const iconColor = useUniwindTheme()["--color-icon"]; |
| 173 | 173 | const isAndroid = Platform.OS === "android"; |
| 174 | 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 | 178 | const sizeBytes = Number.parseInt(params.sizeBytes, 10) || 0; |
| 177 | 179 | const draftKey = params.draftKey ?? null; |
| 178 | 180 | const draft = useComposerDraft(draftKey); |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+4-5| ... | ... | @@ -171,12 +171,11 @@ function FileHeader(props: { |
| 171 | 171 | |
| 172 | 172 | type FileViewMode = "preview" | "source"; |
| 173 | 173 | |
| 174 | // A blank param (a hand-typed deep link) is treated as missing, since branded | |
| 175 | // IDs reject whitespace-only values. | |
| 174 | 176 | function 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; | |
| 180 | 179 | } |
| 181 | 180 | |
| 182 | 181 | function normalizeRoutePath(value: string | string[] | undefined): string | null { |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+4-5| ... | ... | @@ -188,12 +188,11 @@ type TerminalToolbarAction = |
| 188 | 188 | readonly modifier: PendingModifier; |
| 189 | 189 | }; |
| 190 | 190 | |
| 191 | // A blank param (a hand-typed deep link) is treated as missing, since branded | |
| 192 | // IDs reject whitespace-only values. | |
| 191 | 193 | function 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; | |
| 197 | 196 | } |
| 198 | 197 | |
| 199 | 198 | function inferHostPlatform(environmentLabel: string | null): HostPlatform { |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+4-5| ... | ... | @@ -200,12 +200,11 @@ function InspectorPaneRoleActivation() { |
| 200 | 200 | return null; |
| 201 | 201 | } |
| 202 | 202 | |
| 203 | // A blank param (a hand-typed deep link) is treated as missing, since branded | |
| 204 | // IDs reject whitespace-only values. | |
| 203 | 205 | function 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; | |
| 209 | 208 | } |
| 210 | 209 | |
| 211 | 210 | function OpeningThreadLoadingScreen() { |
apps/mobile/src/state/use-thread-selection.ts+4-5| ... | ... | @@ -38,12 +38,11 @@ type ThreadSelectionRouteParams = { |
| 38 | 38 | readonly threadId?: string | string[]; |
| 39 | 39 | }; |
| 40 | 40 | |
| 41 | // A blank param (a hand-typed deep link) is treated as missing, since branded | |
| 42 | // IDs reject whitespace-only values. | |
| 41 | 43 | function 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; | |
| 47 | 46 | } |
| 48 | 47 | |
| 49 | 48 | function latestUserMessageAt( |
packages/contracts/src/baseSchemas.test.ts+144| ... | ... | @@ -1,13 +1,17 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Arbitrary from "effect/Arbitrary"; | |
| 2 | 3 | import * as DateTime from "effect/DateTime"; |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; | |
| 4 | 6 | import * as Schema from "effect/Schema"; |
| 5 | 7 | |
| 8 | import * as BaseSchemas from "./baseSchemas.ts"; | |
| 6 | 9 | import { |
| 7 | 10 | ForwardCompatibleArray, |
| 8 | 11 | ForwardCompatibleUnion, |
| 9 | 12 | ForwardCompatibleUnionArray, |
| 10 | 13 | isUnknownUnionMember, |
| 14 | TrimmedNonEmptyString, | |
| 11 | 15 | UnknownUnionMember, |
| 12 | 16 | } from "./baseSchemas.ts"; |
| 13 | 17 | |
| ... | ... | @@ -43,6 +47,21 @@ describe("ForwardCompatibleArray", () => { |
| 43 | 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 | }); |
| 47 | 66 | |
| 48 | 67 | describe("ForwardCompatibleUnion", () => { |
| ... | ... | @@ -105,3 +124,128 @@ describe("ForwardCompatibleUnionArray", () => { |
| 105 | 124 | expect(encode([square])).toEqual([square]); |
| 106 | 125 | }); |
| 107 | 126 | }); |
| 127 | ||
| 128 | describe("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 | 13 | }), |
| 14 | 14 | ), |
| 15 | 15 | ); |
| 16 | export 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 | */ | |
| 22 | const 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 | }); | |
| 27 | export const TrimmedNonEmptyString = TrimmedString.check(isNonBlank); | |
| 17 | 28 | |
| 18 | 29 | export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); |
| 19 | 30 | export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); |
| ... | ... | @@ -108,7 +119,7 @@ export const OmittedWhenNull = <Value extends Schema.Top>(value: Value) => { |
| 108 | 119 | * |
| 109 | 120 | * Decoding runs each value through its own schema, so transformations (dates, |
| 110 | 121 | * 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 | 124 | * For a tagged union, prefer {@link ForwardCompatibleUnion}: it drops only |
| 114 | 125 | * 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 | 130 | Schema.UndefinedOr(element).pipe( |
| 120 | 131 | // An element this build cannot read becomes a hole, filtered out below. |
| 121 | 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 | 137 | ).pipe( |
| 124 | 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 | 144 | SchemaTransformation.transform< |
| 127 | 145 | ReadonlyArray<Element["Type"]>, |
| 128 | 146 | ReadonlyArray<Element["Type"] | undefined> |