fix(core): Accept approval commentary when resuming suspended agent runs

A suspended tool call's resume payload is validated against the tool's resume schema serialized to JSON Schema, where additionalProperties: false rejects the envelope-level fields an approval confirmation may attach (userInput free-text commentary, scope). A live resume validates against the zod schema, which strips unknown keys — so the same approval resumed a live run but left a suspended run hanging with no user-visible error. On validation failure, retry without the envelope fields to restore parity.
This commit is contained in:
Oleg Ivaniv 2026-08-05 16:41:24 +02:00
parent 693fc9616f
commit ec2da58371
No known key found for this signature in database
2 changed files with 115 additions and 1 deletions

View File

@ -0,0 +1,79 @@
/**
* Approval confirmations attach envelope-level fields (`userInput` free-text
* commentary, `scope`) to the resume payload alongside the tool's own resume
* fields. Tool resume schemas declare only their own fields, and a SUSPENDED
* resume validates against the schema serialized to JSON Schema — where
* `additionalProperties: false` rejects the envelope fields outright, leaving
* the run suspended forever. (A live resume validates against the zod object,
* which strips unknown keys, so the same payload passes there.)
*
* These tests pin the fallback that restores parity: on validation failure the
* runtime retries without the envelope fields.
*/
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { parseWithSchema } from '../../utils/parse';
import { zodToJsonSchema } from '../../utils/zod';
import { withoutApprovalEnvelopeFields } from '../loop/agent-runtime';
describe('withoutApprovalEnvelopeFields', () => {
it('strips userInput and scope, preserving the tool fields', () => {
expect(
withoutApprovalEnvelopeFields({
approved: true,
userInput: 'Yes, publish it with the credential attached as is.',
scope: 'always',
}),
).toEqual({ approved: true });
});
it('strips one envelope field without touching other keys', () => {
expect(
withoutApprovalEnvelopeFields({ approved: true, action: 'apply', userInput: 'go ahead' }),
).toEqual({ approved: true, action: 'apply' });
});
it('returns undefined when no envelope field is present (nothing to retry)', () => {
expect(withoutApprovalEnvelopeFields({ approved: false })).toBeUndefined();
});
it('returns undefined for non-object payloads', () => {
expect(withoutApprovalEnvelopeFields(undefined)).toBeUndefined();
expect(withoutApprovalEnvelopeFields('approve')).toBeUndefined();
expect(withoutApprovalEnvelopeFields([{ approved: true }])).toBeUndefined();
});
it('does not mutate the original payload', () => {
const original = { approved: true, userInput: 'ok' };
withoutApprovalEnvelopeFields(original);
expect(original).toEqual({ approved: true, userInput: 'ok' });
});
});
describe('suspended-resume validation parity', () => {
// The shape every tool resume schema shares, serialized exactly the way a
// suspended tool call persists it (zod object → JSON Schema with
// additionalProperties: false).
const approvalResumeJsonSchema = zodToJsonSchema(z.object({ approved: z.boolean() }));
if (!approvalResumeJsonSchema) throw new Error('failed to serialize the resume schema');
it('rejects an approval carrying commentary against the serialized schema', async () => {
const result = await parseWithSchema(approvalResumeJsonSchema, {
approved: true,
userInput: 'Yes, publish it.',
});
expect(result.success).toBe(false);
});
it('accepts the same approval once the envelope fields are stripped', async () => {
const stripped = withoutApprovalEnvelopeFields({
approved: true,
userInput: 'Yes, publish it.',
scope: 'always',
});
expect(stripped).toBeDefined();
const result = await parseWithSchema(approvalResumeJsonSchema, stripped);
expect(result).toEqual({ success: true, data: { approved: true } });
});
});

View File

@ -153,6 +153,30 @@ interface LoopContext {
pendingResume?: PendingResume;
}
/** Envelope-level fields an approval confirmation may attach to a resume
* payload alongside the tool's own resume fields. */
const APPROVAL_ENVELOPE_FIELDS = ['userInput', 'scope'] as const;
/**
* A copy of the resume payload without the approval-envelope fields, or
* undefined when there is nothing to strip (not an object, or none present).
* Used as a fallback when a resume payload fails schema validation: tool
* resume schemas declare only their own fields, so envelope fields must not
* make an otherwise-valid approval unresumable.
*/
export function withoutApprovalEnvelopeFields(data: unknown): Record<string, unknown> | undefined {
if (typeof data !== 'object' || data === null || Array.isArray(data)) return undefined;
const stripped: Record<string, unknown> = { ...data };
let removed = false;
for (const field of APPROVAL_ENVELOPE_FIELDS) {
if (field in stripped) {
delete stripped[field];
removed = true;
}
}
return removed ? stripped : undefined;
}
/**
* Core agent execution engine using the Vercel AI SDK directly.
*
@ -372,7 +396,18 @@ export class AgentRuntime {
const resumeSchema = toolCall.suspended ? toolCall.resumeSchema : tool.resumeSchema;
if (!isCancellation(resumeData) && resumeSchema) {
const parseResult = await parseWithSchema(resumeSchema, data);
let parseResult = await parseWithSchema(resumeSchema, data);
if (!parseResult.success) {
// Approval confirmations carry envelope-level fields (`userInput`
// free-text commentary, `scope`) that tool resume schemas do not
// declare. A live resume validates against the zod schema, which
// strips unknown keys; a suspended resume validates against the
// serialized JSON schema (additionalProperties: false), which rejects
// them — leaving the run suspended forever. Retry without the
// envelope fields to restore parity.
const stripped = withoutApprovalEnvelopeFields(data);
if (stripped) parseResult = await parseWithSchema(resumeSchema, stripped);
}
if (!parseResult.success) {
throw new Error(`Invalid resume payload: ${parseResult.error}`);
}