Skip to content

Commit 8698a69

Browse files
authored
Merge pull request #137 from pylon-code/feat/plan-progress-preview
feat(progress): show honest waits and delegated work
2 parents 92ec009 + daebe77 commit 8698a69

20 files changed

Lines changed: 848 additions & 117 deletions

apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2380,6 +2380,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
23802380
activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at
23812381
)
23822382
VALUES
2383+
(
2384+
'plan-old', 'thread-w', 'turn-5', 'info', 'turn.plan.updated',
2385+
'Waiting on external system',
2386+
'{"plan":[{"step":"Await CI","status":"waiting","waitingOn":"external"}]}', NULL,
2387+
'2026-03-01T00:00:00.500Z'
2388+
),
23832389
(
23842390
'approval-old', 'thread-w', NULL, 'approval', 'approval.requested',
23852391
'Approve old command', '{"requestId":"approval-1"}', NULL,
@@ -2429,25 +2435,31 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
24292435
const detailWithPinnedRequests = yield* snapshotQuery.getThreadDetailById(threadW);
24302436
assert.equal(detailWithPinnedRequests._tag, "Some");
24312437
if (detailWithPinnedRequests._tag === "Some") {
2432-
const ids = detailWithPinnedRequests.value.activities.map((activity) => activity.id);
2433-
assert.equal(detailWithPinnedRequests.value.activities.length, 503);
2434-
assert.equal(ids.includes(asEventId("approval-old")), true);
2435-
assert.equal(ids.includes(asEventId("user-input-old")), true);
2436-
assert.equal(ids.includes(asEventId("user-input-closed")), false);
2437-
assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true);
2438+
const ids = new Set(
2439+
detailWithPinnedRequests.value.activities.map((activity) => activity.id),
2440+
);
2441+
assert.equal(detailWithPinnedRequests.value.activities.length, 504);
2442+
assert.equal(ids.has(asEventId("plan-old")), true);
2443+
assert.equal(ids.has(asEventId("approval-old")), true);
2444+
assert.equal(ids.has(asEventId("user-input-old")), true);
2445+
assert.equal(ids.has(asEventId("user-input-closed")), false);
2446+
assert.equal(ids.has(asEventId("user-input-tied-z-request")), true);
24382447
}
24392448

24402449
const windowWithPinnedRequests = yield* snapshotQuery.getThreadDetailSnapshot(threadW, {
24412450
turnLimit: 2,
24422451
});
24432452
assert.equal(windowWithPinnedRequests._tag, "Some");
24442453
if (windowWithPinnedRequests._tag === "Some") {
2445-
const ids = windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id);
2446-
assert.equal(windowWithPinnedRequests.value.thread.activities.length, 503);
2447-
assert.equal(ids.includes(asEventId("approval-old")), true);
2448-
assert.equal(ids.includes(asEventId("user-input-old")), true);
2449-
assert.equal(ids.includes(asEventId("user-input-closed")), false);
2450-
assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true);
2454+
const ids = new Set(
2455+
windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id),
2456+
);
2457+
assert.equal(windowWithPinnedRequests.value.thread.activities.length, 504);
2458+
assert.equal(ids.has(asEventId("plan-old")), true);
2459+
assert.equal(ids.has(asEventId("approval-old")), true);
2460+
assert.equal(ids.has(asEventId("user-input-old")), true);
2461+
assert.equal(ids.has(asEventId("user-input-closed")), false);
2462+
assert.equal(ids.has(asEventId("user-input-tied-z-request")), true);
24512463
}
24522464
}),
24532465
);

apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,9 +1287,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
12871287
`,
12881288
});
12891289

1290-
// Blocking request payloads must remain available even if they predate the
1291-
// recent activity window. Each CTE returns at most one unresolved row per
1292-
// request, so the merge below stays bounded by actionable work.
1290+
// Blocking request payloads and the latest turn's authoritative plan must
1291+
// remain available even if they predate the recent activity window. Each
1292+
// CTE returns bounded state, so the merge below cannot grow with history.
12931293
const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({
12941294
Request: ThreadIdLookupInput,
12951295
Result: ProjectionThreadActivityDbRowSchema,
@@ -1349,6 +1349,20 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
13491349
)
13501350
AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL
13511351
),
1352+
latest_plan_activity AS (
1353+
SELECT activity.activity_id
1354+
FROM projection_thread_activities AS activity
1355+
INNER JOIN projection_threads AS thread
1356+
ON thread.thread_id = activity.thread_id
1357+
WHERE activity.thread_id = ${threadId}
1358+
AND activity.turn_id = thread.latest_turn_id
1359+
AND activity.kind = 'turn.plan.updated'
1360+
ORDER BY
1361+
activity.sequence DESC,
1362+
activity.created_at DESC,
1363+
activity.activity_id DESC
1364+
LIMIT 1
1365+
),
13521366
pinned_activity_ids AS (
13531367
SELECT activity_id
13541368
FROM pending_approval_activities
@@ -1358,6 +1372,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
13581372
FROM user_input_lifecycle
13591373
WHERE request_order = 1
13601374
AND kind = 'user-input.requested'
1375+
UNION ALL
1376+
SELECT activity_id
1377+
FROM latest_plan_activity
13611378
)
13621379
SELECT
13631380
activity.activity_id AS "activityId",

apps/server/src/orchestration/ThreadPlanProgress.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ describe("ThreadPlanProgress", () => {
2424
expect(progress.getThreadPlanProgress(threadId)).toBeNull();
2525
});
2626

27-
it("falls back to the first non-completed step when nothing is in progress", () => {
27+
it("falls back to the first pending step when nothing is active", () => {
2828
const progress = ThreadPlanProgress.make();
2929
const threadId = "t-plan-2";
3030
progress.recordPlanProgress(threadId, [
@@ -34,6 +34,20 @@ describe("ThreadPlanProgress", () => {
3434
expect(progress.getThreadPlanProgress(threadId)?.step).toBe("First");
3535
});
3636

37+
it("surfaces an explicit wait before pending work", () => {
38+
const progress = ThreadPlanProgress.make();
39+
const threadId = "t-plan-wait";
40+
progress.recordPlanProgress(threadId, [
41+
{ step: "Pending setup", status: "pending" },
42+
{ step: "Await review", status: "waiting" },
43+
]);
44+
expect(progress.getThreadPlanProgress(threadId)).toEqual({
45+
step: "Await review",
46+
completedSteps: 0,
47+
totalSteps: 2,
48+
});
49+
});
50+
3751
it("clearThreadPlanProgress removes the entry (turn settled / session died)", () => {
3852
const progress = ThreadPlanProgress.make();
3953
const threadId = "t-plan-3";

apps/server/src/orchestration/ThreadPlanProgress.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,12 @@ export function make(): ThreadPlanProgressService["Service"] {
4949
recordPlanProgress: (threadId, plan) => {
5050
const totalSteps = plan.length;
5151
const completedSteps = plan.filter((step) => step.status === "completed").length;
52-
// Current step: the in-progress one, else the first pending one (a
53-
// plan that was just written has no in-progress step yet).
52+
// Current step: active work or an explicit wait, else the first
53+
// pending outcome (a plan that was just written has no active step yet).
5454
const current =
5555
plan.find((step) => step.status === "inProgress") ??
56-
plan.find((step) => step.status !== "completed");
56+
plan.find((step) => step.status === "waiting") ??
57+
plan.find((step) => step.status === "pending");
5758
if (totalSteps === 0 || completedSteps === totalSteps || current === undefined) {
5859
progressByThreadId.delete(threadId);
5960
return;

apps/server/src/provider/prime/PrimeAgentDaemonEvents.test.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,8 @@ describe("PrimeAgentDaemonEvents", () => {
255255
explanation: " Safe task parity ",
256256
plan: [
257257
{ step: " Inspect behavior ", status: "completed" },
258-
{ step: "Project plan", status: "inProgress" },
258+
{ step: "Project plan", status: "pending" },
259+
{ step: "Await delegated review", status: "waiting", waitingOn: "delegates" },
259260
],
260261
},
261262
isError: false,
@@ -272,7 +273,8 @@ describe("PrimeAgentDaemonEvents", () => {
272273
explanation: "Safe task parity",
273274
plan: [
274275
{ step: "Inspect behavior", status: "completed" },
275-
{ step: "Project plan", status: "inProgress" },
276+
{ step: "Project plan", status: "pending" },
277+
{ step: "Await delegated review", status: "waiting", waitingOn: "delegates" },
276278
],
277279
},
278280
},
@@ -335,6 +337,33 @@ describe("PrimeAgentDaemonEvents", () => {
335337
plan: [{ step: " ", status: "pending" }],
336338
},
337339
},
340+
{
341+
toolName: "pylon_update_plan",
342+
isError: false,
343+
details: {
344+
protocol: "pylon-plan-v1",
345+
plan: [{ step: "Wait", status: "waiting" }],
346+
},
347+
},
348+
{
349+
toolName: "pylon_update_plan",
350+
isError: false,
351+
details: {
352+
protocol: "pylon-plan-v1",
353+
plan: [{ step: "Work", status: "inProgress", waitingOn: "user" }],
354+
},
355+
},
356+
{
357+
toolName: "pylon_update_plan",
358+
isError: false,
359+
details: {
360+
protocol: "pylon-plan-v1",
361+
plan: [
362+
{ step: "Work", status: "inProgress" },
363+
{ step: "Wait", status: "waiting", waitingOn: "external" },
364+
],
365+
},
366+
},
338367
];
339368
for (const [index, input] of invalidResults.entries()) {
340369
const decoded = decodePrimeAgentDaemonEvent(

apps/server/src/provider/prime/PrimeAgentDaemonEvents.ts

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,17 @@ export const PrimeAgentDaemonMessage = Schema.Union([
101101
]);
102102
export type PrimeAgentDaemonMessage = typeof PrimeAgentDaemonMessage.Type;
103103

104-
const managedPlanStepDetails = Schema.Struct({
105-
step: Schema.String.check(Schema.isMaxLength(PRIME_AGENT_PLAN_MAX_STEP_CHARS)),
106-
status: Schema.Literals(["pending", "inProgress", "completed"]),
107-
}).annotate({ parseOptions: { onExcessProperty: "error" } });
104+
const managedPlanStepDetails = Schema.Union([
105+
Schema.Struct({
106+
step: Schema.String.check(Schema.isMaxLength(PRIME_AGENT_PLAN_MAX_STEP_CHARS)),
107+
status: Schema.Literals(["pending", "inProgress", "completed"]),
108+
}).annotate({ parseOptions: { onExcessProperty: "error" } }),
109+
Schema.Struct({
110+
step: Schema.String.check(Schema.isMaxLength(PRIME_AGENT_PLAN_MAX_STEP_CHARS)),
111+
status: Schema.Literal("waiting"),
112+
waitingOn: Schema.Literals(["user", "delegates", "external"]),
113+
}).annotate({ parseOptions: { onExcessProperty: "error" } }),
114+
]);
108115
const managedPlanDetails = Schema.Struct({
109116
protocol: Schema.Literal(PRIME_AGENT_PLAN_PROTOCOL),
110117
explanation: Schema.optional(
@@ -660,10 +667,17 @@ export interface PrimeDaemonToolCall {
660667
export interface PrimeDaemonPlanUpdate {
661668
readonly toolCallId: string;
662669
readonly explanation?: string | undefined;
663-
readonly plan: ReadonlyArray<{
664-
readonly step: string;
665-
readonly status: "pending" | "inProgress" | "completed";
666-
}>;
670+
readonly plan: ReadonlyArray<
671+
| {
672+
readonly step: string;
673+
readonly status: "pending" | "inProgress" | "completed";
674+
}
675+
| {
676+
readonly step: string;
677+
readonly status: "waiting";
678+
readonly waitingOn: "user" | "delegates" | "external";
679+
}
680+
>;
667681
}
668682

669683
export type PrimeDaemonMessage =
@@ -991,11 +1005,17 @@ export function projectPrimeAgentManagedPlanUpdate(input: {
9911005
if (toolCallId.length === 0 || toolCallId.length > MAX_PREVIEW_LENGTH) return undefined;
9921006
const decoded = decodeManagedPlanDetails(input.details);
9931007
if (Option.isNone(decoded)) return undefined;
994-
const plan = decoded.value.plan.map((item) => ({
995-
step: item.step.trim(),
996-
status: item.status,
997-
}));
998-
if (plan.some((item) => item.step.length === 0)) return undefined;
1008+
const plan: PrimeDaemonPlanUpdate["plan"] = decoded.value.plan.map((item) =>
1009+
item.status === "waiting"
1010+
? { step: item.step.trim(), status: item.status, waitingOn: item.waitingOn }
1011+
: { step: item.step.trim(), status: item.status },
1012+
);
1013+
if (
1014+
plan.some((item) => item.step.length === 0) ||
1015+
plan.filter((item) => item.status === "inProgress" || item.status === "waiting").length > 1
1016+
) {
1017+
return undefined;
1018+
}
9991019
const explanation = decoded.value.explanation?.trim();
10001020
return {
10011021
toolCallId,

apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ describe("mapPrimeAgentDaemonRuntimeEventDrafts", () => {
235235
explanation: "Prime tasks",
236236
plan: [
237237
{ step: "Inspect", status: "completed" as const },
238-
{ step: "Implement", status: "inProgress" as const },
238+
{ step: "Await review", status: "waiting" as const, waitingOn: "user" as const },
239239
],
240240
},
241241
};
@@ -255,7 +255,7 @@ describe("mapPrimeAgentDaemonRuntimeEventDrafts", () => {
255255
explanation: "Prime tasks",
256256
plan: [
257257
{ step: "Inspect", status: "completed" },
258-
{ step: "Implement", status: "inProgress" },
258+
{ step: "Await review", status: "waiting", waitingOn: "user" },
259259
],
260260
},
261261
},

apps/server/src/provider/prime/PrimeAgentManagedExtension.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest";
22

33
import {
44
PRIME_AGENT_PLAN_PROTOCOL,
5+
PRIME_AGENT_PLAN_TOOL_DEFINITION,
56
PRIME_AGENT_PLAN_TOOL_NAME,
67
makePrimeAgentManagedExtensionSource,
78
projectPrimeAgentManagedPermissionRequest,
@@ -14,6 +15,19 @@ const project = (input: {
1415
readonly message?: string;
1516
}) => projectPrimeAgentManagedPermissionRequest(input, token);
1617

18+
describe("PRIME_AGENT_PLAN_TOOL_DEFINITION", () => {
19+
it("advertises the same discriminated waiting contract enforced at runtime", () => {
20+
const [nonWaiting, waiting] =
21+
PRIME_AGENT_PLAN_TOOL_DEFINITION.parameters.properties.plan.items.oneOf;
22+
23+
expect(nonWaiting.properties.status.enum).toEqual(["pending", "inProgress", "completed"]);
24+
expect(nonWaiting.properties).not.toHaveProperty("waitingOn");
25+
expect(waiting.properties.status.const).toBe("waiting");
26+
expect(waiting.properties.waitingOn.enum).toEqual(["user", "delegates", "external"]);
27+
expect(waiting.required).toContain("waitingOn");
28+
});
29+
});
30+
1731
describe("projectPrimeAgentManagedPermissionRequest", () => {
1832
it("projects only the versioned, session-authorized managed confirmation format", () => {
1933
expect(
@@ -189,6 +203,70 @@ describe("makePrimeAgentManagedExtensionSource", () => {
189203
).rejects.toThrow("root Pylon session");
190204
});
191205

206+
it("preserves honest waits and rejects ambiguous managed plans", async () => {
207+
const extension = await loadExtension(extensionSource());
208+
const execute = extension.tools.get(PRIME_AGENT_PLAN_TOOL_NAME)?.execute;
209+
if (!execute) throw new Error("expected managed plan tool");
210+
211+
for (const waitingOn of ["user", "delegates", "external"] as const) {
212+
await expect(
213+
execute(
214+
`call-wait-${waitingOn}`,
215+
{ plan: [{ step: "Await dependency", status: "waiting", waitingOn }] },
216+
undefined,
217+
undefined,
218+
extensionContext(true),
219+
),
220+
).resolves.toMatchObject({
221+
details: {
222+
plan: [{ step: "Await dependency", status: "waiting", waitingOn }],
223+
},
224+
});
225+
}
226+
227+
await expect(
228+
execute(
229+
"call-wait-missing-owner",
230+
{ plan: [{ step: "Await dependency", status: "waiting" }] },
231+
undefined,
232+
undefined,
233+
extensionContext(true),
234+
),
235+
).rejects.toThrow("Waiting plan steps require waitingOn");
236+
await expect(
237+
execute(
238+
"call-owner-without-wait",
239+
{ plan: [{ step: "Keep working", status: "inProgress", waitingOn: "user" }] },
240+
undefined,
241+
undefined,
242+
extensionContext(true),
243+
),
244+
).rejects.toThrow("waitingOn is valid only for waiting plan steps");
245+
await expect(
246+
execute(
247+
"call-ambiguous-active",
248+
{
249+
plan: [
250+
{ step: "Implement", status: "inProgress" },
251+
{ step: "Await agents", status: "waiting", waitingOn: "delegates" },
252+
],
253+
},
254+
undefined,
255+
undefined,
256+
extensionContext(true),
257+
),
258+
).rejects.toThrow("at most one in-progress or waiting step");
259+
await expect(
260+
execute(
261+
"call-all-complete",
262+
{ plan: [{ step: "Done", status: "completed" }] },
263+
undefined,
264+
undefined,
265+
extensionContext(true),
266+
),
267+
).resolves.toMatchObject({ details: { plan: [{ step: "Done", status: "completed" }] } });
268+
});
269+
192270
it("combines plan updates with the supervised gate and allows only that side-effect-free tool", async () => {
193271
const source = extensionSource(token);
194272
expect(source).toContain('pi.on("tool_call"');

0 commit comments

Comments
 (0)