Skip to content

Commit 45eb79e

Browse files
committed
fix: model MalformedResponseError
1 parent d739bf0 commit 45eb79e

4 files changed

Lines changed: 59 additions & 72 deletions

File tree

src/core/datasetDiff.test.ts

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { describe, expect, test } from "bun:test";
2-
import { AgentCoreCLIError, InputValidationError } from "../errors";
2+
import { InputValidationError, MalformedServiceResponseError } from "../errors";
33
import {
44
applyExampleIds,
55
diffExamples,
66
indexRemoteById,
77
parseJsonl,
8-
stableStringify,
98
stripExampleId,
109
} from "./datasetDiff";
1110

@@ -72,7 +71,7 @@ describe("indexRemoteById", () => {
7271
test("rejects a remote example without an id", () => {
7372
const remote = parseJsonl(jsonl({ scenario_id: "missing-id" }), "remote");
7473

75-
expect(() => indexRemoteById(remote)).toThrow(AgentCoreCLIError);
74+
expect(() => indexRemoteById(remote)).toThrow(MalformedServiceResponseError);
7675
expect(() => indexRemoteById(remote)).toThrow(/missing a valid exampleId/);
7776
try {
7877
indexRemoteById(remote);
@@ -84,7 +83,7 @@ describe("indexRemoteById", () => {
8483
test("rejects duplicate remote ids instead of overwriting an example", () => {
8584
const remote = parseJsonl(jsonl(withId("duplicate"), withId("duplicate")), "remote");
8685

87-
expect(() => indexRemoteById(remote)).toThrow(AgentCoreCLIError);
86+
expect(() => indexRemoteById(remote)).toThrow(MalformedServiceResponseError);
8887
expect(() => indexRemoteById(remote)).toThrow(/duplicate exampleId "duplicate"/);
8988
});
9089
});
@@ -111,10 +110,16 @@ describe("diffExamples", () => {
111110
// must not read as modified.
112111
test("treats key reordering as unchanged", () => {
113112
const remote = indexRemoteById([
114-
{ exampleId: "a", content: { exampleId: "a", alpha: 1, beta: 2 } },
113+
{
114+
exampleId: "a",
115+
content: { exampleId: "a", alpha: 1, nested: { first: 1, second: 2 } },
116+
},
115117
]);
116118
const local: Parameters<typeof diffExamples>[0] = [
117-
{ exampleId: "a", content: { beta: 2, exampleId: "a", alpha: 1 } },
119+
{
120+
exampleId: "a",
121+
content: { nested: { second: 2, first: 1 }, exampleId: "a", alpha: 1 },
122+
},
118123
];
119124

120125
const diff = diffExamples(local, remote);
@@ -123,6 +128,20 @@ describe("diffExamples", () => {
123128
expect(diff.updates).toEqual([]);
124129
});
125130

131+
test("treats array reordering as an update", () => {
132+
const remote = indexRemoteById([
133+
{ exampleId: "a", content: { exampleId: "a", values: [1, 2] } },
134+
]);
135+
const local: Parameters<typeof diffExamples>[0] = [
136+
{ exampleId: "a", content: { exampleId: "a", values: [2, 1] } },
137+
];
138+
139+
const diff = diffExamples(local, remote);
140+
141+
expect(diff.unchanged).toBe(0);
142+
expect(diff.updates).toEqual([{ exampleId: "a", values: [2, 1] }]);
143+
});
144+
126145
// An id absent remotely is stale (e.g. the dataset was recreated); re-adding is
127146
// recoverable, whereas failing would leave the file unusable.
128147
test("treats a stale exampleId as an addition and strips the id", () => {
@@ -227,7 +246,9 @@ describe("applyExampleIds", () => {
227246
const local = parseJsonl(jsonl({ scenario_id: "one" }, { scenario_id: "two" }), "file-path");
228247
const additions = diffExamples(local, new Map()).additions;
229248

230-
expect(() => applyExampleIds(local, additions, assignedIds)).toThrow(AgentCoreCLIError);
249+
expect(() => applyExampleIds(local, additions, assignedIds)).toThrow(
250+
MalformedServiceResponseError,
251+
);
231252
expect(() => applyExampleIds(local, additions, assignedIds)).toThrow(
232253
/returned \d+ exampleIds for 2 additions/,
233254
);
@@ -237,7 +258,7 @@ describe("applyExampleIds", () => {
237258
const local = parseJsonl(jsonl({ scenario_id: "one" }), "file-path");
238259
const additions = diffExamples(local, new Map()).additions;
239260

240-
expect(() => applyExampleIds(local, additions, [""])).toThrow(AgentCoreCLIError);
261+
expect(() => applyExampleIds(local, additions, [""])).toThrow(MalformedServiceResponseError);
241262
expect(() => applyExampleIds(local, additions, [""])).toThrow(/invalid exampleId/);
242263
});
243264

@@ -246,32 +267,14 @@ describe("applyExampleIds", () => {
246267
const additions = diffExamples(local, new Map()).additions;
247268

248269
expect(() => applyExampleIds(local, additions, ["same-id", "same-id"])).toThrow(
249-
AgentCoreCLIError,
270+
MalformedServiceResponseError,
250271
);
251272
expect(() => applyExampleIds(local, additions, ["same-id", "same-id"])).toThrow(
252273
/duplicate exampleId "same-id"/,
253274
);
254275
});
255276
});
256277

257-
describe("stableStringify", () => {
258-
test("sorts object keys at every depth", () => {
259-
expect(stableStringify({ b: 1, a: { d: 2, c: 3 } })).toBe(
260-
stableStringify({ a: { c: 3, d: 2 }, b: 1 }),
261-
);
262-
});
263-
264-
// Arrays are ordered data — reordering them is a real change, unlike key order.
265-
test("preserves array order", () => {
266-
expect(stableStringify([1, 2])).not.toBe(stableStringify([2, 1]));
267-
});
268-
269-
test("handles null and primitives", () => {
270-
expect(stableStringify(null)).toBe("null");
271-
expect(stableStringify({ a: null })).toBe('{"a":null}');
272-
});
273-
});
274-
275278
describe("stripExampleId", () => {
276279
test("removes only exampleId", () => {
277280
expect(stripExampleId({ exampleId: "a", scenario_id: "x" })).toEqual({ scenario_id: "x" });

src/core/datasetDiff.ts

Lines changed: 21 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { AgentCoreCLIError, ERROR_SOURCE, InputValidationError } from "../errors";
1+
import { isDeepStrictEqual } from "node:util";
2+
import { InputValidationError, MalformedServiceResponseError } from "../errors";
23
import { parseJsonObjectLines, type JsonObject } from "../io";
34

45
// One dataset example as it appears in a JSONL file: an opaque JSON document,
@@ -49,28 +50,22 @@ export function indexRemoteById(examples: ParsedExample[]): Map<string, DatasetE
4950
examples.forEach((example, index) => {
5051
const id = example.exampleId;
5152
if (!id) {
52-
throw cliServiceError(`Remote dataset example ${index + 1} is missing a valid exampleId`, {
53-
index,
54-
});
53+
throw new MalformedServiceResponseError(
54+
`Remote dataset example ${index + 1} is missing a valid exampleId`,
55+
{ meta: { index } },
56+
);
5557
}
5658
if (byId.has(id)) {
57-
throw cliServiceError(`Remote dataset contains duplicate exampleId "${id}"`, {
58-
exampleId: id,
59-
index,
60-
});
59+
throw new MalformedServiceResponseError(
60+
`Remote dataset contains duplicate exampleId "${id}"`,
61+
{ meta: { exampleId: id, index } },
62+
);
6163
}
6264
byId.set(id, example.content);
6365
});
6466
return byId;
6567
}
6668

67-
function cliServiceError(message: string, meta: Record<string, unknown>): AgentCoreCLIError {
68-
return new AgentCoreCLIError(message, {
69-
source: ERROR_SOURCE.SERVICE,
70-
meta,
71-
});
72-
}
73-
7469
function validateUniqueLocalIds(local: ParsedExample[]): void {
7570
const ids = new Set<string>();
7671
for (const example of local) {
@@ -106,8 +101,9 @@ export function diffExamples(
106101
return;
107102
}
108103
matched.add(example.exampleId!);
109-
if (contentEquals(example.content, remoteContent)) unchanged++;
110-
else updates.push(example.content);
104+
if (isDeepStrictEqual(stripExampleId(example.content), stripExampleId(remoteContent))) {
105+
unchanged++;
106+
} else updates.push(example.content);
111107
});
112108

113109
const deleteIds = [...remote.keys()].filter((id) => !matched.has(id));
@@ -121,26 +117,6 @@ export function stripExampleId(content: DatasetExample): DatasetExample {
121117
return rest;
122118
}
123119

124-
// contentEquals compares two examples ignoring exampleId and key order. The
125-
// service does not preserve the key order examples were submitted in, so a plain
126-
// JSON.stringify comparison would report all round-trip examples as changed.
127-
function contentEquals(a: DatasetExample, b: DatasetExample): boolean {
128-
return stableStringify(stripExampleId(a)) === stableStringify(stripExampleId(b));
129-
}
130-
131-
// stableStringify serializes a JSON document with object keys sorted, so two
132-
// documents differing only in key order produce the same string.
133-
export function stableStringify(value: unknown): string {
134-
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
135-
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
136-
137-
const entries = Object.entries(value as Record<string, unknown>)
138-
.filter(([, v]) => v !== undefined)
139-
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
140-
.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
141-
return `{${entries.join(",")}}`;
142-
}
143-
144120
// applyExampleIds renders the local file with service-assigned ids attached to the
145121
// rows that were added, leaving every other row untouched. `assignedIds` is
146122
// positional: the nth id belongs to the nth addition. Without these ids the next
@@ -151,26 +127,26 @@ export function applyExampleIds(
151127
assignedIds: string[],
152128
): string {
153129
if (assignedIds.length !== additions.length) {
154-
throw cliServiceError(
130+
throw new MalformedServiceResponseError(
155131
`Dataset service returned ${assignedIds.length} exampleIds for ` +
156132
`${additions.length} additions`,
157-
{ expected: additions.length, received: assignedIds.length },
133+
{ meta: { expected: additions.length, received: assignedIds.length } },
158134
);
159135
}
160136

161137
const uniqueIds = new Set<string>();
162138
assignedIds.forEach((id, index) => {
163139
if (typeof id !== "string" || id.trim() === "") {
164-
throw cliServiceError(
140+
throw new MalformedServiceResponseError(
165141
`Dataset service returned an invalid exampleId for addition ${index + 1}`,
166-
{ index },
142+
{ meta: { index } },
167143
);
168144
}
169145
if (uniqueIds.has(id)) {
170-
throw cliServiceError(`Dataset service returned duplicate exampleId "${id}"`, {
171-
exampleId: id,
172-
index,
173-
});
146+
throw new MalformedServiceResponseError(
147+
`Dataset service returned duplicate exampleId "${id}"`,
148+
{ meta: { exampleId: id, index } },
149+
);
174150
}
175151
uniqueIds.add(id);
176152
});

src/errors/errors.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,13 @@ export class NetworkingError extends AgentCoreCLIError {
150150
}
151151
}
152152

153+
/** Service data was returned successfully, but did not match the expected contract. */
154+
export class MalformedServiceResponseError extends AgentCoreCLIError {
155+
constructor(message: string, options?: Omit<AgentCoreCLIErrorOptions, "source">) {
156+
super(message, { ...options, source: ERROR_SOURCE.SERVICE });
157+
}
158+
}
159+
153160
/** A file could not be written locally: missing directory, permission denial, etc. */
154161
export class FileWriteError extends AgentCoreCLIError {
155162
constructor(message: string, options?: AgentCoreCLIErrorOptions) {

src/errors/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {
55
FileWriteError,
66
InputValidationError,
77
InvalidEnvironmentError,
8+
MalformedServiceResponseError,
89
NestedProjectError,
910
NetworkingError,
1011
NotImplementedError,

0 commit comments

Comments
 (0)