authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 16:20:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 16:20:55-07:00
log13aabf3d7d0ec13e902234e86d3df27149c0e4a9
tree9687a0093ff006c717d7adc0a3e77bd29f667f8d
parent8ddf200e8f637eaf9a4f0d3036ece8f1d3869d10
signature Signed by PGP key B5690EEEBB952194

fix(contracts): a context record that cannot be encoded no longer fails the send (#16398)

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,6 +55,18 @@ type GitOverviewSheetProps = StaticScreenProps<{
55};55};
5656
57export function GitOverviewSheet(props: GitOverviewSheetProps) {57export 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
69function GitOverviewSheetContent(props: GitOverviewSheetProps) {
58 const { layout } = useAdaptiveWorkspaceLayout();70 const { layout } = useAdaptiveWorkspaceLayout();
59 const navigation = useNavigation();71 const navigation = useNavigation();
60 const insets = useSafeAreaInsets();72 const insets = useSafeAreaInsets();
apps/web/src/lib/composerContextRecords.test.ts+2-2
...@@ -514,7 +514,7 @@ describe("composerContextRecords", () => {...@@ -514,7 +514,7 @@ describe("composerContextRecords", () => {
514 expect(514 expect(
515 isSameComposerContextPayload(base, {515 isSameComposerContextPayload(base, {
516 ...base,516 ...base,
517 elements: base.elements?.map((element) => ({517 elements: base.elements!.map((element) => ({
518 ...element,518 ...element,
519 htmlPreview: '<button id="pay">Changed</button>',519 htmlPreview: '<button id="pay">Changed</button>',
520 })),520 })),
...@@ -523,7 +523,7 @@ describe("composerContextRecords", () => {...@@ -523,7 +523,7 @@ describe("composerContextRecords", () => {
523 expect(523 expect(
524 isSameComposerContextPayload(base, {524 isSameComposerContextPayload(base, {
525 ...base,525 ...base,
526 elements: base.elements?.map((element) => ({526 elements: base.elements!.map((element) => ({
527 ...element,527 ...element,
528 source: {528 source: {
529 functionName: "Checkout",529 functionName: "Checkout",
packages/contracts/src/baseSchemas.test.ts+29-3
...@@ -48,8 +48,9 @@ describe("ForwardCompatibleArray", () => {...@@ -48,8 +48,9 @@ describe("ForwardCompatibleArray", () => {
48 ]);48 ]);
49 });49 });
5050
51 it("sends an element it cannot encode as a hole instead of failing the array", () => {51 const Named = ForwardCompatibleArray(Schema.Struct({ name: TrimmedNonEmptyString }));
52 const Named = ForwardCompatibleArray(Schema.Struct({ name: TrimmedNonEmptyString }));52
53 it("drops an element it cannot encode instead of failing the array", () => {
53 const wire = JSON.parse(54 const wire = JSON.parse(
54 JSON.stringify(55 JSON.stringify(
55 Schema.encodeUnknownSync(Schema.toCodecJson(Named))([56 Schema.encodeUnknownSync(Schema.toCodecJson(Named))([
...@@ -59,9 +60,34 @@ describe("ForwardCompatibleArray", () => {...@@ -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 expect(fromWire(Named)(wire)).toEqual([{ name: "a" }, { name: "b" }]);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});
6692
67describe("ForwardCompatibleUnion", () => {93describe("ForwardCompatibleUnion", () => {
packages/contracts/src/baseSchemas.ts+19-1
...@@ -140,13 +140,31 @@ export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Elem...@@ -140,13 +140,31 @@ export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Elem
140 Schema.UndefinedOr(Schema.toType(element)).pipe(140 Schema.UndefinedOr(Schema.toType(element)).pipe(
141 Schema.catchEncoding(() => Effect.succeedSome(undefined)),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 SchemaTransformation.transform<159 SchemaTransformation.transform<
145 ReadonlyArray<Element["Type"]>,160 ReadonlyArray<Element["Type"]>,
146 ReadonlyArray<Element["Type"] | undefined>161 ReadonlyArray<Element["Type"] | undefined>
147 >({162 >({
148 decode: (values) => values.filter((value) => value !== undefined),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 ) as unknown as ForwardCompatibleArray<Element>;170 ) as unknown as ForwardCompatibleArray<Element>;
packages/contracts/src/composerContext.test.ts+41
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import * as Cause from "effect/Cause";
3import * as Exit from "effect/Exit";
2import * as Option from "effect/Option";4import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";5import * as Schema from "effect/Schema";
46
...@@ -197,6 +199,45 @@ describe("OrchestrationMessageContext", () => {...@@ -197,6 +199,45 @@ describe("OrchestrationMessageContext", () => {
197 ).toThrow();199 ).toThrow();
198 });200 });
199201
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 it("normalizes decoded record identifiers", () => {241 it("normalizes decoded record identifiers", () => {
201 const context = decodeContext({242 const context = decodeContext({
202 version: 1,243 version: 1,
packages/contracts/src/composerContext.ts+19-10
...@@ -155,6 +155,9 @@ export const ElementContextRecord = Schema.Struct({...@@ -155,6 +155,9 @@ export const ElementContextRecord = Schema.Struct({
155});155});
156export type ElementContextRecord = typeof ElementContextRecord.Type;156export type ElementContextRecord = typeof ElementContextRecord.Type;
157157
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.
158export const PreviewAnnotationContextRecord = Schema.Struct({161export const PreviewAnnotationContextRecord = Schema.Struct({
159 ...recordBase,162 ...recordBase,
160 kind: Schema.Literal("preview-annotation"),163 kind: Schema.Literal("preview-annotation"),
...@@ -165,14 +168,14 @@ export const PreviewAnnotationContextRecord = Schema.Struct({...@@ -165,14 +168,14 @@ export const PreviewAnnotationContextRecord = Schema.Struct({
165 targetSummary: ShortString,168 targetSummary: ShortString,
166 styleChanges: Schema.Array(ShortString).check(Schema.isMaxLength(200)),169 styleChanges: Schema.Array(ShortString).check(Schema.isMaxLength(200)),
167 /** Picked elements inside the annotation, with the detail the agent needs to find them. */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 /** Original target ids and edits allow pasted annotations to retain exact style changes. */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 /** Region and stroke geometry is lossy on purpose, but their counts feed the target summary,174 /** Region and stroke geometry is lossy on purpose, but their counts feed the target summary,
172 so a pasted annotation still says what it marked. */175 so a pasted annotation still says what it marked. */
173 regionCount: Schema.optional(NonNegativeInt),176 regionCount: Schema.optionalKey(NonNegativeInt),
174 strokeCount: Schema.optional(NonNegativeInt),177 strokeCount: Schema.optionalKey(NonNegativeInt),
175 styleChangeDetails: Schema.optional(178 styleChangeDetails: Schema.optionalKey(
176 Schema.Array(179 Schema.Array(
177 Schema.Struct({180 Schema.Struct({
178 targetId: ShortString,181 targetId: ShortString,
...@@ -184,7 +187,7 @@ export const PreviewAnnotationContextRecord = Schema.Struct({...@@ -184,7 +187,7 @@ export const PreviewAnnotationContextRecord = Schema.Struct({
184 ).check(Schema.isMaxLength(200)),187 ).check(Schema.isMaxLength(200)),
185 ),188 ),
186 /** The screenshot travels as its own image record; this links the two. */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});
189export type PreviewAnnotationContextRecord = typeof PreviewAnnotationContextRecord.Type;192export type PreviewAnnotationContextRecord = typeof PreviewAnnotationContextRecord.Type;
190193
...@@ -199,8 +202,8 @@ export const ReviewCommentContextRecord = Schema.Struct({...@@ -199,8 +202,8 @@ export const ReviewCommentContextRecord = Schema.Struct({
199 rangeLabel: ShortString,202 rangeLabel: ShortString,
200 text: BoundedString(COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS),203 text: BoundedString(COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS),
201 diff: BoundedString(COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS),204 diff: BoundedString(COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS),
202 fenceLanguage: Schema.optional(BoundedString(64)),205 fenceLanguage: Schema.optionalKey(BoundedString(64)),
203 pullRequest: Schema.optional(PullRequestContextMetadata),206 pullRequest: Schema.optionalKey(PullRequestContextMetadata),
204}).check(Schema.makeFilter((record) => record.endIndex >= record.startIndex));207}).check(Schema.makeFilter((record) => record.endIndex >= record.startIndex));
205export type ReviewCommentContextRecord = typeof ReviewCommentContextRecord.Type;208export type ReviewCommentContextRecord = typeof ReviewCommentContextRecord.Type;
206209
...@@ -231,6 +234,8 @@ export const ThreadContextRecord = Schema.Struct({...@@ -231,6 +234,8 @@ export const ThreadContextRecord = Schema.Struct({
231});234});
232export type ThreadContextRecord = typeof ThreadContextRecord.Type;235export type ThreadContextRecord = typeof ThreadContextRecord.Type;
233236
237const isJson = Schema.is(Schema.Json);
238
234/**239/**
235 * Catch-all for kinds this build does not know. Known discriminators are excluded so a240 * Catch-all for kinds this build does not know. Known discriminators are excluded so a
236 * malformed known record fails its own schema instead of sliding through unchecked.241 * malformed known record fails its own schema instead of sliding through unchecked.
...@@ -241,12 +246,16 @@ export const UnknownContextRecord = Schema.Struct({...@@ -241,12 +246,16 @@ export const UnknownContextRecord = Schema.Struct({
241 kind: ComposerContextKind.check(Schema.isPattern(KNOWN_KIND_PATTERN)),246 kind: ComposerContextKind.check(Schema.isPattern(KNOWN_KIND_PATTERN)),
242 payload: Schema.Unknown.check(247 payload: Schema.Unknown.check(
243 Schema.makeFilter((payload) => {248 Schema.makeFilter((payload) => {
249 let encoded: string | undefined;
244 try {250 try {
245 const encoded = JSON.stringify(payload);251 encoded = JSON.stringify(payload);
246 return encoded !== undefined && encoded.length <= 64_000;
247 } catch {252 } catch {
253 // Cycles, bigints, and payloads nested deeper than this engine's stack.
248 return false;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});