feat(web): replace the @ mention with an /agent command, and stage both switches until send (#122)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* agent-handoff.ts unit tests: the `/agent` picker's candidate filtering, the staged-switch
|
||||
* send decision (`/agent` and `/model` only act on Enter — this is where "act on what, and
|
||||
* when" is pinned down), 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";
|
||||
import {
|
||||
filterAgents,
|
||||
handoffMessage,
|
||||
modelSwitchMessage,
|
||||
parseHandoffMessage,
|
||||
parseModelSwitchMessage,
|
||||
parseScheduledMessage,
|
||||
stagedSendRoute,
|
||||
} from "../src/features/chat/agent-handoff";
|
||||
|
||||
const agent = (agentId: string, name?: string): AgentSummary => ({
|
||||
agentId,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
activeSessionCount: 0,
|
||||
sessionCount: 0,
|
||||
sessionActivity: [],
|
||||
toolCount: 0,
|
||||
version: 1,
|
||||
vaultKeyCount: 0,
|
||||
scheduleCount: 0,
|
||||
skillCount: 0,
|
||||
});
|
||||
|
||||
const AGENTS: AgentSummary[] = [
|
||||
agent("default_agent", "General Agent"),
|
||||
agent("agent_creator", "Agent Creator"),
|
||||
agent("agent_optimizer", "Agent Optimizer"),
|
||||
agent("researcher"),
|
||||
];
|
||||
|
||||
describe("filterAgents (the /agent picker's search box)", () => {
|
||||
it("an empty or whitespace-only query returns all candidates", () => {
|
||||
expect(filterAgents(AGENTS, "")).toHaveLength(4);
|
||||
expect(filterAgents(AGENTS, " ")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("filters by agentId, case-insensitively", () => {
|
||||
expect(filterAgents(AGENTS, "agent_").map((a) => a.agentId)).toEqual([
|
||||
"agent_creator",
|
||||
"agent_optimizer",
|
||||
]);
|
||||
expect(filterAgents(AGENTS, "RES").map((a) => a.agentId)).toEqual(["researcher"]);
|
||||
});
|
||||
|
||||
it("display names match too", () => {
|
||||
expect(filterAgents(AGENTS, "General").map((a) => a.agentId)).toEqual(["default_agent"]);
|
||||
});
|
||||
|
||||
it("matches anywhere in the id/name, not only at its start (substring rule, like the model search box)", () => {
|
||||
expect(filterAgents(AGENTS, "creator").map((a) => a.agentId)).toEqual(["agent_creator"]);
|
||||
expect(filterAgents(AGENTS, "optim").map((a) => a.agentId)).toEqual(["agent_optimizer"]);
|
||||
});
|
||||
|
||||
it("nothing matches: the picker gets an empty list (and renders its no-match copy)", () => {
|
||||
expect(filterAgents(AGENTS, "nobody")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stagedSendRoute (what a staged /agent or /model chip does on send)", () => {
|
||||
/** Defaults: an idle active session with nothing staged — the composer's ordinary state. */
|
||||
const route = (over: Partial<Parameters<typeof stagedSendRoute>[0]> = {}) =>
|
||||
stagedSendRoute({
|
||||
handoffTarget: false,
|
||||
pendingModel: false,
|
||||
canSwitchModel: true,
|
||||
sessionBusy: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("nothing staged: the message is an ordinary post", () => {
|
||||
expect(route()).toBe("post");
|
||||
// Even mid-run: steering and the follow-up queue are both plain posts.
|
||||
expect(route({ sessionBusy: true })).toBe("post");
|
||||
});
|
||||
|
||||
it("a staged handoff opens the new chat regardless of what this session is doing", () => {
|
||||
// The handoff neither reads nor writes the running session — it is safe mid-run, and was
|
||||
// already allowed there before the switches became staged.
|
||||
expect(route({ handoffTarget: true })).toBe("handoff");
|
||||
expect(route({ handoffTarget: true, sessionBusy: true })).toBe("handoff");
|
||||
});
|
||||
|
||||
it("a staged model fork goes out only while this session is idle", () => {
|
||||
expect(route({ pendingModel: true })).toBe("model");
|
||||
// Running / compacting: the fork branches off a Trace still being appended to, and the run
|
||||
// may have started from outside the composer (queued follow-up, schedule, another tab).
|
||||
expect(route({ pendingModel: true, sessionBusy: true })).toBe("blocked");
|
||||
});
|
||||
|
||||
it("blocked never degrades into a plain post: the message must not land in the session being left", () => {
|
||||
// The distinction that matters — "blocked" is not "post". Falling through would deliver the
|
||||
// draft to the very session the user staged a switch away from.
|
||||
expect(route({ pendingModel: true, sessionBusy: true })).not.toBe("post");
|
||||
});
|
||||
|
||||
it("where a fork is impossible at all (the draft page has no session to fork), the chip cannot block a send", () => {
|
||||
expect(route({ pendingModel: true, canSwitchModel: false })).toBe("post");
|
||||
expect(route({ pendingModel: true, canSwitchModel: false, sessionBusy: true })).toBe("post");
|
||||
});
|
||||
|
||||
it("should both chips ever coexist, the handoff wins — it is the one that touches nothing here", () => {
|
||||
expect(route({ handoffTarget: true, pendingModel: true })).toBe("handoff");
|
||||
expect(route({ handoffTarget: true, pendingModel: true, sessionBusy: true })).toBe("handoff");
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
workspace: "/data/ws",
|
||||
});
|
||||
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("the scheduled-task parser (server-produced block) is reachable here for the banner", () => {
|
||||
expect(
|
||||
parseScheduledMessage(
|
||||
"[scheduled_task]\nschedule: daily\nfired_at: 2026-01-01T00:00:00Z\n[/scheduled_task]\n\nbody",
|
||||
),
|
||||
).toEqual({ origin: { name: "daily", firedAt: "2026-01-01T00:00:00Z" }, rest: "body" });
|
||||
});
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* agent-mentions.ts unit tests: @ mention matching (cursor prefix / boundary
|
||||
* rules), candidate filtering, send-time parsing of a leading @ mention,
|
||||
* 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";
|
||||
import {
|
||||
filterAgents,
|
||||
handoffMessage,
|
||||
matchMention,
|
||||
modelSwitchMessage,
|
||||
parseHandoffMessage,
|
||||
parseModelSwitchMessage,
|
||||
parseScheduledMessage,
|
||||
splitLeadingMention,
|
||||
} from "../src/features/chat/agent-mentions";
|
||||
|
||||
const agent = (agentId: string, name?: string): AgentSummary => ({
|
||||
agentId,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
activeSessionCount: 0,
|
||||
sessionCount: 0,
|
||||
sessionActivity: [],
|
||||
toolCount: 0,
|
||||
version: 1,
|
||||
vaultKeyCount: 0,
|
||||
scheduleCount: 0,
|
||||
skillCount: 0,
|
||||
});
|
||||
|
||||
const AGENTS: AgentSummary[] = [
|
||||
agent("default_agent", "General Agent"),
|
||||
agent("agent_creator", "Agent Creator"),
|
||||
agent("agent_optimizer", "Agent Optimizer"),
|
||||
agent("researcher"),
|
||||
];
|
||||
|
||||
describe("matchMention (the @ prefix being typed at the cursor)", () => {
|
||||
it("@ at text start or after whitespace triggers; query is the prefix between @ and the cursor, end == cursor when the cursor sits at token end", () => {
|
||||
expect(matchMention("@", 1)).toEqual({ start: 0, end: 1, query: "" });
|
||||
expect(matchMention("@age", 4)).toEqual({ start: 0, end: 4, query: "age" });
|
||||
expect(matchMention("fix it @res", 11)).toEqual({ start: 7, end: 11, query: "res" });
|
||||
expect(matchMention("line1\n@a", 8)).toEqual({ start: 6, end: 8, query: "a" });
|
||||
});
|
||||
|
||||
it("@ after non-whitespace (e.g. an email) does not trigger", () => {
|
||||
expect(matchMention("mail me a@b", 11)).toBeNull();
|
||||
expect(matchMention("a@", 2)).toBeNull();
|
||||
});
|
||||
|
||||
it("non-id characters (spaces etc.) between @ and the cursor do not trigger", () => {
|
||||
expect(matchMention("@agent done", 11)).toBeNull();
|
||||
expect(matchMention("no at here", 10)).toBeNull();
|
||||
});
|
||||
|
||||
it("works with the cursor mid-token: end covers the whole remainder right of the cursor (replacement leaves no tail behind)", () => {
|
||||
expect(matchMention("@agent_creator", 3)).toEqual({ start: 0, end: 14, query: "ag" });
|
||||
// The remainder stops at the token boundary (whitespace/end of text): trailing plain text is not swallowed.
|
||||
expect(matchMention("@ag rest", 3)).toEqual({ start: 0, end: 3, query: "ag" });
|
||||
expect(matchMention("see @agent_creator now", 7)).toEqual({ start: 4, end: 18, query: "ag" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterAgents (prefix filtering)", () => {
|
||||
it("an empty prefix returns all candidates", () => {
|
||||
expect(filterAgents(AGENTS, "")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("filters by agentId prefix, case-insensitive", () => {
|
||||
expect(filterAgents(AGENTS, "agent_").map((a) => a.agentId)).toEqual([
|
||||
"agent_creator",
|
||||
"agent_optimizer",
|
||||
]);
|
||||
expect(filterAgents(AGENTS, "RES").map((a) => a.agentId)).toEqual(["researcher"]);
|
||||
});
|
||||
|
||||
it("display-name prefixes match too", () => {
|
||||
expect(filterAgents(AGENTS, "General").map((a) => a.agentId)).toEqual(["default_agent"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitLeadingMention (send-time parsing of a leading @)", () => {
|
||||
it("starting with an existing agentId: splits out the target and the remaining body (leading whitespace trimmed, newlines absorbed too)", () => {
|
||||
expect(splitLeadingMention("@researcher check this", AGENTS)).toEqual({
|
||||
agent: AGENTS[3],
|
||||
rest: "check this",
|
||||
});
|
||||
expect(splitLeadingMention("@researcher\nnext line", AGENTS)).toEqual({
|
||||
agent: AGENTS[3],
|
||||
rest: "next line",
|
||||
});
|
||||
});
|
||||
|
||||
it("@ alone with no body: rest is an empty string; punctuation right after the token stays in the body", () => {
|
||||
expect(splitLeadingMention("@agent_creator", AGENTS)).toEqual({
|
||||
agent: AGENTS[1],
|
||||
rest: "",
|
||||
});
|
||||
expect(splitLeadingMention("@researcher, please", AGENTS)).toEqual({
|
||||
agent: AGENTS[3],
|
||||
rest: ", please",
|
||||
});
|
||||
});
|
||||
|
||||
it("the id is the longest [\\w-]+ run, matched exactly: overlong/unknown tokens do not count (@foo2 is not a mention of foo)", () => {
|
||||
expect(splitLeadingMention("@agent_creator2 x", AGENTS)).toBeNull();
|
||||
expect(splitLeadingMention("@nobody x", AGENTS)).toBeNull();
|
||||
});
|
||||
|
||||
it("@ not at the start never parses (a mid-body @ is plain text)", () => {
|
||||
expect(splitLeadingMention("hi @researcher", AGENTS)).toBeNull();
|
||||
expect(splitLeadingMention("plain text", AGENTS)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
workspace: "/data/ws",
|
||||
});
|
||||
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("the scheduled-task parser (server-produced block) is reachable here for the banner", () => {
|
||||
expect(
|
||||
parseScheduledMessage(
|
||||
"[scheduled_task]\nschedule: daily\nfired_at: 2026-01-01T00:00:00Z\n[/scheduled_task]\n\nbody",
|
||||
),
|
||||
).toEqual({ origin: { name: "daily", firedAt: "2026-01-01T00:00:00Z" }, rest: "body" });
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ describe("parseDraft (field-by-field validation)", () => {
|
||||
expect(parseDraft("[1,2]")).toEqual({});
|
||||
});
|
||||
|
||||
it("valid fields pass through one by one (the model is a paired reference)", () => {
|
||||
it("valid fields pass through one by one (both model fields are paired references)", () => {
|
||||
const raw = JSON.stringify({
|
||||
text: "Write me a script",
|
||||
agentId: "default_agent",
|
||||
@@ -49,6 +49,7 @@ describe("parseDraft (field-by-field validation)", () => {
|
||||
approvalMode: "read-only",
|
||||
modelRef: { provider: "anthropic", modelId: "claude-opus-4-8" },
|
||||
handoffAgentId: "agent_helper",
|
||||
switchModelRef: { provider: "openai", modelId: "gpt-5" },
|
||||
skills: ["agent-creation", "penguin-sdk"],
|
||||
});
|
||||
expect(parseDraft(raw)).toEqual({
|
||||
@@ -58,6 +59,7 @@ describe("parseDraft (field-by-field validation)", () => {
|
||||
approvalMode: "read-only",
|
||||
modelRef: { provider: "anthropic", modelId: "claude-opus-4-8" },
|
||||
handoffAgentId: "agent_helper",
|
||||
switchModelRef: { provider: "openai", modelId: "gpt-5" },
|
||||
skills: ["agent-creation", "penguin-sdk"],
|
||||
});
|
||||
});
|
||||
@@ -70,6 +72,7 @@ describe("parseDraft (field-by-field validation)", () => {
|
||||
approvalMode: "read-only",
|
||||
modelRef: ["m"],
|
||||
handoffAgentId: 7,
|
||||
switchModelRef: "custom:claude-4-8",
|
||||
});
|
||||
expect(parseDraft(raw)).toEqual({ approvalMode: "read-only" });
|
||||
});
|
||||
@@ -80,6 +83,28 @@ describe("parseDraft (field-by-field validation)", () => {
|
||||
expect(parseDraft(JSON.stringify({ modelRef: { provider: "anthropic" } }))).toEqual({});
|
||||
});
|
||||
|
||||
it("the staged /model target validates exactly like modelRef: half references and non-objects are dropped", () => {
|
||||
// The staged switch chip is cached alongside the text it belongs to (a chip lost while its
|
||||
// text survived would send that text to the current session on the old model), so a
|
||||
// corrupted entry must degrade to "no chip" rather than to a half-formed model reference.
|
||||
expect(parseDraft(JSON.stringify({ switchModelRef: { modelId: "gpt-5" } }))).toEqual({});
|
||||
expect(parseDraft(JSON.stringify({ switchModelRef: { provider: "openai" } }))).toEqual({});
|
||||
expect(parseDraft(JSON.stringify({ switchModelRef: null }))).toEqual({});
|
||||
expect(parseDraft(JSON.stringify({ switchModelRef: 42 }))).toEqual({});
|
||||
expect(
|
||||
parseDraft(JSON.stringify({ switchModelRef: { provider: "openai", modelId: 5 } })),
|
||||
).toEqual({});
|
||||
// The two model fields are independent: a bad one never takes the good one down with it.
|
||||
expect(
|
||||
parseDraft(
|
||||
JSON.stringify({
|
||||
modelRef: { provider: "anthropic", modelId: "claude-opus-4-8" },
|
||||
switchModelRef: { provider: 1, modelId: "gpt-5" },
|
||||
}),
|
||||
),
|
||||
).toEqual({ modelRef: { provider: "anthropic", modelId: "claude-opus-4-8" } });
|
||||
});
|
||||
|
||||
it("approvalMode accepts only the four valid values", () => {
|
||||
expect(parseDraft(JSON.stringify({ approvalMode: "yolo" }))).toEqual({});
|
||||
for (const m of ["always-ask", "read-only", "allow-all", "deny-all"]) {
|
||||
|
||||
Reference in New Issue
Block a user