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<{
5555};
5656
5757export 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) {
5870 const { layout } = useAdaptiveWorkspaceLayout();
5971 const navigation = useNavigation();
6072 const insets = useSafeAreaInsets();
apps/web/src/lib/composerContextRecords.test.ts+2-2
......@@ -514,7 +514,7 @@ describe("composerContextRecords", () => {
514514 expect(
515515 isSameComposerContextPayload(base, {
516516 ...base,
517 elements: base.elements?.map((element) => ({
517 elements: base.elements!.map((element) => ({
518518 ...element,
519519 htmlPreview: '<button id="pay">Changed</button>',
520520 })),
......@@ -523,7 +523,7 @@ describe("composerContextRecords", () => {
523523 expect(
524524 isSameComposerContextPayload(base, {
525525 ...base,
526 elements: base.elements?.map((element) => ({
526 elements: base.elements!.map((element) => ({
527527 ...element,
528528 source: {
529529 functionName: "Checkout",
packages/contracts/src/baseSchemas.test.ts+29-3
......@@ -48,8 +48,9 @@ describe("ForwardCompatibleArray", () => {
4848 ]);
4949 });
5050
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", () => {
5354 const wire = JSON.parse(
5455 JSON.stringify(
5556 Schema.encodeUnknownSync(Schema.toCodecJson(Named))([
......@@ -59,9 +60,34 @@ describe("ForwardCompatibleArray", () => {
5960 ]),
6061 ),
6162 );
62 expect(wire).toEqual([{ name: "a" }, null, { name: "b" }]);
63 expect(wire).toEqual([{ name: "a" }, { name: "b" }]);
6364 expect(fromWire(Named)(wire)).toEqual([{ name: "a" }, { name: "b" }]);
6465 });
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 });
6591});
6692
6793describe("ForwardCompatibleUnion", () => {
packages/contracts/src/baseSchemas.ts+19-1
......@@ -140,13 +140,31 @@ export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Elem
140140 Schema.UndefinedOr(Schema.toType(element)).pipe(
141141 Schema.catchEncoding(() => Effect.succeedSome(undefined)),
142142 ),
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 ),
143158 ),
144159 SchemaTransformation.transform<
145160 ReadonlyArray<Element["Type"]>,
146161 ReadonlyArray<Element["Type"] | undefined>
147162 >({
148163 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),
150168 }),
151169 ),
152170 ) as unknown as ForwardCompatibleArray<Element>;
packages/contracts/src/composerContext.test.ts+41
......@@ -1,4 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
2import * as Cause from "effect/Cause";
3import * as Exit from "effect/Exit";
24import * as Option from "effect/Option";
35import * as Schema from "effect/Schema";
46
......@@ -197,6 +199,45 @@ describe("OrchestrationMessageContext", () => {
197199 ).toThrow();
198200 });
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
200241 it("normalizes decoded record identifiers", () => {
201242 const context = decodeContext({
202243 version: 1,
packages/contracts/src/composerContext.ts+19-10
......@@ -155,6 +155,9 @@ export const ElementContextRecord = Schema.Struct({
155155});
156156export 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.
158161export const PreviewAnnotationContextRecord = Schema.Struct({
159162 ...recordBase,
160163 kind: Schema.Literal("preview-annotation"),
......@@ -165,14 +168,14 @@ export const PreviewAnnotationContextRecord = Schema.Struct({
165168 targetSummary: ShortString,
166169 styleChanges: Schema.Array(ShortString).check(Schema.isMaxLength(200)),
167170 /** 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))),
169172 /** 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))),
171174 /** Region and stroke geometry is lossy on purpose, but their counts feed the target summary,
172175 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(
176179 Schema.Array(
177180 Schema.Struct({
178181 targetId: ShortString,
......@@ -184,7 +187,7 @@ export const PreviewAnnotationContextRecord = Schema.Struct({
184187 ).check(Schema.isMaxLength(200)),
185188 ),
186189 /** The screenshot travels as its own image record; this links the two. */
187 screenshotContextId: Schema.optional(ComposerContextId),
190 screenshotContextId: Schema.optionalKey(ComposerContextId),
188191});
189192export type PreviewAnnotationContextRecord = typeof PreviewAnnotationContextRecord.Type;
190193
......@@ -199,8 +202,8 @@ export const ReviewCommentContextRecord = Schema.Struct({
199202 rangeLabel: ShortString,
200203 text: BoundedString(COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS),
201204 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),
204207}).check(Schema.makeFilter((record) => record.endIndex >= record.startIndex));
205208export type ReviewCommentContextRecord = typeof ReviewCommentContextRecord.Type;
206209
......@@ -231,6 +234,8 @@ export const ThreadContextRecord = Schema.Struct({
231234});
232235export type ThreadContextRecord = typeof ThreadContextRecord.Type;
233236
237const isJson = Schema.is(Schema.Json);
238
234239/**
235240 * Catch-all for kinds this build does not know. Known discriminators are excluded so a
236241 * malformed known record fails its own schema instead of sliding through unchecked.
......@@ -241,12 +246,16 @@ export const UnknownContextRecord = Schema.Struct({
241246 kind: ComposerContextKind.check(Schema.isPattern(KNOWN_KIND_PATTERN)),
242247 payload: Schema.Unknown.check(
243248 Schema.makeFilter((payload) => {
249 let encoded: string | undefined;
244250 try {
245 const encoded = JSON.stringify(payload);
246 return encoded !== undefined && encoded.length <= 64_000;
251 encoded = JSON.stringify(payload);
247252 } catch {
253 // Cycles, bigints, and payloads nested deeper than this engine's stack.
248254 return false;
249255 }
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);
250259 }),
251260 ),
252261});