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
2 changes: 1 addition & 1 deletion .agboss-version.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
"repository": "agbossjs/agboss",
"branch": "main",
"source": "commit",
"frameworkSha": "da8148e19697329dd3c4cddc3feaa09acda3daba",
"frameworkSha": "606370a0012178f69352275fc9a12860c524262f",
"version": "0.1.0"
}
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ own process, no shared disk — share every store on one database.

### Fixed

- **A question role's pause is read back** (agbossjs/agboss#746) — the pause row check took every
`ask` for an approval's tool call (`callId`, `tool`, `summary`), so a question asked by a flow step
(agbossjs/agboss#659) or an agent's tool (#712) failed its execution reading its own pause. A
question's ask (`component.kind: "question"`) is kept as written; an approval's still needs its
tool call. The core's `pausedExecutionContract` now has the case.
- **The README's wiring compiles** (agbossjs/agboss#749) — _1. List the stores_ imported `days` from
`@agboss/core/units`, which has none; it reads `executionRecordRetention: hours(24 * 30)`.

Expand Down
19 changes: 16 additions & 3 deletions src/stores/framework-json.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ComponentKind,
PauseAnswerer,
PauseResolution,
PauseStatus,
Expand Down Expand Up @@ -52,14 +53,24 @@ const StoredPause = z.looseObject({
agent: text,
channel: text,
answeredBy: z.enum(PauseAnswerer),
ask: z.looseObject({ callId: text, tool: text, summary: text }),
// an approval's tool call, or a question role's own ask (agbossjs/agboss#659, #712): the component says
ask: z.looseObject({}),
component: StoredComponent.optional(),
status: z.enum(PauseStatus),
isSent: z.boolean(),
askedAt: text,
resolution: z.enum(PauseResolution).optional(),
resolvedAt: optionalText,
});

/** The tool call an approval's pause asks about. */
const StoredApprovalAsk = z.looseObject({ callId: text, tool: text, summary: text });

/** agbossjs/agboss#746: a question's ask is the role's own DTO; every other pause asks a tool call. */
const isAskOfItsKind = (pause: z.infer<typeof StoredPause>): boolean =>
pause.component?.kind === ComponentKind.Question ||
StoredApprovalAsk.safeParse(pause.ask).success;

/** A paused run: the fields a resume relies on (`budgetUsd` may be `Infinity`: no cost limit). */
const StoredRun = z.looseObject({
status: z.literal("paused"),
Expand All @@ -73,8 +84,10 @@ const StoredRun = z.looseObject({
const isPauseAnswer = (value: unknown): value is PauseAnswer =>
StoredPauseAnswer.safeParse(value).success;

const isPauseRecord = (value: unknown): value is PauseRecord =>
StoredPause.safeParse(value).success;
const isPauseRecord = (value: unknown): value is PauseRecord => {
const parsed = StoredPause.safeParse(value);
return parsed.success && isAskOfItsKind(parsed.data);
};

const isPausedRun = (value: unknown): value is PausedExecution["run"] =>
StoredRun.safeParse(value).success;
Expand Down
58 changes: 57 additions & 1 deletion tests/stores/paused-execution.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { PauseAnswerer, PauseResolution, PauseStatus, type PauseRecord } from "@agboss/core";
import {
ComponentKind,
PauseAnswerer,
PauseResolution,
PauseStatus,
type PauseRecord,
} from "@agboss/core";
import { VirtualClock } from "@agboss/core/testing";
import { describe, expect, it } from "vitest";
import { PostgresPausedExecutionRepository } from "../../src/index.js";
Expand Down Expand Up @@ -33,7 +39,57 @@ const pauseOf = (pauseId: string, stepKey: string): PauseRecord => ({
askedAt: "2026-01-05T09:00:00.000Z",
});

/** agbossjs/agboss#746: a question role's pause — the role its component, its ask the role's DTO. */
const questionPauseOf = (pauseId: string, stepKey: string): PauseRecord => ({
...pauseOf(pauseId, stepKey),
nodeId: "ship-question",
agent: "ship-question",
channel: "ship-question",
component: {
name: "ship-question",
kind: ComponentKind.Question,
promptHash: undefined,
schemaHash: "ddb0c5dd",
},
ask: { name: "box-1" },
});

describe("PostgresPausedExecutionRepository", () => {
it("agbossjs/agboss#746: a question's pause, whose ask is no tool call, is read back as written", async () => {
const pauses = pausesOn();
const asked = questionPauseOf("p-question", "step-question");

await pauses.findOrCreateByStepKey(asked);
await pauses.resolveByTenantIdAndPauseId(
"default",
"p-question",
{ decision: { verdict: "yes" }, answerer: undefined },
NINE,
);

expect(await pauses.findAllByTenantIdAndRootExecutionId("default", "run-1")).toEqual([
expect.objectContaining({ pauseId: "p-question", ask: { name: "box-1" } }),
]);
expect(await pauses.findByTenantIdAndPauseId("default", "p-question")).toMatchObject({
component: { kind: ComponentKind.Question },
status: PauseStatus.Resolved,
answer: { decision: { verdict: "yes" } },
});
});

it("an approval's pause whose ask lost its tool call still fails loudly when read", async () => {
const pauses = pausesOn();
await pauses.findOrCreateByStepKey(pauseOf("p-no-call", "step-no-call"));
await onSchema(
database.url(),
`UPDATE agboss_pauses SET record = record #- '{ask,callId}' WHERE pause_id = 'p-no-call'`,
);

await expect(pauses.findByTenantIdAndPauseId("default", "p-no-call")).rejects.toThrow(
/a stored pause is not readable/,
);
});

it("markTaken turns a Resolved pause to Taken, and nothing else", async () => {
const pauses = pausesOn();
await pauses.findOrCreateByStepKey(pauseOf("p-resolved", "step-resolved"));
Expand Down
Loading