| author | |
| committer | |
| log | 13aabf3d7d0ec13e902234e86d3df27149c0e4a9 |
| tree | 9687a0093ff006c717d7adc0a3e77bd29f667f8d |
| parent | 8ddf200e8f637eaf9a4f0d3036ece8f1d3869d10 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>6 files changed, 122 insertions(+), 16 deletions(-)
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+12| ... | ... | @@ -55,6 +55,18 @@ type GitOverviewSheetProps = StaticScreenProps<{ |
| 55 | 55 | }; |
| 56 | 56 | |
| 57 | 57 | export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 58 | const navigation = useNavigation(); | |
| 59 | const { environmentId, threadId } = props.route.params; | |
| 60 | // A hand-typed deep link can carry a blank ID, which the branded IDs reject. | |
| 61 | const isBlankLink = environmentId.trim().length === 0 || threadId.trim().length === 0; | |
| 62 | useEffect(() => { | |
| 63 | if (isBlankLink) navigation.goBack(); | |
| 64 | }, [isBlankLink, navigation]); | |
| 65 | if (isBlankLink) return null; | |
| 66 | return <GitOverviewSheetContent {...props} />; | |
| 67 | } | |
| 68 | ||
| 69 | function GitOverviewSheetContent(props: GitOverviewSheetProps) { | |
| 58 | 70 | const { layout } = useAdaptiveWorkspaceLayout(); |
| 59 | 71 | const navigation = useNavigation(); |
| 60 | 72 | const insets = useSafeAreaInsets(); |
apps/web/src/lib/composerContextRecords.test.ts+2-2| ... | ... | @@ -514,7 +514,7 @@ describe("composerContextRecords", () => { |
| 514 | 514 | expect( |
| 515 | 515 | isSameComposerContextPayload(base, { |
| 516 | 516 | ...base, |
| 517 | elements: base.elements?.map((element) => ({ | |
| 517 | elements: base.elements!.map((element) => ({ | |
| 518 | 518 | ...element, |
| 519 | 519 | htmlPreview: '<button id="pay">Changed</button>', |
| 520 | 520 | })), |
| ... | ... | @@ -523,7 +523,7 @@ describe("composerContextRecords", () => { |
| 523 | 523 | expect( |
| 524 | 524 | isSameComposerContextPayload(base, { |
| 525 | 525 | ...base, |
| 526 | elements: base.elements?.map((element) => ({ | |
| 526 | elements: base.elements!.map((element) => ({ | |
| 527 | 527 | ...element, |
| 528 | 528 | source: { |
| 529 | 529 | functionName: "Checkout", |
packages/contracts/src/baseSchemas.test.ts+29-3| ... | ... | @@ -48,8 +48,9 @@ describe("ForwardCompatibleArray", () => { |
| 48 | 48 | ]); |
| 49 | 49 | }); |
| 50 | 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 })); | |
| 51 | const Named = ForwardCompatibleArray(Schema.Struct({ name: TrimmedNonEmptyString })); | |
| 52 | ||
| 53 | it("drops an element it cannot encode instead of failing the array", () => { | |
| 53 | 54 | const wire = JSON.parse( |
| 54 | 55 | JSON.stringify( |
| 55 | 56 | Schema.encodeUnknownSync(Schema.toCodecJson(Named))([ |
| ... | ... | @@ -59,9 +60,34 @@ describe("ForwardCompatibleArray", () => { |
| 59 | 60 | ]), |
| 60 | 61 | ), |
| 61 | 62 | ); |
| 62 | expect(wire).toEqual([{ name: "a" }, null, { name: "b" }]); | |
| 63 | expect(wire).toEqual([{ name: "a" }, { name: "b" }]); | |
| 63 | 64 | expect(fromWire(Named)(wire)).toEqual([{ name: "a" }, { name: "b" }]); |
| 64 | 65 | }); |
| 66 | ||
| 67 | it("drops it too when a wrapper reads the encoded array as JSON values", () => { | |
| 68 | // How context records are bounded before forward-compatible decoding. | |
| 69 | const Wrapped = Schema.Array(Schema.Unknown).pipe(Schema.decodeTo(Named)); | |
| 70 | expect( | |
| 71 | Schema.encodeUnknownSync(Schema.toCodecJson(Wrapped))([{ name: "a" }, { name: " " }]), | |
| 72 | ).toEqual([{ name: "a" }]); | |
| 73 | }); | |
| 74 | ||
| 75 | it("still drops the null holes a server on an earlier build sends", () => { | |
| 76 | expect(fromWire(Named)([{ name: "a" }, null, { name: "b" }])).toEqual([ | |
| 77 | { name: "a" }, | |
| 78 | { name: "b" }, | |
| 79 | ]); | |
| 80 | }); | |
| 81 | ||
| 82 | it("does not accept holes as a decoded value", () => { | |
| 83 | expect(Schema.is(Named)([undefined])).toBe(false); | |
| 84 | // A sparse array's missing index is a hole too. | |
| 85 | const sparse: Array<{ name: string }> = [{ name: "a" }]; | |
| 86 | sparse.length = 2; | |
| 87 | expect(Schema.is(Named)(sparse)).toBe(false); | |
| 88 | expect(() => Named.make(sparse)).toThrow(); | |
| 89 | expect(Schema.is(Named)([{ name: "a" }])).toBe(true); | |
| 90 | }); | |
| 65 | 91 | }); |
| 66 | 92 | |
| 67 | 93 | describe("ForwardCompatibleUnion", () => { |
packages/contracts/src/baseSchemas.ts+19-1| ... | ... | @@ -140,13 +140,31 @@ export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Elem |
| 140 | 140 | Schema.UndefinedOr(Schema.toType(element)).pipe( |
| 141 | 141 | Schema.catchEncoding(() => Effect.succeedSome(undefined)), |
| 142 | 142 | ), |
| 143 | ).check( | |
| 144 | // The holes above are an encoding detail: a decoded value has none, so | |
| 145 | // `Schema.is` and `make` still reject an array that does. Aborts, so a | |
| 146 | // later check on the array never sees a hole. | |
| 147 | Schema.makeFilter( | |
| 148 | (values) => { | |
| 149 | // Every index, not `every`, which skips the holes of a sparse array. | |
| 150 | for (let index = 0; index < values.length; index++) { | |
| 151 | if (values[index] === undefined) return false; | |
| 152 | } | |
| 153 | return true; | |
| 154 | }, | |
| 155 | { expected: "an array without holes" }, | |
| 156 | true, | |
| 157 | ), | |
| 143 | 158 | ), |
| 144 | 159 | SchemaTransformation.transform< |
| 145 | 160 | ReadonlyArray<Element["Type"]>, |
| 146 | 161 | ReadonlyArray<Element["Type"] | undefined> |
| 147 | 162 | >({ |
| 148 | 163 | decode: (values) => values.filter((value) => value !== undefined), |
| 149 | encode: (values) => values, | |
| 164 | // An element that fails its own checks is dropped before the wire, so | |
| 165 | // a wrapper that reads the encoded array as JSON values never meets | |
| 166 | // the hole it left. | |
| 167 | encode: (values) => values.filter((value) => value !== undefined), | |
| 150 | 168 | }), |
| 151 | 169 | ), |
| 152 | 170 | ) as unknown as ForwardCompatibleArray<Element>; |
packages/contracts/src/composerContext.test.ts+41| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import * as Cause from "effect/Cause"; | |
| 3 | import * as Exit from "effect/Exit"; | |
| 2 | 4 | import * as Option from "effect/Option"; |
| 3 | 5 | import * as Schema from "effect/Schema"; |
| 4 | 6 | |
| ... | ... | @@ -197,6 +199,45 @@ describe("OrchestrationMessageContext", () => { |
| 197 | 199 | ).toThrow(); |
| 198 | 200 | }); |
| 199 | 201 | |
| 202 | it("sends a message without a record it cannot encode", () => { | |
| 203 | const wire = Schema.encodeUnknownSync(Schema.toCodecJson(OrchestrationMessageContext))({ | |
| 204 | version: 1, | |
| 205 | records: [ | |
| 206 | decodeContext({ version: 1, records: [knownRecords.terminal] }).records[0], | |
| 207 | { ...knownRecords.terminal, contextId: "ctx_2", terminalLabel: " " }, | |
| 208 | ], | |
| 209 | }); | |
| 210 | expect(decodeContext(wire).records.map((record) => record.contextId)).toEqual(["ctx_1"]); | |
| 211 | }); | |
| 212 | ||
| 213 | it("sends a message without the records the wire cannot carry", () => { | |
| 214 | const wire = Schema.encodeUnknownSync(Schema.toCodecJson(OrchestrationMessageContext))({ | |
| 215 | version: 1, | |
| 216 | records: [ | |
| 217 | decodeContext({ version: 1, records: [knownRecords.terminal] }).records[0], | |
| 218 | { | |
| 219 | ...base, | |
| 220 | contextId: "ctx_2", | |
| 221 | kind: "future-kind", | |
| 222 | label: "x", | |
| 223 | payload: { count: Number.NaN }, | |
| 224 | }, | |
| 225 | { ...knownRecords["review-comment"], contextId: "ctx_3", fenceLanguage: undefined }, | |
| 226 | // JSON.stringify throws on a bigint on every engine. | |
| 227 | { ...base, contextId: "ctx_4", kind: "future-kind", label: "y", payload: { n: 1n } }, | |
| 228 | ], | |
| 229 | }); | |
| 230 | expect(decodeContext(wire).records.map((record) => record.contextId)).toEqual(["ctx_1"]); | |
| 231 | }); | |
| 232 | ||
| 233 | it("reports a hole as a schema issue, even when collecting every issue", () => { | |
| 234 | const result = Schema.decodeUnknownExit(Schema.toType(OrchestrationMessageContext))( | |
| 235 | { version: 1, records: [undefined] }, | |
| 236 | { errors: "all" }, | |
| 237 | ); | |
| 238 | expect(Exit.isFailure(result) && Cause.hasFails(result.cause)).toBe(true); | |
| 239 | }); | |
| 240 | ||
| 200 | 241 | it("normalizes decoded record identifiers", () => { |
| 201 | 242 | const context = decodeContext({ |
| 202 | 243 | version: 1, |
packages/contracts/src/composerContext.ts+19-10| ... | ... | @@ -155,6 +155,9 @@ export const ElementContextRecord = Schema.Struct({ |
| 155 | 155 | }); |
| 156 | 156 | export type ElementContextRecord = typeof ElementContextRecord.Type; |
| 157 | 157 | |
| 158 | // Optional record fields are `optionalKey`: a record holding an explicit | |
| 159 | // `undefined` cannot be sent as JSON, so it fails its own check and is dropped | |
| 160 | // alone instead of failing the whole message. | |
| 158 | 161 | export const PreviewAnnotationContextRecord = Schema.Struct({ |
| 159 | 162 | ...recordBase, |
| 160 | 163 | kind: Schema.Literal("preview-annotation"), |
| ... | ... | @@ -165,14 +168,14 @@ export const PreviewAnnotationContextRecord = Schema.Struct({ |
| 165 | 168 | targetSummary: ShortString, |
| 166 | 169 | styleChanges: Schema.Array(ShortString).check(Schema.isMaxLength(200)), |
| 167 | 170 | /** Picked elements inside the annotation, with the detail the agent needs to find them. */ |
| 168 | elements: Schema.optional(Schema.Array(ElementContextDetails).check(Schema.isMaxLength(50))), | |
| 171 | elements: Schema.optionalKey(Schema.Array(ElementContextDetails).check(Schema.isMaxLength(50))), | |
| 169 | 172 | /** Original target ids and edits allow pasted annotations to retain exact style changes. */ |
| 170 | elementIds: Schema.optional(Schema.Array(ShortString).check(Schema.isMaxLength(50))), | |
| 173 | elementIds: Schema.optionalKey(Schema.Array(ShortString).check(Schema.isMaxLength(50))), | |
| 171 | 174 | /** Region and stroke geometry is lossy on purpose, but their counts feed the target summary, |
| 172 | 175 | so a pasted annotation still says what it marked. */ |
| 173 | regionCount: Schema.optional(NonNegativeInt), | |
| 174 | strokeCount: Schema.optional(NonNegativeInt), | |
| 175 | styleChangeDetails: Schema.optional( | |
| 176 | regionCount: Schema.optionalKey(NonNegativeInt), | |
| 177 | strokeCount: Schema.optionalKey(NonNegativeInt), | |
| 178 | styleChangeDetails: Schema.optionalKey( | |
| 176 | 179 | Schema.Array( |
| 177 | 180 | Schema.Struct({ |
| 178 | 181 | targetId: ShortString, |
| ... | ... | @@ -184,7 +187,7 @@ export const PreviewAnnotationContextRecord = Schema.Struct({ |
| 184 | 187 | ).check(Schema.isMaxLength(200)), |
| 185 | 188 | ), |
| 186 | 189 | /** The screenshot travels as its own image record; this links the two. */ |
| 187 | screenshotContextId: Schema.optional(ComposerContextId), | |
| 190 | screenshotContextId: Schema.optionalKey(ComposerContextId), | |
| 188 | 191 | }); |
| 189 | 192 | export type PreviewAnnotationContextRecord = typeof PreviewAnnotationContextRecord.Type; |
| 190 | 193 | |
| ... | ... | @@ -199,8 +202,8 @@ export const ReviewCommentContextRecord = Schema.Struct({ |
| 199 | 202 | rangeLabel: ShortString, |
| 200 | 203 | text: BoundedString(COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS), |
| 201 | 204 | diff: BoundedString(COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS), |
| 202 | fenceLanguage: Schema.optional(BoundedString(64)), | |
| 203 | pullRequest: Schema.optional(PullRequestContextMetadata), | |
| 205 | fenceLanguage: Schema.optionalKey(BoundedString(64)), | |
| 206 | pullRequest: Schema.optionalKey(PullRequestContextMetadata), | |
| 204 | 207 | }).check(Schema.makeFilter((record) => record.endIndex >= record.startIndex)); |
| 205 | 208 | export type ReviewCommentContextRecord = typeof ReviewCommentContextRecord.Type; |
| 206 | 209 | |
| ... | ... | @@ -231,6 +234,8 @@ export const ThreadContextRecord = Schema.Struct({ |
| 231 | 234 | }); |
| 232 | 235 | export type ThreadContextRecord = typeof ThreadContextRecord.Type; |
| 233 | 236 | |
| 237 | const isJson = Schema.is(Schema.Json); | |
| 238 | ||
| 234 | 239 | /** |
| 235 | 240 | * Catch-all for kinds this build does not know. Known discriminators are excluded so a |
| 236 | 241 | * malformed known record fails its own schema instead of sliding through unchecked. |
| ... | ... | @@ -241,12 +246,16 @@ export const UnknownContextRecord = Schema.Struct({ |
| 241 | 246 | kind: ComposerContextKind.check(Schema.isPattern(KNOWN_KIND_PATTERN)), |
| 242 | 247 | payload: Schema.Unknown.check( |
| 243 | 248 | Schema.makeFilter((payload) => { |
| 249 | let encoded: string | undefined; | |
| 244 | 250 | try { |
| 245 | const encoded = JSON.stringify(payload); | |
| 246 | return encoded !== undefined && encoded.length <= 64_000; | |
| 251 | encoded = JSON.stringify(payload); | |
| 247 | 252 | } catch { |
| 253 | // Cycles, bigints, and payloads nested deeper than this engine's stack. | |
| 248 | 254 | return false; |
| 249 | 255 | } |
| 256 | // Only JSON values, so a payload the wire cannot carry (a Date, NaN, an | |
| 257 | // undefined field) fails this record alone instead of the whole message. | |
| 258 | return encoded !== undefined && encoded.length <= 64_000 && isJson(payload); | |
| 250 | 259 | }), |
| 251 | 260 | ), |
| 252 | 261 | }); |