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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ The recent channel chat transcript is supplied as bounded context for the next t
even when those messages did not address the Planner. Saved member messages also
maintain the document's title independently of conversation-derived cards. Weak
or incidental discussion leaves the title alone; topic changes may refine a blank,
generated or Planner-drafted title, but never one a person set. This work obeys `AGENT`, ownership and implementation locks, and
generated or Planner-drafted title, but never one a person set or renamed, and never
while someone is renaming. This work obeys `AGENT`, ownership and implementation locks, and
does not require Jev. A title job can seed a goal only before body prose exists.

The first eligible model-backed action, including title maintenance, a Planner turn
Expand Down
129 changes: 128 additions & 1 deletion apps/server/src/agent/heading-tool.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { afterEach, expect, spyOn, test } from "bun:test";
import { QuestionnaireNode } from "@chopin/dialect";
import { $nodesOfType } from "lexical";
import { $createTextNode, $getRoot, $isElementNode, $nodesOfType } from "lexical";
import * as Y from "yjs";

import { toolbox } from "./heading-tool.test-bridge";
import * as room from "../plan/room";
import * as Service from "../plan/service";
import { openPlan } from "../testing/plan";

import type { ConversationPlan } from "@chopin/protocol";
import type { Socket } from "../wire";

const CARD = `<Questionnaire id="01K0N4X2M5R8T3VQ7YB6ZC4DEF" by="ana" at="2026-07-28T10:14:00.000Z">
<Question id="01K0N4Y2M5R8T3VQ7YB6ZC4DEF" header="Scope" prompt="What ships first?" multiple="false">
Expand Down Expand Up @@ -191,6 +193,131 @@ test("a member's rename after a draft is kept by the next draft", async () => {
expect(room.project(context.plan.document)).toBe("# Member title\n\nExisting body.\n");
});

test("a person's rename is never redrafted, even to a blank, generated or earlier draft", async () => {
for (let renamed of ["", "quiet-river", "First draft"]) {
let context = await opened("# \n\nExisting body.\n");
headingJob(context.plan);
await headingTool(context).handler({ revision: 0, title: "First draft" }, {} as never);
await Service.exclusive(context.plan, () => Service.rename(context.plan, "Member title"));
await Service.exclusive(context.plan, () => Service.rename(context.plan, renamed));
let kept = room.project(context.plan.document);
await Service.close(context.plan);
context.plan = await Service.open(context.channel.id, context.backend, context.server);
headingJob(context.plan);
let result = await headingTool(context).handler(
{ revision: context.plan.revision, title: "Second draft" },
{} as never,
);
expect(JSON.parse(String(result))).toMatchObject({ ok: true, changed: false });
expect(room.project(context.plan.document)).toBe(kept);
}
});

test("a draft waits while a person has the title open, and for a while after", async () => {
let context = await opened("# amber-river\n\nExisting body.\n");
let person = {};
let clock = spyOn(Date, "now");
let at = Date.parse("2026-10-21T10:00:00.000Z");
clock.mockImplementation(() => at);
try {
Service.titling(context.plan, person, { active: true });
headingJob(context.plan);
let held = await headingTool(context).handler({ revision: 0, title: "Drafted" }, {} as never);
expect(JSON.parse(String(held))).toMatchObject({ ok: true, changed: false });
Service.titling(context.plan, person, { active: false });
at += 30_000;
headingJob(context.plan);
await headingTool(context).handler({ revision: 0, title: "Drafted" }, {} as never);
expect(room.project(context.plan.document)).toBe("# amber-river\n\nExisting body.\n");
at += 31_000;
headingJob(context.plan);
let applied = await headingTool(context).handler(
{ revision: 0, title: "Drafted" },
{} as never,
);
expect(JSON.parse(String(applied))).toMatchObject({ ok: true, title: "Drafted" });
expect(room.project(context.plan.document)).toBe("# Drafted\n\nExisting body.\n");
} finally {
clock.mockRestore();
}
});

test("a person who typed in the title field holds drafts until they leave", async () => {
let context = await opened("# amber-river\n\nExisting body.\n");
let person = {};
let clock = spyOn(Date, "now");
let at = Date.parse("2026-10-21T10:00:00.000Z");
clock.mockImplementation(() => at);
try {
Service.titling(context.plan, person, { active: true, edited: true });
Service.titling(context.plan, person, { active: false });
at += 10 * 60_000;
headingJob(context.plan);
await headingTool(context).handler({ revision: 0, title: "Drafted" }, {} as never);
expect(room.project(context.plan.document)).toBe("# amber-river\n\nExisting body.\n");
Service.departed(context.plan, person as never);
at += 61_000;
headingJob(context.plan);
await headingTool(context).handler({ revision: 0, title: "Drafted" }, {} as never);
expect(room.project(context.plan.document)).toBe("# Drafted\n\nExisting body.\n");
} finally {
clock.mockRestore();
}
});
test("a person's edit of the title in the document is never redrafted", async () => {
for (let typed of ["", "First draft"]) {
let context = await opened("# \n\nExisting body.\n");
context.backend.documentTitles = true;
await Service.close(context.plan);
context.plan = await Service.open(context.channel.id, context.backend, context.server);
let plan = context.plan;
headingJob(plan);
await headingTool(context).handler({ revision: plan.revision, title: "Drafted" }, {} as never);
expect(room.project(plan.document)).toBe("# Drafted\n\nExisting body.\n");
let peer = await room.restore(
plan.document.epoch,
Y.encodeStateAsUpdate(plan.document.doc),
room.project(plan.document),
[],
);
let vector = Y.encodeStateVector(peer.doc);
peer.editor.update(() => {
let title = $getRoot().getFirstChild();
if (!$isElementNode(title)) throw new Error("title missing");
title.clear();
if (typed) title.append($createTextNode(typed));
}, { discrete: true });
await room.settle();
let ws = {
data: { canEdit: true, room: plan.id, handle: "ana", client: "peer" },
send() {},
publish() {},
close() {},
} as unknown as Socket;
Service.submit(plan, ws, {
kind: "plan:update",
ts: 0,
rid: "title",
id: "title",
epoch: plan.document.epoch,
update: Buffer.from(Y.encodeStateAsUpdate(peer.doc, vector)).toString("base64"),
});
await Service.drain(plan);
peer.doc.destroy();
let kept = room.project(plan.document);
expect(kept).toBe(`${typed ? `# ${typed}` : "#"}\n\nExisting body.\n`);
await Service.close(plan);
context.plan = await Service.open(context.channel.id, context.backend, context.server);
headingJob(context.plan);
let result = await headingTool(context).handler(
{ revision: context.plan.revision, title: "Second draft" },
{} as never,
);
expect(JSON.parse(String(result))).toMatchObject({ ok: true, changed: false });
expect(room.project(context.plan.document)).toBe(kept);
}
});

test("unchanged friendly or blank titles are successful no-ops", async () => {
for (let title of ["Friendly otter", ""]) {
let context = await opened(`# ${title}\n`);
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/agent/heading-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { parse } from "@chopin/dialect/parse";
import { serialize } from "@chopin/dialect/serialize";

