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
2 changes: 2 additions & 0 deletions apps/server/src/chat/draft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ test("the draft instruction comes from the plan and refuses an unsettled one", a
let built = draftInstruction(plan, "ana", true);
expect(built.text).toContain("Chopin approves and starts these tasks automatically");
expect(built.text).toContain("do not describe them as unapproved or unstarted");
expect(built.text).toContain("do not add a task that prototypes that passage again");
expect(prepare.text).not.toContain("Prototyping");
expect(built.text).not.toContain("Do not approve or start implementation.");
expect(built.said).toBe(prepare.said);
expect(draftRefusal(plan)).toBeUndefined();
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/experiments/spikes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ test("a submitted spike renders as a titled callout with findings and screenshot
expect(source).toContain(`title="Drag handles work on touch with a 44px target"`);
expect(source).toContain("- Pointer events fire on iOS Safari.");
expect(source).toContain("**Recommendation:** Keep drag handles");
// The recommendation leads; the findings that support it follow.
expect(source.indexOf("**Recommendation:**")).toBeLessThan(source.indexOf("- Pointer events"));
expect(source).toContain(`![Prototype screenshot 1](${IMAGE})`);
room.validate(source);
let node = parse(source).children[0];
Expand All @@ -75,7 +77,7 @@ test("a submitted spike renders as a titled callout with findings and screenshot
test("running and stopped spikes render their own callouts without naming a machine", () => {
let running = serialize({ type: "root", children: [spikeCallout(spike("running"))] });
expect(running).toContain(`title="Prototyping…"`);
expect(running).toContain("maggie's coding agent");
expect(running).toContain("@maggie’s coding agent");
room.validate(running);
let stopped = spike("failed");
stopped.progress = "Agent stopped: cancelled";
Expand All @@ -86,7 +88,7 @@ test("running and stopped spikes render their own callouts without naming a mach
expect(renderKey(spike("requested"))).toBe("queued");
expect(renderKey(spike("queued"))).toBe("queued");
let queued = serialize({ type: "root", children: [spikeCallout(spike("queued"))] });
expect(queued).toContain(`title="Queued"`);
expect(queued).toContain(`title="Prototype queued"`);
expect(queued).not.toContain("Prototyping");
room.validate(queued);
expect(renderKey(spike("interrupted"))).toBe("stopped");
Expand Down
14 changes: 7 additions & 7 deletions apps/server/src/experiments/spikes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function paragraph(...children: PhrasingContent[]): BlockContent {
return { type: "paragraph", children };
}

/** Canonical report MDX: bold headline, findings, recommendation, then screenshots. */
/** Canonical report MDX: bold headline, the recommendation first, findings, then screenshots. */
export function spikeReport(input: SpikeSubmission): string {
let items: ListItem[] = input.findings.map(finding => ({
type: "listItem",
Expand All @@ -66,11 +66,11 @@ export function spikeReport(input: SpikeSubmission): string {
type: "root",
children: [
paragraph({ type: "strong", children: [{ type: "text", value: input.headline }] }),
{ type: "list", ordered: false, spread: false, children: items },
paragraph(
{ type: "strong", children: [{ type: "text", value: "Recommendation:" }] },
{ type: "text", value: ` ${input.recommendation}` },
),
{ type: "list", ordered: false, spread: false, children: items },
...input.images.map((url, index) =>
paragraph({ type: "image", url, alt: `Prototype screenshot ${index + 1}` })
),
Expand Down Expand Up @@ -121,19 +121,19 @@ export function spikeCallout(value: Investigation): RootContent {
]);
}
if (key === "queued") {
return callout(spike.callout, "note", "Queued", [
return callout(spike.callout, "note", "Prototype queued", [
paragraph({
type: "text",
value: `${spike.login}'s coding agent will build a quick prototype to test the passage `
+ "above once it finishes its current work. Delete this callout to cancel it.",
value: `@${spike.login}’s coding agent will prototype the passage above when it’s free. `
+ "Delete this callout to cancel.",
}),
]);
}
return callout(spike.callout, "note", "Prototyping…", [
paragraph({
type: "text",
value: `${spike.login}'s coding agent is building a quick prototype to test the passage `
+ "above. Delete this callout to stop it.",
value: `@${spike.login}’s coding agent is testing the passage above with a quick prototype. `
+ "Delete this callout to stop it.",
}),
]);
}
10 changes: 8 additions & 2 deletions apps/server/src/tasks/builds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,8 +774,12 @@ export function reportRebuild(
`Living-document rebuild of revisions ${live.baseRevision} to ${target.revision}.`,
goal: task.goal,
acceptance: [
`Committed on ${task.pullRequest}.`,
`Reflects the document change since revision ${live.baseRevision}.`,
`Updates pull request ${
task.pullRequest.startsWith(prefix)
? `#${task.pullRequest.slice(prefix.length)}`
: task.pullRequest
}.`,
"Matches the document as edited.",
],
dependsOn: [],
})),
Expand Down Expand Up @@ -884,8 +888,10 @@ export function liveSnapshot(plan: Plan, connections: Connection[]): LiveSnapsho
// The first build's tasks, then each rebuild's appended version, with their last reported state.
let first = plan.builds.find(build => build.id === live.buildId)?.graphVersion;
let history = plan.graph ? historyFor(plan.graph, plan.lifecycle) : [];
// Only versions that ran: a Planner draft started since would list unbuilt duplicates.
let tasks = (plan.graph?.versions ?? []).filter(version =>
first !== undefined && version.number >= first
&& history.some(item => item.run.graphVersion === version.number)
).flatMap(version => {
let progress = history.findLast(item => item.run.graphVersion === version.number)?.progress;
return version.definition.tasks.map(task => {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/tasks/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function draftInstruction(
build = false,
): { text: string; said: string } {
let close = build
? "Do not approve or start implementation yourself: Chopin approves and starts these tasks automatically once they are saved, so do not describe them as unapproved or unstarted."
? "Do not approve or start implementation yourself: Chopin approves and starts these tasks automatically once they are saved, so do not describe them as unapproved or unstarted. A note Callout titled “Prototyping…” marks a passage a prototype is already testing, and its result lands in the document before these tasks start: do not add a task that prototypes that passage again; have the task that implements it follow the prototype's result."
: "Do not approve or start implementation.";
let graph = plan.graph?.versions.at(-1);
if (!graph) {
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/tasks/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,16 @@ test("report_rebuild appends a completed version, records commits and advances t
});
expect(snapshot.live.baseSource).toBeUndefined();
expect(snapshot.lifecycle.history.at(-1).outcome.kind).toBe("implemented");
expect(snapshot.live.tasks.at(-1)).toMatchObject({
id: "rebuild-2-1",
acceptance: ["Updates pull request #7.", "Matches the document as edited."],
});
// A Planner draft started after the build is not built work, so the list ignores it.
plan.graph!.versions.push({ ...structuredClone(plan.graph!.versions[0]!), number: 3 });
plan.graph!.versions.at(-1)!.state = "draft";
let drafted = await (await context.call(context.path)).json();
expect(drafted.live.tasks).toEqual(snapshot.live.tasks);
plan.graph!.versions.pop();
let live = plan.live;
await Plan.close(plan);
closed = true;
Expand Down
168 changes: 160 additions & 8 deletions apps/web/src/build-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,29 @@ import {
advanceFirstBuild,
ago,
attentionHint,
blockerLabelled,
blockerText,
buildPhase,
draftKey,
draftRefusalCopy,
elapsed,
firstBuildStep,
linkParts,
liveTaskGroups,
liveTaskState,
pullRequestCommits,
pullRequestNumber,
shortUrl,
shouldAutoDraft,
startedBy,
startingHint,
startingLabel,
syncHint,
syncLabel,
syncStatus,
syncTooltip,
taskStartsOpen,
unfinishedReason,
waitingLabel,
} from "./build-model";

Expand Down Expand Up @@ -439,7 +448,16 @@ describe("living document sync", () => {
expect(syncStatus(live({ outOfSync: true, rebuild: rebuild(state) })))
.toEqual({ kind: "building" });
}
expect(syncStatus({ ...live(), build: build("running") })).toEqual({ kind: "building" });
expect(syncStatus({ ...live(), build: build("running") }))
.toEqual({ kind: "building", first: true });
});

it("keeps the first build's label while it finishes after delivering", () => {
let finishing = syncStatus({ ...live(), build: build("running") })!;
expect(syncLabel(finishing)).toBe("Building…");
expect(syncTooltip(finishing, undefined, "me")).toBe("Finishing the first build");
expect(syncLabel(syncStatus(live({ outOfSync: true, rebuild: rebuild("running") }))!))
.toBe("Syncing…");
});

it("explains a first build queued behind another document's build", () => {
Expand Down Expand Up @@ -473,14 +491,19 @@ describe("living document sync", () => {
it("explains why it is out of sync", () => {
let pending = live({ outOfSync: true, rebuild: rebuild("stopped") });
expect(syncStatus(pending)).toEqual({ kind: "out-of-sync", reason: "pending", outstanding: 0 });
expect(syncHint(syncStatus(pending), pending, "me")).toBe("Changes will build shortly");
expect(syncHint(syncStatus(pending), pending, "me")).toBe("Your edits will sync shortly");
let waiting = { ...live({ outOfSync: true, builderConnected: false }), builtBy: "jev" };
expect(syncStatus(waiting)).toEqual({ kind: "out-of-sync", reason: "waiting", outstanding: 0 });
expect(syncHint(syncStatus(waiting), waiting, "me")).toBe("Waiting for @jev’s agent");
expect(syncHint(syncStatus(waiting), waiting, "u")).toBe("Waiting for your agent");
let failed = live({ outOfSync: true, rebuild: rebuild("failed") });
expect(syncStatus(failed)).toEqual({ kind: "out-of-sync", reason: "failed", outstanding: 0 });
expect(syncHint(syncStatus(failed), failed, "me")).toBe("The last rebuild failed");
expect(syncHint(syncStatus(failed), failed, "me")).toBe("The next edit will try again");
expect(syncLabel({ kind: "out-of-sync", reason: "failed", outstanding: 0 })).toBe(
"Sync failed",
);
expect(syncLabel({ kind: "out-of-sync", reason: "waiting", outstanding: 0 }))
.toBe("Out of sync");
});

it("needs attention while a blocked task waits for an edit, even in sync", () => {
Expand All @@ -492,27 +515,135 @@ describe("living document sync", () => {
expect(syncStatus(stuck)).toEqual({ kind: "needs-attention", outstanding: 2 });
expect(syncHint(syncStatus(stuck), stuck, "me"))
.toBe(
"“Store graphs” is blocked: Which database? (and 1 other). Edit the document to retry.",
"“Store graphs” is blocked: Which database? (and 1 other). Edit the document to retry them.",
);
let edited = live({ outstandingTasks, outOfSync: true });
expect(syncStatus(edited)).toEqual({ kind: "out-of-sync", reason: "pending", outstanding: 2 });
expect(syncHint(syncStatus(edited), edited, "me"))
.toBe("Changes will build shortly · will also retry 2 blocked tasks");
.toBe("Your edits and 2 blocked tasks will sync shortly");
let waiting = { ...live({ outstandingTasks, outOfSync: true, builderConnected: false }) };
expect(syncHint(syncStatus(waiting), waiting, "u"))
.toBe("Waiting for your agent to sync your edits and 2 blocked tasks");
let failed = live({ outstandingTasks, outOfSync: true, rebuild: rebuild("failed") });
expect(syncHint(syncStatus(failed), failed, "me"))
.toBe("The next edit will retry the sync and 2 blocked tasks");
expect(syncLabel(syncStatus(stuck)!)).toBe("Needs attention");
expect(syncTooltip(syncStatus(stuck)!, stuck, "me")).toContain(
"Edit the document to retry them.",
);
// Syncing beats needing attention.
expect(syncStatus(live({ outstandingTasks, rebuild: rebuild("running") })))
.toEqual({ kind: "building" });
});

it("leaves the blocker to the task row in the Build view's brief hint", () => {
let stuck = live({
outstandingTasks: [
{ id: "a", title: "Store graphs", state: "blocked", blocker: "Which database?" },
{ id: "b", title: "Render graphs", state: "queued" },
],
});
expect(attentionHint(stuck, true))
.toBe("Edit the document to retry “Store graphs” and 1 other");
let unfinished = live({ outstandingTasks: [{ id: "a", title: "Ship", state: "queued" }] });
expect(attentionHint(unfinished, true)).toBe("Edit the document to retry “Ship”");
});

it("caps a long blocker in the tooltip at a word boundary", () => {
let blocker = `The repository has no app.${" Should I scaffold one?".repeat(10)}`;
let hint = attentionHint(live({
outstandingTasks: [{ id: "a", title: "Ship", state: "blocked", blocker }],
}))!;
expect(hint).toMatch(/^“Ship” is blocked: The repository has no app\. Should I .*…/);
expect(hint.endsWith("… Edit the document to retry it.")).toBe(true);
expect(hint.length).toBeLessThan(180);
});

it("says why an unfinished task stopped: its last report, else where the agent stopped", () => {
expect(unfinishedReason(undefined)).toBe("Your agent stopped before opening a pull request");
expect(unfinishedReason({ pullRequest: { url: PR, state: "open" } }))
.toBe("Your agent stopped before finishing this task");
expect(unfinishedReason({ summary: " Tests still fail on CI. " }))
.toBe("Tests still fail on CI.");
let task = (progress: object) => ({
id: "a",
title: "Ship",
context: "",
goal: "",
acceptance: [],
dependsOn: [],
progress: { id: "a", state: "queued" as const, ...progress },
});
let quiet = live({
outstandingTasks: [{ id: "a", title: "Ship", state: "queued" }],
tasks: [task({})],
});
expect(attentionHint(quiet)).toBe(
"“Ship” didn’t finish: your agent stopped before opening a pull request. Edit the document to retry it.",
);
let reported = live({
outstandingTasks: [{ id: "a", title: "Ship", state: "queued" }],
tasks: [task({ summary: "Waiting on a review of https://github.com/o/r/pull/7." })],
});
expect(attentionHint(reported)).toBe(
"“Ship” didn’t finish: Waiting on a review of o/r#7. Edit the document to retry it.",
);
});

it("drops an agent's stacked Blocked: labels and keeps its own", () => {
expect(blockerText("Blocked: Blocker - Which database?")).toBe("Which database?");
expect(blockerText("Blocked by CI")).toBe("Blocked by CI");
expect(blockerLabelled("Awaiting human confirmation: Jev must confirm")).toBe(true);
expect(blockerLabelled("Which database? Postgres: or SQLite")).toBe(false);
expect(blockerLabelled("https://github.com/o/r/pull/1 fails")).toBe(false);
let hint = attentionHint(live({
outstandingTasks: [{
id: "a",
title: "Ship",
state: "blocked",
blocker: "Blocked: Awaiting review: see https://github.com/o/r/pull/28#issuecomment-339.",
}],
}));
expect(hint).toBe(
"“Ship” is blocked: Awaiting review: see o/r#28 comment. Edit the document to retry it.",
);
});

it("shortens GitHub links and leaves other links whole", () => {
expect(shortUrl("https://github.com/o/r/pull/28")).toBe("o/r#28");
expect(shortUrl("https://github.com/o/r/issues/3#issuecomment-12")).toBe("o/r#3 comment");
expect(shortUrl("https://github.com/o/r/pull/28/files")).toBe("o/r#28");
expect(shortUrl("https://example.com/o/r/pull/28")).toBeUndefined();
expect(
linkParts("on PR #28 (https://github.com/o/r/pull/28#issuecomment-9). See https://x.dev/a."),
).toEqual([
{ text: "on PR #28 (" },
{ text: "o/r#28 comment", href: "https://github.com/o/r/pull/28#issuecomment-9" },
{ text: "). See " },
{ text: "https://x.dev/a", href: "https://x.dev/a" },
{ text: "." },
]);
expect(linkParts("no links")).toEqual([{ text: "no links" }]);
});

it("shows a task the first build left unfinished as needing attention until a sync runs", () => {
expect(liveTaskState("queued", true, false)).toBe("blocked");
expect(liveTaskState("queued", true, true)).toBe("queued");
expect(liveTaskState("queued", false, false)).toBe("queued");
expect(liveTaskState("in_progress", true, false)).toBe("in_progress");
expect(liveTaskState("completed", true, false)).toBe("completed");
});

it("ends a blocker's hint with one stop, whatever punctuation it brought", () => {
let hint = (blocker: string) =>
attentionHint(live({
outstandingTasks: [{ id: "a", title: "Ship", state: "blocked", blocker }],
}));
expect(hint("I can't decide this myself.")).toBe(
"“Ship” is blocked: I can't decide this myself. Edit the document to retry.",
"“Ship” is blocked: I can't decide this myself. Edit the document to retry it.",
);
expect(hint("Which database?")).toBe(
"“Ship” is blocked: Which database? Edit the document to retry.",
"“Ship” is blocked: Which database? Edit the document to retry it.",
);
});

Expand All @@ -526,14 +657,35 @@ describe("living document sync", () => {
sync: { kind: "needs-attention", outstanding: 1 },
});
expect(attentionHint(stopped)).toBe(
"“Ship” is blocked: Pick a host. Edit the document to retry.",
"“Ship” is blocked: Pick a host. Edit the document to retry it.",
);
});

it("does not call a failed rebuild out of sync once later edits match", () => {
expect(syncStatus(live({ rebuild: rebuild("failed") }))).toEqual({ kind: "in-sync" });
expect(syncHint({ kind: "in-sync" }, live(), "me")).toBeUndefined();
});

it("names every state with one sync vocabulary and a tooltip", () => {
expect(syncLabel({ kind: "in-sync" })).toBe("In sync");
expect(syncLabel({ kind: "building" })).toBe("Syncing…");
expect(syncTooltip({ kind: "in-sync" }, live(), "me")).toBe("Pull requests match the document");
expect(syncTooltip({ kind: "building" }, live(), "me"))
.toBe("Updating pull requests to match the document");
let pending = live({ outOfSync: true, rebuild: rebuild("stopped") });
expect(syncTooltip(syncStatus(pending)!, pending, "me")).toBe("Your edits will sync shortly");
});

it("splits the first build's tasks from those later syncs added", () => {
let tasks = [{ id: "workspace" }, { id: "rebuild-2-1" }, { id: "notes" }, {
id: "rebuild-3-1",
}];
expect(liveTaskGroups(tasks)).toEqual({
first: [{ id: "workspace" }, { id: "notes" }],
since: [{ id: "rebuild-2-1" }, { id: "rebuild-3-1" }],
});
expect(liveTaskGroups([{ id: "workspace" }]).since).toEqual([]);
});
});

describe("living document commits", () => {
Expand Down
Loading
Loading