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
32 changes: 16 additions & 16 deletions apps/desktop/src/components/human-tasks/human-task-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,22 +446,22 @@ function CloseBox({
</Button>
)}
<div className="ml-auto flex items-center gap-2">
{/* A question the agent stands still for can only be answered. */}
{!(decision && task.questionId) &&
(decision ? (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" disabled={pending} onClick={() => finish("declined")}>
<X /> {words.declined}
</Button>
</TooltipTrigger>
<TooltipContent side="top">Nothing to decide here. {agentName ?? "The agent"} isn't woken.</TooltipContent>
</Tooltip>
) : (
<Button variant="outline" size="sm" disabled={pending} onClick={() => finish("declined")}>
{approval ? <X /> : <CircleSlash />} {words.declined}
</Button>
))}
{decision ? (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" disabled={pending} onClick={() => finish("declined")}>
<X /> {words.declined}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{task.questionId ? `No answer needed anymore. ${agentName ?? "The agent"} goes on without it.` : `Nothing to decide here. ${agentName ?? "The agent"} isn't woken.`}
</TooltipContent>
</Tooltip>
) : (
<Button variant="outline" size="sm" disabled={pending} onClick={() => finish("declined")}>
{approval ? <X /> : <CircleSlash />} {words.declined}
</Button>
)}
<Button size="sm" disabled={pending || !ready} onClick={() => finish("done")}>
{pending ? <Spinner /> : decision ? <SendHorizontal /> : <Check />} {words.done}
<Hint>{modKey}↵</Hint>
Expand Down
18 changes: 8 additions & 10 deletions apps/mobile/src/app/human-task/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -291,16 +291,14 @@ export default function HumanTaskSheet() {
onPress={() => move.mutate(task.status === "open" ? "doing" : "open")}
style={{ flex: 1 }}
/>
{decision && task.questionId ? null : (
<Button
title={words.declined}
variant="secondary"
loading={close.isPending && close.variables === "declined"}
disabled={busy}
onPress={() => close.mutate("declined")}
style={{ flex: 1 }}
/>
)}
<Button
title={words.declined}
variant="secondary"
loading={close.isPending && close.variables === "declined"}
disabled={busy}
onPress={() => close.mutate("declined")}
style={{ flex: 1 }}
/>
</Row>
{task.status !== "backlog" && (
<Pressable
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/services/humanTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,20 +608,28 @@ function safely(fn: () => void): void {
}
}

const DISMISSED = "Dismissed: this no longer needs an answer, it may be solved already. Don't wait for it or ask it again: check where things stand now and carry on.";

/** The human answers a question from its ticket: the run that stands still for it continues. */
function answerFromTicket(r: Row, input: HumanTaskCloseInput, by: Answerer): HumanTaskCloseResult {
const note = (input.note ?? "").trim();
const files = input.attachments ?? [];
let answer: AnswerQuestionInput;
const dismissed = r.kind !== "approval" && input.outcome === "declined";
if (r.kind === "approval") answer = { decision: input.outcome === "done" ? "approve" : "decline", ...(note ? { note } : {}) };
else if (input.outcome === "declined") throw badRequest("The agent stands still until you answer. If the question no longer matters, stop it in its chat.");
else if (dismissed) answer = { text: [DISMISSED, note].filter(Boolean).join("\n\n") };
else if (input.optionId && !note && !files.length) answer = { optionId: input.optionId };
else {
const picked = input.optionId ? parseJson<QuestionOption[]>(r.options, []).find((o) => o.id === input.optionId)?.label : "";
answer = { text: [picked, note].filter(Boolean).join("\n\n"), ...(files.length ? { attachments: files } : {}) };
}
const { question } = answerQuestion(r.question_id!, answer, by);
settleMirror(question);
if (dismissed) {
const ts = now();
sql("UPDATE human_tasks SET status = 'declined', response = ?, response_option = NULL, closed_at = COALESCE(closed_at, ?), updated_at = ? WHERE id = ?", note || null, ts, ts, r.id);
changed();
}
return { task: getHumanTask(r.id), continued: true };
}

Expand Down
15 changes: 12 additions & 3 deletions packages/core/test/human-tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,9 +482,7 @@ describe("questions and approvals have a ticket", () => {
expect(attention.some((i) => i.id === `question:${question.id}`)).toBe(true);
expect(attention.some((i) => i.id === `todo:${task.id}`)).toBe(false);

const declined = await api("POST", `/api/human-tasks/${task.id}/close`, { outcome: "declined" });
expect(declined.status).toBe(400);
// The run stands still for it: the ticket can't be dismissed or deleted from under it.
// The run stands still for it: the ticket can't be deleted from under it.
expect((await api("DELETE", `/api/human-tasks/${task.id}`)).status).toBe(400);
const res = await api("POST", `/api/human-tasks/${task.id}/close`, { outcome: "done", optionId: "2" });
expect(res.status).toBe(200);
Expand All @@ -501,6 +499,17 @@ describe("questions and approvals have a ticket", () => {
await waitForRun(run.id, 10_000);
}, 30_000);

test("dismissing the ticket tells the run the question no longer matters", async () => {
const { run, question, task } = await asking();
const res = await api("POST", `/api/human-tasks/${task.id}/close`, { outcome: "declined", note: "Solved it myself" });
expect(res.status).toBe(200);
expect(res.json.continued).toBe(true);
expect(getQuestion(question.id).status).toBe("answered");
expect(getQuestion(question.id).answer?.text).toContain("Solved it myself");
expect(getHumanTask(task.id)).toMatchObject({ status: "declined", response: { text: "Solved it myself" } });
expect((await waitForRun(run.id, 10_000)).result).toBe("CONTINUED");
}, 30_000);

test("an approval is approved or declined from its ticket", async () => {
const { run, question, task } = await asking("ASK_APPROVAL send it");
expect(task).toMatchObject({ kind: "approval", questionId: question.id, title: "Send the payment reminder to billing@acme.com" });
Expand Down
Loading