Skip to content
Open
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
32 changes: 32 additions & 0 deletions apps/mobile/src/agent-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | boolean> = {};
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<string, unknown>)[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;
Expand Down Expand Up @@ -298,6 +313,7 @@ export function TaskDetail({ taskId }: { taskId: string }) {
.then((result) => {
if (active) {
setDetail(result);
setFields(recoverableDocumentValues(result.task));
setError("");
}
})
Expand Down Expand Up @@ -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 (
<Sheet
title={task?.title || "Task"}
Expand Down Expand Up @@ -452,6 +483,7 @@ export function TaskDetail({ taskId }: { taskId: string }) {
{task.status === "waiting_input" && (
<Card style={{ backgroundColor: colors.sky, gap: 10 }}>
<Text style={s.heading}>{task.question || "A detail from you will help"}</Text>
<ErrorNotice error={fieldErrors.join("\n")} />
{fieldNames.map((name) =>
missing.some(
(f) => typeof f === "object" && f && f.name === name && f.type === "checkbox",
Expand Down
64 changes: 48 additions & 16 deletions apps/server/src/engine/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<string, unknown> = { ...task.state, answer };
if (task.kind === "document") {
delete nextState.missingFields;
delete nextState.fieldErrors;
}
const next = await this.db.compareAndSwap<AgentTask>(
owner,
"tasks",
Expand All @@ -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(),
},
);
Expand Down Expand Up @@ -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<string, string | boolean> = {};
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,
},
};
}
Expand Down
128 changes: 128 additions & 0 deletions tests/workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Artifact>(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<Artifact>(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);
Expand Down