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:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-07-26 23:01:18 +08:00
1 parent abd5b13d52
commit 7584171482
81 files changed
+3139 -930

No files matched your search

+15 -125
View File
@@ -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 } : {}),
+10 -55
View File
@@ -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();
});
});
+57 -7
View File
@@ -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)", () => {