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
8 changes: 4 additions & 4 deletions apps/hub/src/config/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import { SPECIALIST_ENDED_LEAD } from "./specialist-ended.ts";
const RESUME_CONTEXT_LEAD =
"Background: this project's conversation continues from an earlier session. This is where it stood, not a new request.";
const RESUME_CONTEXT_END = "\n\nThe person's new message:\n\n";
// Mail delivery turns every line break into CRLF.
const DELIVERED_CONTEXT_END = /\r?\n\r?\nThe person's new message:\r?\n\r?\n/;

// Hubs before the held resume context mailed this opening on the person's behalf.
const LEGACY_RESUME_OPENING = "I'm picking this project back up.";
Expand Down Expand Up @@ -77,6 +75,8 @@ export function approvalDecisionLine({
export function withoutResumeContext(text: string): string | undefined {
if (text.startsWith(LEGACY_RESUME_OPENING)) return undefined;
if (!text.startsWith(RESUME_CONTEXT_LEAD)) return text;
const end = DELIVERED_CONTEXT_END.exec(text);
return end === null ? text : text.slice(end.index + end[0].length);
const end = text.indexOf(RESUME_CONTEXT_END);
if (end !== -1) return text.slice(end + RESUME_CONTEXT_END.length);
// Delivered text is trimmed, so a blank new message leaves the marker without its blank line.
return text.endsWith(RESUME_CONTEXT_END.trimEnd()) ? "" : text;
}
10 changes: 9 additions & 1 deletion apps/hub/src/config/specialist-ended.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ const PERSON_LINES: Record<SpawnedRole, Record<Outcome, string>> = {
},
};

const NOTICE_LINES = new Set(
Object.values(PERSON_LINES).flatMap((lines) => Object.values(lines)),
);

const TASKS: Record<SpawnedRole, (kind: string | undefined) => string> = {
writer: (kind) => `The writer writing the ${kind ?? "assigned"} document`,
reviewer: (kind) =>
Expand All @@ -59,5 +63,9 @@ export function specialistEndedMessage(ended: {
export function personTurnText(text: string): string {
if (!text.startsWith(SPECIALIST_ENDED_LEAD)) return text;
const end = text.indexOf(COORDINATOR_ONLY);
return end === -1 ? text : text.slice(0, end);
if (end === -1) return text;
// A person may type the same lead; only the hub's own notice lines are cut.
return NOTICE_LINES.has(text.slice(SPECIALIST_ENDED_LEAD.length, end))
? text.slice(0, end)
: text;
}
9 changes: 2 additions & 7 deletions apps/hub/src/mail/conversation-threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { listMail } from "@intx/storage-isogit/node";
import { isRunAddress, parseRunAddress } from "@intx/types/agent-address";
import { SPECIALIST_RESULT_TYPE } from "@corbits-builder/schemas/native-specialist";
import {
clippedTurnText,
maxConversationTurns,
type ConversationTextTurn,
type ConversationThread,
Expand All @@ -20,9 +21,6 @@ import { requestErrorMetadata } from "../request-error.ts";

const log = getLogger(["corbits-builder", "hub", "conversation-threads"]);

// ConversationEvents rejects a longer turn than this.
export const maxTurnText = 20000;

type ThreadMail = {
ordinal: number;
messageId: string;
Expand Down Expand Up @@ -104,10 +102,7 @@ export async function readCoordinatorMailThreads(input: {
);
if (from === undefined || part === undefined || !Number.isFinite(at))
continue;
const text = new TextDecoder()
.decode(part.content)
.trim()
.slice(0, maxTurnText);
const text = clippedTurnText(new TextDecoder().decode(part.content).trim());
const thread = mail.get(entry.threadId) ?? {
participants: new Set(),
messages: [],
Expand Down
78 changes: 76 additions & 2 deletions apps/hub/src/mail/conversation-turns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "bun";
import { resumeMessage } from "../config/resume.ts";
import { specialistEndedMessage } from "../config/specialist-ended.ts";
import { readDurableConversationTurns } from "./conversation-turns.ts";

const person = "b65b0e2f4c4a444db11b7321dc413964@sunny-harbor-panda.localhost";
Expand All @@ -14,15 +16,20 @@ afterEach(async () => {
);
});

// A run repo whose committed coordinator mirror holds these inbound messages, as Interchange stores them.
// A run repo whose committed coordinator mirror holds these inbound messages, with the CRLF line breaks mail delivers.
async function runRepo(inbound: { from: string; text: string }[]) {
const dir = await mkdtemp(join(tmpdir(), "sb-conversation-turns-"));
dirs.push(dir);
const wal = join(dir, "agent-state", "coordinate", "wal", "0");
await mkdir(wal, { recursive: true });
const turns = inbound.map(({ from, text }, at) => ({
role: "user",
content: [{ type: "text", text: `[From: ${from}]\n\n${text}` }],
content: [
{
type: "text",
text: `[From: ${from}]\n\n${text.replaceAll("\n", "\r\n")}`,
},
],
timestamp: at + 1,
}));
const metadata = {
Expand Down Expand Up @@ -97,3 +104,70 @@ test("resume turns read each approval decision as a plain line", async () => {
{ role: "person", text: "Approved a request." },
]);
});

test("a person's words read back with the line breaks they typed", async () => {
const typed =
'I dont get this one bit\n\nOn "Constraints" v1:\n\n> Use $5 per month';
const dir = await runRepo([
{ from: person, text: typed },
{ from: person, text: "Sure" },
]);
expect(await personTexts(dir, "note")).toEqual([
{ role: "person", text: typed },
{ role: "person", text: "Sure" },
]);
});

test("a specialist-ended notice reads back without its coordinator-only part", async () => {
const notice = specialistEndedMessage({
role: "writer",
status: "failed",
reason: { message: "provider refused the request" },
});
const dir = await runRepo([{ from: person, text: notice }]);
expect(await personTexts(dir, "note")).toEqual([
{
role: "person",
text: "Status update: The writer stopped before finishing.",
},
]);
});

test("a resumed message reads back without its held context", async () => {
const context =
"Background: this project's conversation continues from an earlier session. This is where it stood, not a new request.\n\nEarlier conversation, oldest first:\n\nPerson: hi";
const dir = await runRepo([
{ from: person, text: resumeMessage(context, "Line one\n\nLine two") },
]);
expect(await personTexts(dir, "note")).toEqual([
{ role: "person", text: "Line one\n\nLine two" },
]);
});

test("a person's own message with the notice lead reads back whole", async () => {
const typed = "Status update: done\n\nmore details";
const dir = await runRepo([{ from: person, text: typed }]);
expect(await personTexts(dir, "note")).toEqual([
{ role: "person", text: typed },
]);
});

test("a resumed message with no words leaves no turn", async () => {
const context =
"Background: this project's conversation continues from an earlier session. This is where it stood, not a new request.\n\nEarlier conversation, oldest first:\n\nPerson: hi";
const dir = await runRepo([
{ from: person, text: resumeMessage(context, " ") },
]);
expect(await personTexts(dir, "note")).toEqual([]);
});

test("a resumed message is read after the separator, not at a quoted line that starts like it", async () => {
const context =
"Background: this project's conversation continues from an earlier session. This is where it stood, not a new request.\n\nEarlier conversation, oldest first:\n\nPerson: hi\n\nThe person's new message: quoted earlier";
const dir = await runRepo([
{ from: person, text: resumeMessage(context, "Real words") },
]);
expect(await personTexts(dir, "note")).toEqual([
{ role: "person", text: "Real words" },
]);
});
24 changes: 13 additions & 11 deletions apps/hub/src/mail/conversation-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { isRunAddress } from "@intx/types/agent-address";
import { ApprovalDecision } from "@intx/types/signals";
import { type } from "arktype";
import {
clippedTurnText,
deliveredText,
maxConversationTurns,
type ConversationTextTurn,
type ConversationThread,
Expand All @@ -19,10 +21,7 @@ import {
withoutResumeContext,
} from "../config/resume.ts";
import { personTurnText } from "../config/specialist-ended.ts";
import {
maxTurnText,
readCoordinatorMailThreads,
} from "./conversation-threads.ts";
import { readCoordinatorMailThreads } from "./conversation-threads.ts";
import { withCommittedAgentState } from "./committed-agent-state.ts";
import type { CoordinatorRepoDirSource } from "./coordinator-question.ts";
import { getLogger } from "@intx/log";
Expand Down Expand Up @@ -53,9 +52,6 @@ const textFromContent = (content: unknown): string | undefined => {
return text.trim() === "" ? undefined : text;
};

const clipTurn = (text: string) =>
text.length > maxTurnText ? text.slice(0, maxTurnText) : text;

// The durable copy of a person's mail keeps the routing line the runtime
// wrapped around their words. That line is not part of what they wrote.
const inboundEnvelope =
Expand Down Expand Up @@ -103,7 +99,7 @@ function mapMirrorConversationTurns(
const raw = textFromContent(turn["content"]);
if (raw === undefined) continue;
if (role === "assistant") {
mapped.push({ role, text: clipTurn(raw), at });
mapped.push({ role, text: clippedTurnText(raw), at });
continue;
}
const from = inboundEnvelope.exec(raw)?.[1];
Expand All @@ -114,12 +110,18 @@ function mapMirrorConversationTurns(
) {
const text = approvalText(raw.replace(inboundEnvelope, ""), decisions);
if (text !== undefined && text.trim() !== "")
mapped.push({ role: "person", text: clipTurn(text), at });
mapped.push({ role: "person", text: clippedTurnText(text), at });
continue;
}
const text = withoutResumeContext(raw.replace(inboundEnvelope, ""));
const text = withoutResumeContext(
deliveredText(raw.replace(inboundEnvelope, "")),
);
Comment thread
TheGreatAxios marked this conversation as resolved.
Resolved
if (text === undefined || text.trim() === "") continue;
mapped.push({ role: "person", text: clipTurn(personTurnText(text)), at });
mapped.push({
role: "person",
text: clippedTurnText(personTurnText(text)),
at,
});
}
mapped.sort((left, right) => left.at - right.at);
return mapped.length > maxConversationTurns
Expand Down
Loading