Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/ai-claude-compat/src/subagent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,36 @@ test('submittedOutput: invalid on a raw-string input left by a JSON-parse failur
if (!out.ok) assert.equal(out.reason, 'invalid');
});

test('submittedOutput: salvages a stringified-JSON submit that validates (issue #185)', () => {
// The live failure shape: submit("{\"n\": 7}") instead of submit({ n: 7 }).
const result = { steps: [{ toolCalls: [{ toolName: SUBMIT_TOOL_NAME, input: '{"n": 7}' }] }] };
assert.deepEqual(submittedOutput(result, OutSchema), { ok: true, value: { n: 7 } });
});

test('submittedOutput: salvages a ```-fenced JSON submit (issue #185)', () => {
const result = {
steps: [{ toolCalls: [{ toolName: SUBMIT_TOOL_NAME, input: '```json\n{"n": 7}\n```' }] }],
};
assert.deepEqual(submittedOutput(result, OutSchema), { ok: true, value: { n: 7 } });
});

test('submittedOutput: a stringified payload that parses but fails the schema stays invalid, with the parsed value issues (issue #185)', () => {
const result = {
steps: [{ toolCalls: [{ toolName: SUBMIT_TOOL_NAME, input: '{"n": "nope"}' }] }],
};
const out = submittedOutput(result, OutSchema);
assert.equal(out.ok, false);
if (!out.ok && out.reason === 'invalid') {
assert.equal(
out.issues[0]?.path[0],
'n',
'issues reflect the parsed object, not the raw string',
);
} else {
assert.fail(`expected invalid, got ${JSON.stringify(out)}`);
}
});

// --- runWithSchemaRetry ---

test('runWithSchemaRetry: corrects an invalid submit on retry; corrective message quotes the Zod issues', async () => {
Expand Down
32 changes: 30 additions & 2 deletions packages/ai-claude-compat/src/subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,36 @@ export function submittedOutput<OUTPUT>(
.find((toolCall) => toolCall.toolName === SUBMIT_TOOL_NAME);
if (!call) return { ok: false, reason: 'no-submission' };
const parsed = outputSchema.safeParse(call.input);
if (!parsed.success) return { ok: false, reason: 'invalid', issues: parsed.error.issues };
return { ok: true, value: parsed.data };
if (parsed.success) return { ok: true, value: parsed.data };
// Weak models often call submit with a JSON STRING (optionally ```-fenced) instead of the object
// the schema expects (issue #185; live: a run died at pr-open, one step from success). Salvage that
// shape upstream of the #101 retry kernel — parse and re-validate — so retries only spend on
// genuinely wrong payloads. A non-string, non-parsing, or still-invalid value keeps the existing
// typed `invalid` result (with the parsed value's issues when it parsed but didn't validate).
if (typeof call.input === 'string') {
const salvaged = salvageJsonString(call.input);
if (salvaged.parsed) {
const reparsed = outputSchema.safeParse(salvaged.value);
return reparsed.success
? { ok: true, value: reparsed.data }
: { ok: false, reason: 'invalid', issues: reparsed.error.issues };
}
}
return { ok: false, reason: 'invalid', issues: parsed.error.issues };
}

// Parse a submit payload that arrived as a JSON string, tolerating a single ```-fenced wrapper (the
// two dominant weak-model fumble shapes). `{ parsed: false }` when it isn't valid JSON, so the caller
// keeps the raw-value validation issues. See issue #185.
function salvageJsonString(raw: string): { parsed: true; value: unknown } | { parsed: false } {
const trimmed = raw.trim();
const fenced = /^```[^\n]*\n([\s\S]*?)\n```$/.exec(trimmed);
const body = (fenced ? (fenced[1] ?? '') : trimmed).trim();
try {
return { parsed: true, value: JSON.parse(body) };
} catch {
return { parsed: false };
}
}

// Human-readable one-line rendering of Zod issues, for a caller's blocked/error/wontfix reason
Expand Down
Loading