diff --git a/apps/mobile/src/agent-ui.tsx b/apps/mobile/src/agent-ui.tsx index ead833976..00c9f7859 100644 --- a/apps/mobile/src/agent-ui.tsx +++ b/apps/mobile/src/agent-ui.tsx @@ -72,6 +72,21 @@ function errorText(e: unknown) { function activeTask(task: AgentTask) { return !["succeeded", "failed", "cancelled"].includes(task.status); } +function recoverableDocumentValues(task: AgentTask) { + if (task.kind !== "document" || task.status !== "waiting_input") return {}; + const values = task.input.fields; + if (!values || typeof values !== "object" || Array.isArray(values)) return {}; + const supported = Array.isArray(task.state.missingFields) ? task.state.missingFields : []; + const result: Record = {}; + for (const field of supported) { + if (!field || typeof field !== "object" || !("name" in field) || !("type" in field)) continue; + const name = String(field.name); + const value = (values as Record)[name]; + if (field.type === "checkbox" ? typeof value === "boolean" : typeof value === "string") + result[name] = value as string | boolean; + } + return result; +} export function AgentStatus() { const { data, error, refresh } = useAgentWorkspace(); if (data?.worker.running && !error) return null; @@ -298,6 +313,7 @@ export function TaskDetail({ taskId }: { taskId: string }) { .then((result) => { if (active) { setDetail(result); + setFields(recoverableDocumentValues(result.task)); setError(""); } }) @@ -372,6 +388,21 @@ export function TaskDetail({ taskId }: { taskId: string }) { : "", ) .filter(Boolean); + const fieldErrors = Array.isArray(task?.state.fieldErrors) + ? task.state.fieldErrors + .filter( + (issue) => + issue && + typeof issue === "object" && + "name" in issue && + "expected" in issue && + "received" in issue, + ) + .map( + (issue) => + `${String(issue.name)}: expected ${String(issue.expected)}, received ${String(issue.received)}`, + ) + : []; return ( {task.question || "A detail from you will help"} + {fieldNames.map((name) => missing.some( (f) => typeof f === "object" && f && f.name === name && f.type === "checkbox", diff --git a/apps/server/src/engine/service.ts b/apps/server/src/engine/service.ts index 0e071ba87..4960a822f 100644 --- a/apps/server/src/engine/service.ts +++ b/apps/server/src/engine/service.ts @@ -45,6 +45,7 @@ import { import { LostLeaseError, type TaskContext, TaskWorker } from "./worker.ts"; const hash = (text: string) => createHash("sha256").update(text).digest("hex"); +const documentFieldsSchema = z.record(z.string(), z.union([z.string(), z.boolean()])); /** A change watch's last saved page: its hash, relative-time-free fingerprint and lines. */ type MonitorPage = { id: string; hash?: string; timelessHash?: string; lines: string[] }; const date = () => new Date().toISOString(); @@ -341,6 +342,16 @@ export class AgentService { const task = await this.getTask(owner, id); if (task.status !== "waiting_input") throw new AppError("This task is not waiting for input", 409); + const existingFields = + task.kind === "document" ? documentFieldsSchema.safeParse(task.input.fields) : undefined; + const nextFields = fields + ? { ...(existingFields?.success ? existingFields.data : {}), ...fields } + : undefined; + const nextState: Record = { ...task.state, answer }; + if (task.kind === "document") { + delete nextState.missingFields; + delete nextState.fieldErrors; + } const next = await this.db.compareAndSwap( owner, "tasks", @@ -349,8 +360,8 @@ export class AgentService { { status: "queued", question: null, - input: { ...task.input, ...(fields ? { fields } : {}) }, - state: { ...task.state, answer }, + input: { ...task.input, ...(nextFields ? { fields: nextFields } : {}) }, + state: nextState, updatedAt: date(), }, ); @@ -994,27 +1005,48 @@ export class AgentService { }); await ctx.event("step", "Found the document", file.name); } - const fields = z - .record(z.string(), z.union([z.string(), z.boolean()])) - .optional() - .parse(task.input.fields); + const fields = documentFieldsSchema.optional().parse(task.input.fields); + const file = await this.files.get(owner, source.fileId); + const supportedFields = file.fields?.filter((field) => field.type !== "unsupported") ?? []; + if (!supportedFields.length) + throw new Error("This PDF has no supported fillable fields. Open it in Files to review it."); if (!fields || !Object.keys(fields).length) { - const file = await this.files.get(owner, source.fileId); - const names = file.fields - ?.filter((f) => f.type !== "unsupported") - .map((f) => f.name) - .join(", "); - if (!names) - throw new Error( - "This PDF has no supported fillable fields. Open it in Files to review it.", - ); + const names = supportedFields.map((field) => field.name).join(", "); return { status: "waiting_input", question: `Enter the form values you want to use. Supported fields: ${names}. The original PDF will stay intact.`, state: { ...task.state, source, - missingFields: file.fields?.filter((f) => f.type !== "unsupported"), + missingFields: supportedFields, + }, + }; + } + const supportedByName = new Map(supportedFields.map((field) => [field.name, field])); + const acceptedFields: Record = {}; + const fieldErrors: { name: string; expected: string; received: string }[] = []; + for (const [name, value] of Object.entries(fields)) { + const field = supportedByName.get(name); + const expected = field?.type === "checkbox" ? "boolean" : "string"; + if (!field) + fieldErrors.push({ name, expected: "a supported PDF field", received: typeof value }); + else if (typeof value !== expected) + fieldErrors.push({ name, expected, received: typeof value }); + else acceptedFields[name] = value; + } + if (fieldErrors.length) { + const details = fieldErrors + .map(({ name, expected, received }) => `${name} needs ${expected}, received ${received}`) + .join("; "); + return { + status: "waiting_input", + question: `Correct the form values before filling the PDF. ${details}. The original PDF and valid values will stay intact.`, + input: { ...task.input, fields: acceptedFields }, + state: { + ...task.state, + source, + missingFields: supportedFields, + fieldErrors, }, }; } diff --git a/tests/workflows.test.ts b/tests/workflows.test.ts index f6d81d78f..b8fbf0f42 100644 --- a/tests/workflows.test.ts +++ b/tests/workflows.test.ts @@ -106,6 +106,134 @@ test("document job runs without a client, waits for review, and resumes from its ); }); +test("document job preserves valid fields while asking to repair an invalid checkbox", async () => { + const recoveryOwner = "document-field-recovery"; + await server.workspace.ensureSample(recoveryOwner, server.actions); + const workspace = await server.workspace.snapshot(recoveryOwner); + const mail = workspace.mail.find((message) => message.attachments.length); + assert.ok(mail); + const task = await server.agent.createTask(recoveryOwner, { + prompt: "Fill the sample form and prepare a reply", + kind: "document", + input: { + messageId: mail.id, + fields: { + participant_name: "Test Student", + guardian_name: "Test Guardian", + permission_granted: "true", + }, + }, + }); + + await server.agent.worker.tick(); + const waiting = await server.agent.getTask(recoveryOwner, task.id); + assert.equal(waiting.status, "waiting_input", waiting.error ?? waiting.question); + assert.equal(waiting.plan[0]?.status, "succeeded"); + assert.equal(waiting.plan[1]?.status, "pending"); + assert.ok(waiting.state.source); + assert.deepEqual(waiting.input.fields, { + participant_name: "Test Student", + guardian_name: "Test Guardian", + }); + assert.deepEqual(waiting.state.fieldErrors, [ + { + name: "permission_granted", + expected: "boolean", + received: "string", + }, + ]); + assert.equal( + (await db.list(recoveryOwner, "files")).filter((file) => file.parentId).length, + 0, + ); + const source = waiting.state.source as { fileId: string }; + + await server.agent.answer(recoveryOwner, task.id, "Use a checked checkbox", { + permission_granted: true, + }); + await server.agent.worker.tick(); + + const reviewed = await server.agent.detail(recoveryOwner, task.id); + assert.equal( + reviewed.task.status, + "waiting_approval", + reviewed.task.error ?? reviewed.task.question, + ); + assert.equal((reviewed.task.state.source as { fileId: string }).fileId, source.fileId); + assert.deepEqual(reviewed.task.input.fields, { + participant_name: "Test Student", + guardian_name: "Test Guardian", + permission_granted: true, + }); + assert.equal(reviewed.task.state.fieldErrors, undefined); + assert.equal(reviewed.task.state.missingFields, undefined); + assert.equal(reviewed.files.length, 1); +}); + +test("document job preserves valid fields while asking to replace an unknown field", async () => { + const recoveryOwner = "document-field-name-recovery"; + await server.workspace.ensureSample(recoveryOwner, server.actions); + const workspace = await server.workspace.snapshot(recoveryOwner); + const mail = workspace.mail.find((message) => message.attachments.length); + assert.ok(mail); + const task = await server.agent.createTask(recoveryOwner, { + prompt: "Fill the sample form and prepare a reply", + kind: "document", + input: { + messageId: mail.id, + fields: { + participant_name: "Test Student", + guardian_name: "Test Guardian", + consent: true, + }, + }, + }); + + await server.agent.worker.tick(); + const waiting = await server.agent.getTask(recoveryOwner, task.id); + assert.equal(waiting.status, "waiting_input", waiting.error ?? waiting.question); + assert.equal(waiting.plan[0]?.status, "succeeded"); + assert.equal(waiting.plan[1]?.status, "pending"); + assert.ok(waiting.state.source); + assert.deepEqual(waiting.input.fields, { + participant_name: "Test Student", + guardian_name: "Test Guardian", + }); + assert.deepEqual(waiting.state.fieldErrors, [ + { + name: "consent", + expected: "a supported PDF field", + received: "boolean", + }, + ]); + assert.equal( + (await db.list(recoveryOwner, "files")).filter((file) => file.parentId).length, + 0, + ); + const source = waiting.state.source as { fileId: string }; + + await server.agent.answer(recoveryOwner, task.id, "Use the PDF's checkbox field", { + permission_granted: true, + }); + await server.agent.worker.tick(); + + const reviewed = await server.agent.detail(recoveryOwner, task.id); + assert.equal( + reviewed.task.status, + "waiting_approval", + reviewed.task.error ?? reviewed.task.question, + ); + assert.equal((reviewed.task.state.source as { fileId: string }).fileId, source.fileId); + assert.deepEqual(reviewed.task.input.fields, { + participant_name: "Test Student", + guardian_name: "Test Guardian", + permission_granted: true, + }); + assert.equal(reviewed.task.state.fieldErrors, undefined); + assert.equal(reviewed.task.state.missingFields, undefined); + assert.equal(reviewed.files.length, 1); +}); + test("document retry reuses the filled PDF after its task checkpoint is lost", async (t) => { const replayOwner = "interrupted-document"; await server.workspace.ensureSample(replayOwner, server.actions);