import { draftableTitle } from "../channels/document-title";
import { draftableTitle, titleHeld } from "../channels/document-title";
import * as Comments from "../comments/service";
import * as edit from "../plan/edit";
import * as room from "../plan/room";
Expand Down Expand Up @@ -84,8 +84,9 @@ export async function draftHeading(
if (revision !== plan.revision) throw new Error("stale; read_plan again");
let source = room.project(plan.document);
let current = documentTitleText(parse(source)) ?? "";
// A person's title is theirs: the draft may still seed the goal beneath it.
if (!draftableTitle(current, plan.plannerTitle)) title = current;
// A person's title is theirs, and so is one they are renaming right now: the draft may
// still seed the goal beneath it.
if (!draftableTitle(current, plan) || titleHeld(plan.titling, Date.now())) title = current;
let retitled = title !== current;
let next = withDocumentTitle(source, title);
let body = parse(documentBodySource(source));
Expand Down
29 changes: 26 additions & 3 deletions apps/server/src/channels/document-title.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,30 @@ export function generatedTitle(value: string): boolean {
return rest.length === 0 && adjectiveSet.has(adjective!) && nounSet.has(noun ?? "");
}

/** A draft may replace a blank or generated title, or the one a Planner last drafted. */
export function draftableTitle(title: string, plannerTitle: string | undefined): boolean {
return !title || title === plannerTitle || generatedTitle(title);
/**
* A draft may replace a blank or generated title, or the one a Planner last drafted, until a
* person renames the document: their title is then theirs, whatever it is.
*/
export function draftableTitle(
title: string,
plan: { plannerTitle?: string; titleChosen?: boolean },
): boolean {
return !plan.titleChosen && (!title || title === plan.plannerTitle || generatedTitle(title));
}

/** How long after a person last had the title open a drafted title still waits. */
export const TITLE_HOLD_MS = 60_000;

/** Who has the title open, or has typed in it, on which connection. Never persisted. */
export type Titling = {
people: Map<object, { active: boolean; edited: boolean }>;
/** When someone last let go of the title. */
releasedAt?: number;
};

/** A person is renaming, or about to: a drafted title must not race them. */
export function titleHeld(titling: Titling | undefined, now: number): boolean {
if (!titling) return false;
for (let person of titling.people.values()) if (person.active || person.edited) return true;
return titling.releasedAt !== undefined && now - titling.releasedAt < TITLE_HOLD_MS;
}
2 changes: 1 addition & 1 deletion apps/server/src/conversation-plan/job-prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ export const PROMPT_FOR: Record<
transcript: plan.chat.entries,
outline: outline(plan),
title,
personal: !draftableTitle(title ?? "", plan.plannerTitle),
personal: !draftableTitle(title ?? "", plan),
});
},
refine: (plan, job) => card(plan, job, "refine"),
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,12 @@ async function receive(ws: Socket, raw: string): Promise<void> {
if (room.plan) Service.awareness(room.plan, ws, frame);
return;

case "plan:titling":
if (room.plan && typeof frame.active === "boolean") {
Service.titling(room.plan, ws, { active: frame.active, edited: frame.edited === true });
}
return;

case "plan:close":
if (room.plan) Service.departed(room.plan, ws);
return;
Expand Down
17 changes: 15 additions & 2 deletions apps/server/src/plan/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,13 @@ export function mark(target: Document): void {
}

export type Applied =
| { ok: true; seq: number; researchProjections: ResearchProjectionChange[] }
| {
ok: true;
seq: number;
researchProjections: ResearchProjectionChange[];
/** The batch changed the title's text. */
retitled?: true;
}
| { ok: false; issues: string[] };

/**
Expand All @@ -383,6 +389,7 @@ export async function apply(
() => $getNodeByKey(target.titleKey!)?.getTextContent(),
);
let researchProjections: ResearchProjectionChange[] = [];
let retitled = false;
let held = open > 0 ? Buffer.byteLength(project(target)) : 0;
try {
for (let update of updates) Y.applyUpdate(target.doc, update, REMOTE);
Expand All @@ -400,6 +407,7 @@ export async function apply(
: target.editor.getEditorState().read(
() => $getNodeByKey(target.titleKey!)?.getTextContent(),
);
retitled = afterTitle !== beforeTitle;
if (
afterTitle !== undefined && afterTitle !== beforeTitle && !validDocumentTitleText(afterTitle)
) {
Expand Down Expand Up @@ -472,7 +480,12 @@ export async function apply(
}

target.seq += updates.length;
return { ok: true, seq: target.seq, researchProjections };
return {
ok: true,
seq: target.seq,
researchProjections,
...(retitled ? { retitled: true as const } : {}),
};
}

/**
Expand Down
Loading
Loading