feat(core,server,web,cli): mid-run steering messages and square-bracket markers (#63)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
abd5b13d52
commit
7584171482
81 files changed
+3139
-930
No files matched your search
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* agent-mentions.ts unit tests: @ mention matching (cursor prefix / boundary
|
||||
* rules), candidate filtering, send-time parsing of a leading @ mention,
|
||||
* generation of the first new-conversation <handoff_from> origin block,
|
||||
* <scheduled_task> origin block parsing, and the /model switch's
|
||||
* <model_switch_from> origin block round-trip.
|
||||
* and the re-export wiring for the origin marker blocks (their own semantics — both forms,
|
||||
* anchoring, legacy compat — are covered by packages/core/test/markers.test.ts).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentSummary } from "@prismshadow/penguin-server/api";
|
||||
@@ -116,136 +115,27 @@ describe("splitLeadingMention (send-time parsing of a leading @)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handoffMessage (the first new-conversation <handoff_from> origin block)", () => {
|
||||
it("full origin: agent display name and Session title as parentheticals, Workspace on its own line", () => {
|
||||
const text = handoffMessage({
|
||||
describe("origin marker blocks are re-exported from core", () => {
|
||||
it("handoff / model-switch producers emit the square form and round-trip through the feature module", () => {
|
||||
const handoff = handoffMessage({ agentId: "default_agent", workspace: "/data/ws" });
|
||||
expect(handoff.startsWith("[handoff_from]\n")).toBe(true);
|
||||
expect(parseHandoffMessage(handoff)).toEqual({
|
||||
agentId: "default_agent",
|
||||
agentName: "General Agent",
|
||||
sessionId: "session-01ABC",
|
||||
sessionTitle: "Fix the parser",
|
||||
workspace: "/data/ws",
|
||||
});
|
||||
expect(text.startsWith("<handoff_from>\n")).toBe(true);
|
||||
expect(text.endsWith("\n</handoff_from>")).toBe(true);
|
||||
expect(text).toContain("agent: default_agent (General Agent)");
|
||||
expect(text).toContain("session: session-01ABC (Fix the parser)");
|
||||
expect(text).toContain("workspace: /data/ws");
|
||||
});
|
||||
|
||||
it("draft state (no Session) carries only the source agent; the parenthetical is omitted when the display name equals the id or is absent", () => {
|
||||
const text = handoffMessage({ agentId: "researcher", agentName: "researcher" });
|
||||
expect(text).toContain("agent: researcher\n");
|
||||
expect(text).not.toContain("session:");
|
||||
expect(text).not.toContain("workspace:");
|
||||
expect(handoffMessage({ agentId: "researcher" })).toContain("agent: researcher\n");
|
||||
});
|
||||
|
||||
it("the parenthetical is omitted when the Session title is absent", () => {
|
||||
const text = handoffMessage({
|
||||
agentId: "default_agent",
|
||||
sessionId: "session-01ABC",
|
||||
workspace: "/data/ws",
|
||||
});
|
||||
expect(text).toContain("session: session-01ABC\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHandoffMessage (reverse-parses the origin block, driving the handoff notice rendering)", () => {
|
||||
it("round-trips with handoffMessage (including a Session title containing parentheses)", () => {
|
||||
const origin = {
|
||||
agentId: "default_agent",
|
||||
agentName: "General Agent",
|
||||
sessionId: "session-01ABC",
|
||||
sessionTitle: "Fix (the) parser",
|
||||
workspace: "/data/ws",
|
||||
};
|
||||
expect(parseHandoffMessage(handoffMessage(origin))).toEqual(origin);
|
||||
});
|
||||
|
||||
it("minimal origin (agent only) round-trips; with display name equal to the id, generation omits the parenthetical and parsing restores only the id", () => {
|
||||
expect(parseHandoffMessage(handoffMessage({ agentId: "researcher" }))).toEqual({
|
||||
agentId: "researcher",
|
||||
});
|
||||
expect(
|
||||
parseHandoffMessage(handoffMessage({ agentId: "researcher", agentName: "researcher" })),
|
||||
).toEqual({ agentId: "researcher" });
|
||||
});
|
||||
|
||||
it("plain messages and longer messages merely containing an origin block are not misdetected", () => {
|
||||
expect(parseHandoffMessage("hello @default_agent")).toBeNull();
|
||||
expect(parseHandoffMessage(`before\n${handoffMessage({ agentId: "a1" })}`)).toBeNull();
|
||||
expect(parseHandoffMessage(`${handoffMessage({ agentId: "a1" })}\nafter`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseScheduledMessage (parses the scheduled-task origin block, driving the schedule notice rendering)", () => {
|
||||
/** Shape of the server-side scheduledMessage output (scheduler.ts). */
|
||||
const scheduled = (name: string, firedAt: string, prompt: string): string =>
|
||||
[
|
||||
"<scheduled_task>",
|
||||
"This message was sent automatically by a scheduled task; its origin is listed below and the task prompt follows.",
|
||||
`schedule: ${name}`,
|
||||
`fired_at: ${firedAt}`,
|
||||
"</scheduled_task>",
|
||||
"",
|
||||
prompt,
|
||||
].join("\n");
|
||||
|
||||
it("parses the origin block and returns the remaining body (rendered as usual)", () => {
|
||||
const text = scheduled(
|
||||
"daily_report",
|
||||
"2026-07-16T01:00:00.000Z",
|
||||
"Write the daily report\nattach yesterday's data",
|
||||
);
|
||||
expect(parseScheduledMessage(text)).toEqual({
|
||||
origin: { name: "daily_report", firedAt: "2026-07-16T01:00:00.000Z" },
|
||||
rest: "Write the daily report\nattach yesterday's data",
|
||||
const switched = modelSwitchMessage({ sessionId: "session-01", tracePath: "/t.jsonl" });
|
||||
expect(switched.startsWith("[model_switch_from]\n")).toBe(true);
|
||||
expect(parseModelSwitchMessage(switched)).toEqual({
|
||||
sessionId: "session-01",
|
||||
tracePath: "/t.jsonl",
|
||||
});
|
||||
});
|
||||
|
||||
it("plain messages and mid-text blocks are not misdetected; a missing task name means it is not an origin block", () => {
|
||||
expect(parseScheduledMessage("hello")).toBeNull();
|
||||
expect(
|
||||
parseScheduledMessage(`preamble\n${scheduled("t", "2026-01-01T00:00:00Z", "p")}`),
|
||||
).toBeNull();
|
||||
it("the scheduled-task parser (server-produced block) is reachable here for the banner", () => {
|
||||
expect(
|
||||
parseScheduledMessage(
|
||||
"<scheduled_task>\nfired_at: 2026-01-01T00:00:00Z\n</scheduled_task>\n\np",
|
||||
"[scheduled_task]\nschedule: daily\nfired_at: 2026-01-01T00:00:00Z\n[/scheduled_task]\n\nbody",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelSwitchMessage / parseModelSwitchMessage (the /model switch origin block)", () => {
|
||||
it("round-trips a full origin (session title with parentheses, trace path, workspace, previous pair)", () => {
|
||||
const origin = {
|
||||
sessionId: "session-2026-07-24-10-00-00-abcdef01",
|
||||
sessionTitle: "Fix (the) parser",
|
||||
tracePath: "/data/p/agents/a/traces/2026-07-24/session-x_001.jsonl",
|
||||
workspace: "/data/ws",
|
||||
prevProvider: "deepseek",
|
||||
prevModelId: "deepseek-v4-pro",
|
||||
};
|
||||
expect(parseModelSwitchMessage(modelSwitchMessage(origin))).toEqual(origin);
|
||||
});
|
||||
|
||||
it("minimal origin (session id only) round-trips; optional lines are omitted from the block", () => {
|
||||
const text = modelSwitchMessage({ sessionId: "session-01" });
|
||||
expect(text).not.toContain("trace:");
|
||||
expect(text).not.toContain("workspace:");
|
||||
expect(text).not.toContain("previous_model:");
|
||||
expect(parseModelSwitchMessage(text)).toEqual({ sessionId: "session-01" });
|
||||
});
|
||||
|
||||
it("plain messages and longer messages merely containing the block are not misdetected", () => {
|
||||
expect(parseModelSwitchMessage("hello /model")).toBeNull();
|
||||
expect(
|
||||
parseModelSwitchMessage(`before\n${modelSwitchMessage({ sessionId: "s1" })}`),
|
||||
).toBeNull();
|
||||
expect(parseModelSwitchMessage(`${modelSwitchMessage({ sessionId: "s1" })}\nafter`)).toBeNull();
|
||||
// A block without a session line is not an origin block.
|
||||
expect(
|
||||
parseModelSwitchMessage("<model_switch_from>\ntrace: /t.jsonl\n</model_switch_from>"),
|
||||
).toBeNull();
|
||||
).toEqual({ origin: { name: "daily", firedAt: "2026-01-01T00:00:00Z" }, rest: "body" });
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ function session(
|
||||
createdAt,
|
||||
status: "idle",
|
||||
pendingApprovalCount: 0,
|
||||
pendingFollowUpCount: 0,
|
||||
hasTrace: false,
|
||||
archived: over.archived ?? false,
|
||||
...(over.source !== undefined ? { source: over.source } : {}),
|
||||
|
||||
@@ -1,69 +1,24 @@
|
||||
/**
|
||||
* skill-use.ts unit tests: building and reverse-parsing the `<use_skills>`
|
||||
* block (global convention format) —
|
||||
* round-trip consistency, an empty skill list omits the block, and only a
|
||||
* block at the start of the message is recognized.
|
||||
* skill-use.ts unit test: the feature module re-exports the `[use_skills]` marker producer
|
||||
* and parser from core's marker module — the block's own semantics (both forms, anchoring,
|
||||
* legacy compat) are covered by packages/core/test/markers.test.ts. What matters here is
|
||||
* that the wiring is intact for the components importing them from this module.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSkillsMessage, parseSkillsMessage } from "../src/features/chat/skill-use";
|
||||
|
||||
describe("buildSkillsMessage (<use_skills> block + body)", () => {
|
||||
it("single skill: block (tag line, skills: line, closing tag) + blank line + body", () => {
|
||||
expect(buildSkillsMessage(["penguin-sdk"], "write me a demo")).toBe(
|
||||
"<use_skills>\nskills: penguin-sdk\n</use_skills>\n\nwrite me a demo",
|
||||
);
|
||||
});
|
||||
|
||||
it("multiple skills: the skills: line is comma + space separated", () => {
|
||||
expect(buildSkillsMessage(["agent-creation", "penguin-cli"], "x")).toBe(
|
||||
"<use_skills>\nskills: agent-creation, penguin-cli\n</use_skills>\n\nx",
|
||||
);
|
||||
});
|
||||
|
||||
it("an empty list adds no block: the body returns unchanged", () => {
|
||||
expect(buildSkillsMessage([], "hello")).toBe("hello");
|
||||
expect(buildSkillsMessage([], "")).toBe("");
|
||||
});
|
||||
|
||||
it("block only without a body, no trailing blank line", () => {
|
||||
expect(buildSkillsMessage(["solo"], "")).toBe("<use_skills>\nskills: solo\n</use_skills>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSkillsMessage (reverse parsing, driving the use-skills banner)", () => {
|
||||
it("round-trips with build (multiple skills, multi-line body)", () => {
|
||||
describe("skill-use re-exports the core [use_skills] marker helpers", () => {
|
||||
it("builds the square-bracket block and parses it back (round-trip through the feature module)", () => {
|
||||
const text = buildSkillsMessage(["agent-creation", "penguin-cli"], "use them\nline2");
|
||||
expect(text.startsWith("[use_skills]\n")).toBe(true);
|
||||
expect(parseSkillsMessage(text)).toEqual({
|
||||
skills: ["agent-creation", "penguin-cli"],
|
||||
rest: "use them\nline2",
|
||||
});
|
||||
});
|
||||
|
||||
it("a body containing < characters (including tag-like text) is unaffected", () => {
|
||||
const body =
|
||||
"compare a < b and keep <tag> markers\n<use_skills> appearing here is still just body text";
|
||||
expect(parseSkillsMessage(buildSkillsMessage(["x-skill"], body))).toEqual({
|
||||
skills: ["x-skill"],
|
||||
rest: body,
|
||||
});
|
||||
});
|
||||
|
||||
it("block only, no body: rest is an empty string", () => {
|
||||
expect(parseSkillsMessage("<use_skills>\nskills: solo\n</use_skills>")).toEqual({
|
||||
skills: ["solo"],
|
||||
rest: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("a block not at the start of the message (preceding body/whitespace) does not parse; plain messages do not parse", () => {
|
||||
const block = buildSkillsMessage(["a"], "b");
|
||||
expect(parseSkillsMessage(`hi\n${block}`)).toBeNull();
|
||||
expect(parseSkillsMessage(` ${block}`)).toBeNull();
|
||||
expect(parseSkillsMessage("plain text mentioning <use_skills> only")).toBeNull();
|
||||
});
|
||||
|
||||
it("a blank skills: line means it is not a skills block", () => {
|
||||
expect(parseSkillsMessage("<use_skills>\nskills: \n</use_skills>\n\nbody")).toBeNull();
|
||||
expect(parseSkillsMessage("<use_skills>\nskills: , ,\n</use_skills>")).toBeNull();
|
||||
it("an empty selection leaves the body untouched; a non-block message does not parse", () => {
|
||||
expect(buildSkillsMessage([], "hello")).toBe("hello");
|
||||
expect(parseSkillsMessage("plain text mentioning [use_skills] only")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
TaskStatsItem,
|
||||
ThinkingItem,
|
||||
ToolCallItem,
|
||||
UserSteeringItem,
|
||||
UserTextItem,
|
||||
} from "../src/lib/omni/stream-model";
|
||||
|
||||
@@ -556,7 +557,7 @@ describe("Task segmentation and stats triggering", () => {
|
||||
});
|
||||
|
||||
it("round end takes the last request_end: the next round's injection arriving after it does not inflate this round's elapsed time", () => {
|
||||
// During history rebuild, the compaction summary `<context_summary>` is written alongside the
|
||||
// During history rebuild, the compaction summary `[context_summary]` is written alongside the
|
||||
// next round, with its timestamp landing in that next round — it arrives while the previous
|
||||
// round is still open. If round-end took the latest message seen, this injection would
|
||||
// artificially inflate the previous round's elapsed time (the old bug where elapsed grows
|
||||
@@ -568,7 +569,7 @@ describe("Task segmentation and stats triggering", () => {
|
||||
at(tokenUsage(out(100), out(100)), "2026-07-05T00:00:02.000Z"),
|
||||
at(requestEnd("completed"), "2026-07-05T00:00:03.000Z"), // this round's last request_end
|
||||
// Next round's summary injection, timestamped much later; it arrives while this round hasn't closed yet.
|
||||
at(userText("<context_summary>\nsummary\n</context_summary>"), "2026-07-05T00:00:50.000Z"),
|
||||
at(userText("[context_summary]\nsummary\n[/context_summary]"), "2026-07-05T00:00:50.000Z"),
|
||||
at(userText("next question"), "2026-07-05T00:01:00.000Z"), // startTask: closes the previous round
|
||||
]);
|
||||
finalizeHistory(m);
|
||||
@@ -793,7 +794,7 @@ describe("compaction attribution by position: in-round counts toward the round,
|
||||
compactionEnd({ reason: "manual", mode: "summarize", status: "completed" }),
|
||||
"2026-07-05T00:00:33.000Z",
|
||||
),
|
||||
at(userText("<context_summary>\nsummary\n</context_summary>"), "2026-07-05T00:00:33.000Z"),
|
||||
at(userText("[context_summary]\nsummary\n[/context_summary]"), "2026-07-05T00:00:33.000Z"),
|
||||
]);
|
||||
finalizeHistory(m);
|
||||
const stats = items(m).find((i) => i.kind === "task_stats") as TaskStatsItem;
|
||||
@@ -818,7 +819,7 @@ describe("compaction-internal messages (#17: history rebuild aligned with the li
|
||||
),
|
||||
// Compaction prompt (user text) and the compaction request's summary output (assistant text): internal messages.
|
||||
at(userText("Summarize the above (compaction prompt)"), "2026-07-05T00:00:06.100Z"),
|
||||
at(assistantText("<summary>summary content</summary>"), "2026-07-05T00:00:09.000Z"),
|
||||
at(assistantText("[summary]summary content[/summary]"), "2026-07-05T00:00:09.000Z"),
|
||||
at(tokenUsage(counts(11000), counts(1000)), "2026-07-05T00:00:09.500Z"),
|
||||
at(
|
||||
compactionEnd({ reason: "context", mode: "summarize", status: "completed" }),
|
||||
@@ -826,7 +827,7 @@ describe("compaction-internal messages (#17: history rebuild aligned with the li
|
||||
),
|
||||
// Summary injected at the start of the new context file: internal input.
|
||||
at(
|
||||
userText("<context_summary>\nsummary content\n</context_summary>"),
|
||||
userText("[context_summary]\nsummary content\n[/context_summary]"),
|
||||
"2026-07-05T00:00:10.500Z",
|
||||
),
|
||||
at(userText("next question"), "2026-07-05T00:01:00.000Z"),
|
||||
@@ -870,14 +871,14 @@ describe("compaction-internal messages (#17: history rebuild aligned with the li
|
||||
"2026-07-05T00:00:06.000Z",
|
||||
),
|
||||
at(userText("compaction prompt"), "2026-07-05T00:00:06.100Z"),
|
||||
at(assistantText("<summary>progress summary</summary>"), "2026-07-05T00:00:08.000Z"),
|
||||
at(assistantText("[summary]progress summary[/summary]"), "2026-07-05T00:00:08.000Z"),
|
||||
at(
|
||||
compactionEnd({ reason: "context", mode: "summarize", status: "completed" }),
|
||||
"2026-07-05T00:00:09.000Z",
|
||||
),
|
||||
// Mid-round compaction: the summary is written as the new context's first input, after end.
|
||||
at(
|
||||
userText("<context_summary>\nprogress summary\n</context_summary>"),
|
||||
userText("[context_summary]\nprogress summary\n[/context_summary]"),
|
||||
"2026-07-05T00:00:09.500Z",
|
||||
),
|
||||
at(assistantText("continue fixing and finish"), "2026-07-05T00:00:12.000Z"),
|
||||
@@ -889,6 +890,55 @@ describe("compaction-internal messages (#17: history rebuild aligned with the li
|
||||
expect((items(m)[2] as AssistantTextItem).text).toBe("continue fixing and finish");
|
||||
expect(items(m).filter((i) => i.kind === "user_text")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("legacy <context_summary> prefix (old Traces) is still treated as internal input, not a user bubble", () => {
|
||||
// Old Traces contain the angle-bracket form; re-rendering them must keep hiding the
|
||||
// summary injection exactly like the current [context_summary] form.
|
||||
const m = createStreamModel();
|
||||
pushMessages(m, [
|
||||
at(
|
||||
userText("<context_summary>\nold summary\n</context_summary>"),
|
||||
"2026-07-05T00:00:00.000Z",
|
||||
),
|
||||
at(userText("continue the task"), "2026-07-05T00:00:01.000Z"),
|
||||
at(assistantText("resuming"), "2026-07-05T00:00:02.000Z"),
|
||||
]);
|
||||
finalizeHistory(m);
|
||||
const users = items(m).filter((i) => i.kind === "user_text") as UserTextItem[];
|
||||
expect(users.map((u) => u.text)).toEqual(["continue the task"]);
|
||||
});
|
||||
|
||||
it("[user_steering] user text renders as a steering chip inside the running Task — it never starts a new Task", () => {
|
||||
// Mid-run steering is delivered as a standalone [user_steering] user message: it must
|
||||
// stay inside the current Task (one stats row, one task) but render in-flow, marker
|
||||
// stripped, as a user_steering item.
|
||||
const m = createStreamModel();
|
||||
pushMessages(m, [
|
||||
at(userText("fix the bug"), "2026-07-05T00:00:00.000Z"),
|
||||
at(assistantText("looking"), "2026-07-05T00:00:02.000Z"),
|
||||
at(tokenUsage(counts(100), counts(100)), "2026-07-05T00:00:03.000Z"),
|
||||
at(
|
||||
userText("[user_steering]\nalso check the tests\n[/user_steering]"),
|
||||
"2026-07-05T00:00:04.000Z",
|
||||
),
|
||||
at(assistantText("checking the tests too"), "2026-07-05T00:00:06.000Z"),
|
||||
at(tokenUsage(counts(200), counts(100)), "2026-07-05T00:00:07.000Z"),
|
||||
]);
|
||||
finalizeHistory(m);
|
||||
const kinds = items(m).map((i) => i.kind);
|
||||
// One Task: user, assistant, steering chip, assistant, one stats row at the end.
|
||||
expect(kinds).toEqual([
|
||||
"user_text",
|
||||
"assistant_text",
|
||||
"user_steering",
|
||||
"assistant_text",
|
||||
"task_stats",
|
||||
]);
|
||||
const steering = items(m).find((i) => i.kind === "user_steering") as UserSteeringItem;
|
||||
expect(steering.text).toBe("also check the tests");
|
||||
// Only the real prompt is a user bubble.
|
||||
expect(items(m).filter((i) => i.kind === "user_text")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live close-out elapsed (#5/#20: mid-join takes the message-timestamp lower bound)", () => {
|
||||
|
||||
Reference in new issue
Block a user