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:
Yaowei Zheng
2026-07-29 23:37:37 +08:00
committed by GitHub
parent b9de3da879
commit 37fc715ce9
28 changed files with 1115 additions and 696 deletions
+95 -14
View File
@@ -12,7 +12,11 @@
* persists per Project (order re-checked after a reload);
* - after switching the sidebar to agent mode (toggle persisted in localStorage), the agent
* group header's "+" creates a draft scoped to that group's Agent (explicitly set via router
* state, overriding the cache).
* state, overriding the cache);
* - both switch commands are **staged**: `/model` and `/agent` pin a chip and send nothing, the
* chip is cached with the body text (it survives a reload), and pressing Enter afterwards is
* what actually forks the conversation onto the picked model / hands it to the picked Agent —
* carrying the text typed after the pick into the new conversation.
*/
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -58,7 +62,7 @@ test("draft: pick model/approval -> reload restores them -> send creates the ses
});
expect(put.ok(), "put models").toBeTruthy();
// The only builtin Agent is default_agent, so the @ delegation and sidebar group-header "+" targets use a custom-created Agent.
// The only builtin Agent is default_agent, so the /agent handoff and sidebar group-header "+" targets use a custom-created Agent.
const created = await page.request.post(`${BASE}/api/projects/${projectId}/agents`, {
data: { agentId: "agent_helper", name: "Helper Agent" },
});
@@ -69,14 +73,18 @@ test("draft: pick model/approval -> reload restores them -> send creates the ses
await expect(page.getByRole("heading", { name: "PenguinHarness" })).toBeVisible();
const ta = page.getByPlaceholder(/输入消息/);
await ta.fill("Draft body must not be lost");
// The @ delegation target is also draft content: typing an @ prefix at the end summons the
// menu, selecting it turns into a chip (the @token in the body text is stripped out).
await ta.fill("Draft body must not be lost @agent_hel");
await page.getByRole("button", { name: /@agent_helper/ }).click();
await expect(page.getByText("@agent_helper")).toBeVisible();
await expect(ta).toHaveValue("Draft body must not be lost");
// `/agent` is a SESSION command: a draft has no conversation to hand over, and its Agent is
// chosen by the draft page's own selector — so the slash menu must not offer it here (the
// staged-handoff flow itself is covered in the session section below). The menu does open on
// the same prefix, matching the installed `/agent-*` skills — which is what makes the absent
// command row a real assertion rather than a menu that simply never appeared.
await ta.fill("/agent");
await expect(page.getByRole("button", { name: /^\/agent-creation/ })).toBeVisible();
await expect(
page.getByRole("button", { name: "/agent 交给其他 Agent,发送时开启新会话" }),
).toHaveCount(0);
await ta.fill("Draft body must not be lost");
// Switch the model: the selector sits to the left of the send button, opens downward, with a quick-search field at the top.
await page.getByRole("button", { name: "选择模型" }).click();
@@ -131,11 +139,6 @@ test("draft: pick model/approval -> reload restores them -> send creates the ses
await expect(page.getByRole("button", { name: "审批模式" })).toContainText("放行只读");
// The thinking level is NOT draft state: it restores from the Agent config (written through above), not the cache.
await expect(page.getByRole("button", { name: "思考等级" })).toContainText("高");
// The @ target restores along with the draft; removing it falls back to a normal send (no delegation triggered).
await expect(page.getByText("@agent_helper")).toBeVisible();
await page.getByRole("button", { name: "移除 @ 目标" }).click();
await expect(page.getByText("@agent_helper")).toHaveCount(0);
// Send: the Session is only created now, and the selections land faithfully in its meta.
await page.getByRole("button", { name: "发送" }).click();
await page.waitForURL(/\/chat\/session-/);
@@ -289,4 +292,82 @@ test("draft: pick model/approval -> reload restores them -> send creates the ses
await expect(ta).toHaveValue("Draft inside the session");
await page.getByRole("button", { name: "发送" }).click();
await expect.poll(() => page.evaluate((k) => localStorage.getItem(k), sessionKey)).toBeNull();
// —— /model stages the fork; Enter is what performs it ——
// Wait for the run above to finish first: the slash menu is suppressed while a Task runs, and
// a staged fork deliberately refuses to send until the Session is idle (it continues from a
// Trace the run is still appending to). Two signals, in order: the second round's closing
// text lands (this Session has now answered two messages), then the action button stops being
// Stop — which it is for as long as a Task runs with an empty composer.
await expect(page.getByText("Command finished; the result looks as expected.")).toHaveCount(2);
await expect(page.getByRole("button", { name: "停止" })).toHaveCount(0);
await ta.fill("/model");
await ta.press("Enter");
await expect(page.getByText("切换模型", { exact: true })).toBeVisible(); // picker title bar
await expect(ta).toHaveValue(""); // the command consumed its own token
await page.getByPlaceholder(/搜索模型/).fill("claude-4-8");
// Both models match the query (one id is the other's prefix); pick the non-mini one, i.e. not
// the model this Session already runs on.
await page
.getByRole("button", { name: /claude-4-8/ })
.filter({ hasNotText: "mini" })
.click();
// Nothing was sent: we are still in the same Session, with the pick pinned as a chip — which
// is why the body can be typed AFTER the pick and still ride along.
await expect(page).toHaveURL(new RegExp(`/chat/${secondSessionId}$`));
await expect(page.getByLabel("移除切换模型")).toBeVisible();
await ta.fill("Fork body typed after the pick");
// The chip is draft content, cached in the SAME entry as the text (poll on the text: it is
// the debounced field, so its arrival means everything is flushed), and a reload restores
// BOTH. A chip lost while its text survived would send that text to the current Session on
// the old model — the opposite of what was staged.
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), sessionKey))
.toContain("Fork body typed after the pick");
expect(await page.evaluate((k) => localStorage.getItem(k), sessionKey)).toContain("claude-4-8");
await page.reload();
await expect(ta).toHaveValue("Fork body typed after the pick");
await expect(page.getByLabel("移除切换模型")).toBeVisible();
// Enter performs the fork: a NEW Session on the picked model, same Agent, carrying the body.
await ta.press("Enter");
await page.waitForURL(
(url) => /\/chat\/session-/.test(url.pathname) && !url.href.endsWith(secondSessionId),
);
const thirdSessionId = page.url().split("/chat/")[1];
const third = await (await page.request.get(`${BASE}/api/sessions/${thirdSessionId}`)).json();
expect(third.session.modelId).toBe("claude-4-8");
expect(third.session.agentId).toBe("agent_helper");
// The source block collapses into the "switched model" banner, and the typed body follows it.
await expect(page.getByText(/已切换模型(原为 claude-4-8-mini)/)).toBeVisible();
await expect(page.getByText("Fork body typed after the pick")).toBeVisible();
// —— /agent stages the handoff; Enter is what performs it ——
// The forked Session started its own run; wait it out the same way (a fresh stream, so its
// first closing text is the only one).
await expect(page.getByText("Command finished; the result looks as expected.")).toHaveCount(1);
await expect(page.getByRole("button", { name: "停止" })).toHaveCount(0);
await ta.fill("/agent");
await ta.press("Enter");
await page.getByPlaceholder(/搜索 Agent/).fill("default");
await page.getByRole("button", { name: /default_agent/ }).click();
// Staged only: still in the forked Session, still able to type the message to hand over.
await expect(page).toHaveURL(new RegExp(`/chat/${thirdSessionId}$`));
await expect(page.getByLabel("移除交接目标")).toBeVisible();
await ta.fill("Handoff body typed after the pick");
await ta.press("Enter");
await page.waitForURL(
(url) => /\/chat\/session-/.test(url.pathname) && !url.href.endsWith(thirdSessionId),
);
const fourthSessionId = page.url().split("/chat/")[1];
const fourth = await (await page.request.get(`${BASE}/api/sessions/${fourthSessionId}`)).json();
expect(fourth.session.agentId).toBe("default_agent");
// The [handoff_from] block collapses into the origin banner (the source agent is named
// without an @ sigil, matching the composer chip), and the typed body follows it.
await expect(page.getByText(/由 .*agent_helper.* 的对话交接而来/)).toBeVisible();
await expect(page.getByText("Handoff body typed after the pick")).toBeVisible();
});
@@ -0,0 +1,94 @@
/**
* Agent handoff for the chat input area (pure logic, shared by chat-input.tsx /
* chat-page.tsx and unit tests). A target agent is picked from the `/agent` command's picker
* and pinned as a highlighted chip at the front of the input; the text body carries no marker
* of its own. Nothing is sent at pick time — sending is what performs the handoff, and it
* opens a NEW conversation for that agent instead of posting to the current Session.
* - `filterAgents`: the picker's search box — filters candidates by agentId or display name.
* - `stagedSendRoute`: where a send goes once a switch chip is staged (and when a staged
* `/model` fork must refuse to go at all).
*
* The origin **marker blocks** these flows produce and render — `[handoff_from]`,
* `[scheduled_task]`, `[model_switch_from]` — are defined in core's marker module
* (`@prismshadow/penguin-core/markers`) alongside every other message marker, and are
* re-exported below under this feature's existing names.
*/
import { buildHandoffMessage, buildModelSwitchMessage } from "@prismshadow/penguin-core/markers";
import type { AgentSummary } from "@prismshadow/penguin-server/api";
export {
parseHandoffMessage,
parseModelSwitchMessage,
parseScheduledMessage,
} from "@prismshadow/penguin-core/markers";
export type {
HandoffOrigin,
ModelSwitchOrigin,
ScheduledOrigin,
} from "@prismshadow/penguin-core/markers";
/** First message of an `/agent` handoff conversation (core's `[handoff_from]` origin block). */
export const handoffMessage = buildHandoffMessage;
/** First message of a `/model` switch new conversation (core's `[model_switch_from]` origin block). */
export const modelSwitchMessage = buildModelSwitchMessage;
/**
* Filters the `/agent` picker's candidates by its search box: a case-insensitive **substring**
* match on the agentId or the display name — the same rule the model picker's search box uses,
* so a word typed from memory ("creator") still finds the agent wherever it sits in the id. An
* empty query returns every candidate.
*/
export function filterAgents(agents: AgentSummary[], query: string): AgentSummary[] {
const q = query.trim().toLowerCase();
if (!q) return agents;
return agents.filter(
(a) => a.agentId.toLowerCase().includes(q) || (a.name ?? "").toLowerCase().includes(q),
);
}
/**
* Where a send goes with the composer's switch chips staged:
* - `post` — no chip is in play: the ordinary task / steer-fallback / follow-up post;
* - `handoff` — a staged `/agent` target: opens a NEW chat for that agent, the current Session
* is not posted to at all;
* - `model` — a staged `/model` target: forks this conversation onto that model;
* - `blocked` — a staged `/model` target that must NOT go out yet (see below).
*/
export type StagedSendRoute = "post" | "handoff" | "model" | "blocked";
/**
* The staged-switch send decision, pulled out of the composer so it can be reasoned about (and
* unit-tested) on its own: staging is the whole point of `/agent` and `/model`, so *when* the
* staged pick is allowed to fire is the behaviour worth pinning down.
*
* The one rule that isn't merely "which chip is staged": a `/model` fork branches a NEW Session
* off **this** Session's Trace, so it may only run while this Session is idle. A run can start
* from outside the composer at any time — a queued follow-up auto-sending, a scheduled Task
* firing, another tab or the CLI posting — and forking then would point the new model at a
* Trace that is still being appended to, while navigating the user away from a live run. Rather
* than silently falling through to `post` (which would deliver the message to the very Session
* the user was switching away from), the send is refused until the Session goes idle.
*
* A staged handoff has no such constraint: it never reads or writes the running Session.
*/
export function stagedSendRoute({
handoffTarget,
pendingModel,
canSwitchModel,
sessionBusy,
}: {
/** An `/agent` handoff target is staged. */
handoffTarget: boolean;
/** A `/model` fork target is staged. */
pendingModel: boolean;
/** The host can actually perform a fork (an active session supplies onSwitchModel; the draft page does not). */
canSwitchModel: boolean;
/** This Session is running or compacting — its Trace is still being appended to. */
sessionBusy: boolean;
}): StagedSendRoute {
// The two chips are mutually exclusive by construction (picking either clears the other);
// should they ever coexist, the handoff wins — it is the one that touches nothing here.
if (handoffTarget) return "handoff";
if (!pendingModel || !canSwitchModel) return "post";
return sessionBusy ? "blocked" : "model";
}
@@ -1,94 +0,0 @@
/**
* @-agent handoff for the chat input area (pure logic, shared by chat-input.tsx /
* chat-page.tsx and unit tests). Only a **leading** @ is meaningful: a target picked from
* the menu is pinned as a highlighted chip at the front of the input (the text itself
* carries no @ marker); hand-typed/pasted text starting with `@<agentId>` takes effect the
* same way on send. Any @ elsewhere in the text is plain text.
* - `matchMention`: finds the `@` prefix currently being typed from the text before the
* caret, driving the agent-picker popup;
* - `filterAgents`: filters candidates by prefix (agentId or display name, case-insensitive);
* - `splitLeadingMention`: on send, parses a leading `@<agentId>`, splitting off the target
* agent from the remaining text.
*
* The origin **marker blocks** these flows produce and render — `[handoff_from]`,
* `[scheduled_task]`, `[model_switch_from]` — are defined in core's marker module
* (`@prismshadow/penguin-core/markers`) alongside every other message marker, and are
* re-exported below under this feature's existing names.
*/
import { buildHandoffMessage, buildModelSwitchMessage } from "@prismshadow/penguin-core/markers";
import type { AgentSummary } from "@prismshadow/penguin-server/api";
export {
parseHandoffMessage,
parseModelSwitchMessage,
parseScheduledMessage,
} from "@prismshadow/penguin-core/markers";
export type {
HandoffOrigin,
ModelSwitchOrigin,
ScheduledOrigin,
} from "@prismshadow/penguin-core/markers";
/** First message of an @-handoff new conversation (core's `[handoff_from]` origin block). */
export const handoffMessage = buildHandoffMessage;
/** First message of a `/model` switch new conversation (core's `[model_switch_from]` origin block). */
export const modelSwitchMessage = buildModelSwitchMessage;
/** Id characters allowed between `@` and the caret (matches core's id convention: letters, digits, underscore, hyphen). */
const ID_PREFIX = /^[\w-]*$/;
/**
* The @ mention currently being typed: `start` is the index of `@` in the full text,
* `query` is the prefix between `@` and the caret, and `end` is the end position of the
* same token to the right of the caret — selecting a candidate replaces the **entire**
* `start..end` token (no leftover tail when the caret sits mid-token).
*/
export interface MentionMatch {
start: number;
end: number;
query: string;
}
/**
* Finds the @ mention currently being typed at the caret; returns null if none.
* `@` must be at the start of the text or preceded by whitespace (to avoid treating
* ordinary text like emails as mentions); only id characters are allowed between `@` and
* the caret.
*/
export function matchMention(text: string, caret: number): MentionMatch | null {
const before = text.slice(0, caret);
const at = before.lastIndexOf("@");
if (at < 0) return null;
if (at > 0 && !/\s/.test(before[at - 1]!)) return null;
const query = before.slice(at + 1);
if (!ID_PREFIX.test(query)) return null;
const rest = /^[\w-]*/.exec(text.slice(caret))![0];
return { start: at, end: caret + rest.length, query };
}
/** Filters candidate agents by prefix (agentId or display name, case-insensitive); an empty prefix returns all. */
export function filterAgents(agents: AgentSummary[], query: string): AgentSummary[] {
const q = query.toLowerCase();
return agents.filter(
(a) => a.agentId.toLowerCase().startsWith(q) || (a.name ?? "").toLowerCase().startsWith(q),
);
}
/**
* Parses a leading mention: when text (expected to already be trimmed) starts with
* `@<existing agentId>`, splits off the target agent from the remaining text (the id is
* the longest `[\w-]+` run after `@`, and must exactly match an existing agentId — `@foo2`
* does not count as @-ing foo; leading whitespace in the remaining text is trimmed).
* Returns null when the text doesn't start with an @ for an existing agent; an @ elsewhere
* in the text is never parsed.
*/
export function splitLeadingMention(
text: string,
agents: AgentSummary[],
): { agent: AgentSummary; rest: string } | null {
const m = /^@([\w-]+)([\s\S]*)$/.exec(text);
if (!m) return null;
const agent = agents.find((a) => a.agentId === m[1]);
if (!agent) return null;
return { agent, rest: m[2]!.trimStart() };
}
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -59,7 +59,7 @@ import { latestTaskHasSubagent, taskStartCount } from "./agent-topology";
import { ChatInput } from "./chat-input";
import { DraftView } from "./draft-view";
import { GoalStatusBanner } from "./goal-banner";
import { handoffMessage, modelSwitchMessage } from "./agent-mentions";
import { handoffMessage, modelSwitchMessage } from "./agent-handoff";
import { sameModelRef } from "../models/model-grouping";
import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
import { FilesPanel } from "./files-panel";
@@ -287,11 +287,14 @@ export function ChatPage() {
() => void reloadSessions(),
);
// Chat input area draft: caches text, @ target, and selected skills keyed by sessionId; restored after navigating away and back or a refresh, discarded on successful send.
// Chat input area draft: caches text, both staged switch chips (`/agent` target, `/model`
// target) and the selected skills keyed by sessionId; restored after navigating away and back
// or a refresh, discarded on successful send.
const {
initial: sessionDraft,
onTextChange: onDraftTextChange,
onHandoffTargetChange: onDraftHandoffChange,
onPendingModelChange: onDraftPendingModelChange,
onSkillsChange: onDraftSkillsChange,
discard: discardSessionDraft,
} = useSessionDraft(selected?.sessionId ?? null);
@@ -636,10 +639,10 @@ export function ChatPage() {
[projectId, selected, addSession, discardSessionDraft, navigate],
);
// @ handoff: doesn't use the current Session — creates a new chat for the @-mentioned agent
// /agent handoff: doesn't use the current Session — creates a new chat for the picked agent
// (approval mode carries over from the input area's current value; model/Workspace use the
// creation defaults). The first input = a [handoff_from] source block (current agent / Session
// / Workspace info) + the user's input and images with the @ mention stripped; jumps to the new
// / Workspace info) + the user's input and images; jumps to the new
// chat once sent.
// Returns false on failure, keeping the draft so it can be resent (deletes the empty Session that never got its first message sent).
const onHandoff = useCallback(
@@ -887,6 +890,7 @@ export function ChatPage() {
modeSaving={modeSaving}
autoFocus
agents={agents}
currentAgentId={selected.agentId}
skills={agentSkills}
{...(sessionDraft.skills && sessionDraft.skills.length > 0
? { initialSkills: sessionDraft.skills }
@@ -899,6 +903,10 @@ export function ChatPage() {
? { initialHandoffTargetId: sessionDraft.handoffAgentId }
: {})}
onHandoffTargetChange={onDraftHandoffChange}
{...(sessionDraft.switchModelRef
? { initialPendingModelRef: sessionDraft.switchModelRef }
: {})}
onPendingModelChange={onDraftPendingModelChange}
/>
);
+28 -11
View File
@@ -8,7 +8,7 @@
*
* The key must include userId (#68): if the same browser logs into different accounts in
* succession and the key only contains the Project/Session ID, the later user would recover the
* previous user's text, Workspace, model selection, and @ target — a cross-account information leak.
* previous user's text, Workspace, model selection, and handoff target — a cross-account information leak.
*/
import type { ApprovalMode } from "@prismshadow/penguin-server/api";
@@ -25,8 +25,17 @@ export interface DraftCache {
* (product hasn't shipped, so no migration is done).
*/
modelRef?: { provider: string; modelId: string };
/** The @ handoff target (chip) at the front of the input box: resolved again by id on restore, dropped if no longer valid. */
/** The `/agent` handoff target (chip) at the front of the input box: resolved again by id on restore, dropped if no longer valid. */
handoffAgentId?: string;
/**
* The `/model` switch target (the other switch chip; a paired reference, same shape as
* modelRef above): cached for exactly the same reason as handoffAgentId — the composer text
* is cached and ChatInput remounts on every session switch, so a chip left in component state
* would vanish while the text it belongs to came back, and the next Enter would post to the
* current session on the old model. Resolved again against the model list on restore and
* dropped when that model is no longer available.
*/
switchModelRef?: { provider: string; modelId: string };
/**
* Preselected skill names (written by the quick-invoke action on the Skill library page):
* used as the initial selection when ChatInput mounts, then trimmed to remove names not in the
@@ -46,10 +55,22 @@ export interface DraftStorage {
export const draftKey = (userId: string, projectId: string): string =>
`penguin.chatDraft.${userId}.${projectId}`;
/** Cache key for an existing session's input area: one per "user × Session" (only stores text and @ target; everything else is locked to the Session). */
/** Cache key for an existing session's input area: one per "user × Session" (only stores text and the handoff target; everything else is locked to the Session). */
export const sessionDraftKey = (userId: string, sessionId: string): string =>
`penguin.chatDraft.session.${userId}.${sessionId}`;
/**
* A cached model reference must be a paired `{ provider, modelId }` object; anything else — the
* old string-typed modelId, a half reference, a non-object — yields undefined and the field is
* dropped. Shared by the two model fields (the draft's selection and the staged `/model` switch).
*/
function parseModelRef(value: unknown): { provider: string; modelId: string } | undefined {
if (typeof value !== "object" || value === null) return undefined;
const r = value as Record<string, unknown>;
if (typeof r.provider !== "string" || typeof r.modelId !== "string") return undefined;
return { provider: r.provider, modelId: r.modelId };
}
/** Parses and validates raw JSON field-by-field: null / malformed JSON / non-object / invalid fields are all dropped. */
export function parseDraft(raw: string | null): DraftCache {
if (!raw) return {};
@@ -61,15 +82,11 @@ export function parseDraft(raw: string | null): DraftCache {
if (typeof o.text === "string") out.text = o.text;
if (typeof o.agentId === "string") out.agentId = o.agentId;
if (typeof o.workspace === "string") out.workspace = o.workspace;
// The model reference must be a paired { provider, modelId } object; the old string-typed
// modelId and any malformed shape are dropped.
if (typeof o.modelRef === "object" && o.modelRef !== null) {
const r = o.modelRef as Record<string, unknown>;
if (typeof r.provider === "string" && typeof r.modelId === "string") {
out.modelRef = { provider: r.provider, modelId: r.modelId };
}
}
const modelRef = parseModelRef(o.modelRef);
if (modelRef) out.modelRef = modelRef;
if (typeof o.handoffAgentId === "string") out.handoffAgentId = o.handoffAgentId;
const switchModelRef = parseModelRef(o.switchModelRef);
if (switchModelRef) out.switchModelRef = switchModelRef;
if (Array.isArray(o.skills)) {
// Elements are validated one by one: non-string items are filtered out; if empty after
// filtering, the whole field is omitted.
+4 -59
View File
@@ -57,7 +57,6 @@ import { ChatInput } from "./chat-input";
import { buildSkillsMessage } from "./skill-use";
import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache";
import type { DraftCache } from "./draft-cache";
import { handoffMessage } from "./agent-mentions";
import { sameModelRef } from "../models/model-grouping";
/** Coalescing window for writing body text to the cache: keystrokes are frequent, so a short batch accumulates before persisting (option changes are still written immediately). */
@@ -135,10 +134,6 @@ export function DraftView({
cached.approvalMode ?? "allow-all",
);
const [modelRef, setModelRef] = useState<ModelRefDto | null>(cached.modelRef ?? null);
/** @-handoff target (chip): draft content just like the body text, cached alongside it (fed in via the ChatInput callback). */
const [handoffAgentId, setHandoffAgentId] = useState<string | null>(
cached.handoffAgentId ?? null,
);
const textRef = useRef(cached.text ?? "");
/**
* Selected skills (prefilled by "quick invoke" from the Skills page + checked in
@@ -301,19 +296,9 @@ export function DraftView({
const data: DraftCache = { text: textRef.current, workspace, approvalMode };
if (agentId) data.agentId = agentId;
if (modelRef) data.modelRef = modelRef;
if (handoffAgentId) data.handoffAgentId = handoffAgentId;
if (skillsRef.current.length > 0) data.skills = skillsRef.current;
saveDraft(draftKey(userId, projectId), data);
}, [
cancelPendingSave,
userId,
projectId,
agentId,
workspace,
approvalMode,
modelRef,
handoffAgentId,
]);
}, [cancelPendingSave, userId, projectId, agentId, workspace, approvalMode, modelRef]);
// The timer and unmount cleanup read persistNow via a ref to always get the **latest version**: a stale closure would write back outdated options.
const persistRef = useRef(persistNow);
@@ -374,8 +359,8 @@ export function DraftView({
setCurrentAgentId(a.agentId);
};
// One in-flight guard shared by every send entry point (composer send / example task /
// @-handoff): a second submission while one is running would create a second Session with
// One in-flight guard shared by both send entry points (composer send / example task): a
// second submission while one is running would create a second Session with
// its own first task and a racing navigation. The ref is the synchronous guard; the state
// drives disabled styling on the example button (the composer has its own busy state).
const sendingRef = useRef(false);
@@ -448,45 +433,7 @@ export function DraftView({
[exampleBusy, agentSkills, onSend],
);
// @ handoff: opens a new chat for the @-mentioned agent (approval mode carries over from the
// draft's current value; model/Workspace use the creation defaults), first input =
// [handoff_from] source block + the text and images with the @ mention stripped.
const selectedAgent = agents.find((a) => a.agentId === agentId) ?? null;
const onHandoff = useCallback(
async (target: AgentSummary, input: TaskInputPart[]): Promise<boolean> => {
if (!selectedAgent || sendingRef.current) return false;
sendingRef.current = true;
setSending(true);
const origin: TaskInputPart = {
type: "text",
text: handoffMessage({
agentId: selectedAgent.agentId,
...(selectedAgent.name !== undefined ? { agentName: selectedAgent.name } : {}),
}),
};
let createdId: string | null = null;
try {
const created = await api.createSession(projectId, target.agentId, { approvalMode });
createdId = created.session.sessionId;
const res = await api.postTask(createdId, { input: [origin, ...input] });
add(created.session);
discardDraft();
navigate(`/chat/${res.sessionId}`);
return true;
} catch (e) {
if (createdId) void api.deleteSession(createdId).catch(() => undefined);
// The new chat uses the project's default model (createSession doesn't specify a model reference), so the error copy's model context follows suit.
toastError(
apiErrorText(e, models?.defaultModel ? { modelId: models.defaultModel.modelId } : {}),
);
return false;
} finally {
sendingRef.current = false;
setSending(false);
}
},
[projectId, selectedAgent, approvalMode, add, discardDraft, navigate, models],
);
// Capability info for the currently selected model (vision/context window) switches instantly with the selection (matched by paired reference).
const modelInfo = models?.models.find((m) => sameModelRef(m, modelRef));
@@ -540,14 +487,12 @@ export function DraftView({
modeSaving={false}
autoFocus
agents={agents}
{...(agentId ? { currentAgentId: agentId } : {})}
skills={agentSkills}
{...(cached.skills && cached.skills.length > 0 ? { initialSkills: cached.skills } : {})}
onSkillsChange={onSkillsChange}
onHandoff={onHandoff}
initialText={cached.text ?? ""}
onTextChange={onTextChange}
{...(cached.handoffAgentId ? { initialHandoffTargetId: cached.handoffAgentId } : {})}
onHandoffTargetChange={setHandoffAgentId}
/>
{/* Ownership selection right below the card (small pill dropdowns, styled after ChatGPT's project picker button) */}
@@ -1,7 +1,7 @@
/**
* Provenance banners for conversations opened from another conversation — each collapses a
* machine-inserted source block (the raw text is never shown; the model still sees it):
* - `HandoffBanner` (`[handoff_from]`, @ delegation): "Handed off from <agent>'s chat";
* - `HandoffBanner` (`[handoff_from]`, the /agent handoff): "Handed off from <agent>'s chat";
* - `ModelSwitchBanner` (`[model_switch_from]`, the /model command): "switched model —
* continued from the earlier conversation".
* When there's a source Session, the whole line is clickable and jumps back to it (the
@@ -9,13 +9,18 @@
*/
import { useNavigate } from "react-router";
import { S } from "../../lib/strings";
import type { HandoffOrigin, ModelSwitchOrigin } from "./agent-mentions";
import type { HandoffOrigin, ModelSwitchOrigin } from "./agent-handoff";
/** Display name of the source agent: `displayName (@id)` when the display name differs from the id, otherwise just `@id`. */
/**
* Display name of the source agent: `displayName (id)` when the display name differs from the
* id, otherwise just the id. No `@` sigil — the mention trigger it stood for is gone (`/agent`
* replaced it), and the composer's own handoff chip spells the agent out without one, so the
* banner would otherwise name the same agent differently from the control that started it.
*/
function agentLabel(origin: HandoffOrigin): string {
return origin.agentName && origin.agentName !== origin.agentId
? `${origin.agentName} (@${origin.agentId})`
: `@${origin.agentId}`;
? `${origin.agentName} (${origin.agentId})`
: origin.agentId;
}
const bannerFrame =
@@ -27,7 +27,7 @@ import {
parseHandoffMessage,
parseModelSwitchMessage,
parseScheduledMessage,
} from "./agent-mentions";
} from "./agent-handoff";
import { parseGoalMessage } from "./goal-use";
import { parseSkillsMessage } from "./skill-use";
import { TaskStatsLine } from "./task-stats-line";
@@ -176,7 +176,7 @@ function ReconnectLine({ item, ctx }: { item: ReconnectItem; ctx: StreamRenderCo
export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderContext }) {
switch (item.kind) {
case "user_text": {
// Source block for a chat created via @ handoff: collapsed into a single-line handoff notice (the raw text isn't shown), clickable to jump back to the original chat.
// Source block for a chat created via the /agent handoff: collapsed into a single-line handoff notice (the raw text isn't shown), clickable to jump back to the original chat.
const handoff = parseHandoffMessage(item.text);
if (handoff) return <HandoffBanner origin={handoff} />;
// Source block for a chat opened by the /model switch: collapsed into a single-line switch notice, clickable to jump back to the source conversation.
@@ -7,7 +7,7 @@
*/
import { S } from "../../lib/strings";
import { formatDateTime } from "../../lib/format";
import type { ScheduledOrigin } from "./agent-mentions";
import type { ScheduledOrigin } from "./agent-handoff";
export function ScheduledBanner({ origin }: { origin: ScheduledOrigin }) {
return (
@@ -1,9 +1,9 @@
/**
* Positional slash-command matching for the chat input (pure logic, unit-tested):
* like @ mentions, a `/` opens the command menu from ANY caret position — it must sit at
* the start of the text or be preceded by whitespace (so URLs and paths like `a/b` never
* trigger it), with only command characters between the `/` and the caret. Running a
* command removes just the `start..end` token, leaving the rest of the text intact.
* a `/` opens the command menu from ANY caret position — it must sit at the start of the
* text or be preceded by whitespace (so URLs and paths like `a/b` never trigger it), with
* only command characters between the `/` and the caret. Running a command removes just the
* `start..end` token, leaving the rest of the text intact.
*/
/** Command characters allowed between `/` and the caret (command names and skill names: letters, digits, underscore, hyphen). */
@@ -1,15 +1,17 @@
/**
* Draft auto-cache for an existing session's input area: text +
* @-handoff target + selected skills are cached to localStorage keyed by "user x Session"
* (see draft-cache's sessionDraftKey; the user dimension prevents cross-account leakage on
* the same browser, #68), restored after navigating away/reloading. Model / Workspace /
* approval mode are locked to the Session and need no caching.
* Draft auto-cache for an existing session's input area: text + the two staged switch chips
* (`/agent` handoff target, `/model` switch target) + selected skills are cached to
* localStorage keyed by "user x Session" (see draft-cache's sessionDraftKey; the user
* dimension prevents cross-account leakage on the same browser, #68), restored after
* navigating away/reloading. The Session's OWN model / Workspace / approval mode are locked
* and need no caching — the cached model reference here is the pending switch target, not the
* session's model.
*
* Write strategy matches the draft page: text is debounced and merge-written (an unflushed
* edit gets one extra flush before switching sessions/unmounting); @ target and skill
* selection write immediately; **clearing content deletes the key** (leaving an empty shell
* per session would bloat localStorage); discard on a successful send cancels the pending
* timer first, otherwise it would write the just-cleared draft back.
* edit gets one extra flush before switching sessions/unmounting); the switch chips and the
* skill selection write immediately; **clearing content deletes the key** (leaving an empty
* shell per session would bloat localStorage); discard on a successful send cancels the
* pending timer first, otherwise it would write the just-cleared draft back.
*
* ChatPage keys session content blocks by sessionId, so ChatInput remounts accordingly, but
* this hook is mounted on ChatPage itself and does not remount — switching sessions is
@@ -17,6 +19,7 @@
* resets the refs to the new session's initial values.
*/
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { ModelRefDto } from "@prismshadow/penguin-server/api";
import { useAuth } from "../../state/auth";
import { clearDraft, loadDraft, saveDraft, sessionDraftKey } from "./draft-cache";
import type { DraftCache } from "./draft-cache";
@@ -28,6 +31,8 @@ export function useSessionDraft(sessionId: string | null): {
initial: DraftCache;
onTextChange: (text: string) => void;
onHandoffTargetChange: (agentId: string | null) => void;
/** Staged `/model` switch target change (picked/removed); like the handoff target, a discrete action writes immediately. */
onPendingModelChange: (ref: ModelRefDto | null) => void;
/** Selected-skills change (wired directly to ChatInput's onSkillsChange; a discrete action writes immediately). */
onSkillsChange: (names: string[]) => void;
/** Discard the current session's draft after a successful send. */
@@ -40,6 +45,7 @@ export function useSessionDraft(sessionId: string | null): {
const textRef = useRef(initial.text ?? "");
const handoffRef = useRef<string | null>(initial.handoffAgentId ?? null);
const switchModelRef = useRef<ModelRefDto | null>(initial.switchModelRef ?? null);
const skillsRef = useRef<string[]>(initial.skills ?? []);
const timer = useRef<number | null>(null);
@@ -55,13 +61,15 @@ export function useSessionDraft(sessionId: string | null): {
if (!key) return;
const text = textRef.current;
const handoffAgentId = handoffRef.current;
const pendingModel = switchModelRef.current;
const skills = skillsRef.current;
if (!text && !handoffAgentId && skills.length === 0) {
if (!text && !handoffAgentId && !pendingModel && skills.length === 0) {
clearDraft(key);
return;
}
const data: DraftCache = { text };
if (handoffAgentId) data.handoffAgentId = handoffAgentId;
if (pendingModel) data.switchModelRef = pendingModel;
if (skills.length > 0) data.skills = skills;
saveDraft(key, data);
}, [cancelPending, key]);
@@ -72,6 +80,7 @@ export function useSessionDraft(sessionId: string | null): {
useEffect(() => {
textRef.current = initial.text ?? "";
handoffRef.current = initial.handoffAgentId ?? null;
switchModelRef.current = initial.switchModelRef ?? null;
skillsRef.current = initial.skills ?? [];
return () => {
if (timer.current !== null) {
@@ -103,10 +112,19 @@ export function useSessionDraft(sessionId: string | null): {
[persistNow],
);
const onPendingModelChange = useCallback(
(ref: ModelRefDto | null) => {
switchModelRef.current = ref;
// Same as the handoff target: discrete action writes immediately.
persistNow();
},
[persistNow],
);
const onSkillsChange = useCallback(
(names: string[]) => {
skillsRef.current = names;
// Same as @ target: discrete action writes immediately.
// Same as the handoff target: discrete action writes immediately.
persistNow();
},
[persistNow],
@@ -114,12 +132,21 @@ export function useSessionDraft(sessionId: string | null): {
const discard = useCallback(() => {
cancelPending();
// Also clear selected skills: ChatInput's clear after a successful send doesn't fire a
// callback (same convention as onTextChange); without this, a later text flush would
// resurrect the already-sent selection.
// Also clear the selected skills and both staged switch chips: ChatInput's clear after a
// successful send doesn't fire a callback (same convention as onTextChange); without this,
// a later text flush would resurrect the already-sent selection.
skillsRef.current = [];
handoffRef.current = null;
switchModelRef.current = null;
if (key) clearDraft(key);
}, [cancelPending, key]);
return { initial, onTextChange, onHandoffTargetChange, onSkillsChange, discard };
return {
initial,
onTextChange,
onHandoffTargetChange,
onPendingModelChange,
onSkillsChange,
discard,
};
}
@@ -226,7 +226,7 @@ export function SkillsPage() {
* would only be noise here), and points the Agent to default_agent before
* entering draft mode — the route state explicitly carries agentId
* (overriding whatever was last selected in the cache). handoffAgentId
* must be cleared: a leftover @ target would forward the whole skill
* must be cleared: a leftover handoff target would forward the whole skill
* invocation to a different Agent — quick invoke must always start a new
* conversation with default_agent.
*/
+11 -3
View File
@@ -731,8 +731,12 @@ When done, open index.html in a browser and self-test once.`,
contextUsage: "Context usage",
contextUnknown: "Context usage: unknown until the next request reports it",
slashHint: "Type / for commands",
mentionHint: "@ to handoff to another agent",
mentionRemove: "Remove @ target",
switchAgent: "Hand off to another agent — opens a new session on send",
switchAgentTitle: "Choose agent",
agentSearchPlaceholder: "Search agents: id / name",
agentsNoMatch: "No matching agents",
handoffTargetTitle: (agent: string) => `Sending hands this conversation to ${agent}`,
handoffRemove: "Remove handoff target",
skillsSelect: "Skills",
skillRemove: "Remove skill",
skillsSearchPlaceholder: "Search skills",
@@ -743,8 +747,12 @@ When done, open index.html in a browser and self-test once.`,
handoffFrom: (agent: string) => `Handed off from ${agent}'s conversation`,
handoffBack: (title?: string) =>
title ? `Back to the original conversation: ${title}` : "Back to the original conversation",
switchModel: "Switch model — continue this conversation in a new session",
switchModel: "Switch model — on send, continues this conversation in a new session",
switchModelTitle: "Switch model",
modelSwitchTargetTitle: (model: string) => `Sending continues this conversation on ${model}`,
modelSwitchRemove: "Remove model switch",
modelSwitchBusyHint:
"The model switch waits for this turn to finish: the new session continues from this session's record",
modelSwitchFrom: (prevModel?: string) =>
prevModel
? `Switched model (was ${prevModel}) — continued from the earlier conversation`
+14 -5
View File
@@ -715,8 +715,13 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
contextUsage: "上下文占用",
contextUnknown: "上下文占用:压缩后待下次请求回报",
slashHint: "输入 / 使用命令",
mentionHint: "@ handoff 给其他 Agent",
mentionRemove: "移除 @ 目标",
/** `/agent` handoff: command description, picker title, search box, no-match hint, and the staged target's description and remove button. */
switchAgent: "交给其他 Agent,发送时开启新会话",
switchAgentTitle: "选择 Agent",
agentSearchPlaceholder: "搜索 Agent:id / 名称",
agentsNoMatch: "没有匹配的 Agent",
handoffTargetTitle: (agent: string) => `发送后交接给 ${agent}`,
handoffRemove: "移除交接目标",
/** Skill multi-select dropdown (input toolbar): button text, search box, empty state, and no-match hint. */
skillsSelect: "技能",
skillRemove: "移除技能",
@@ -727,12 +732,16 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
skillsAutoMessage: (names: string[]): string => `使用 ${names.join("、")} 技能`,
handoffFrom: (agent: string) => `由 ${agent} 的对话交接而来`,
handoffBack: (title?: string) => (title ? `回到原对话:${title}` : "回到原对话"),
/** /model 切换:命令描述、拾取器标题、切换来源横幅与空正文自动消息。 */
switchModel: "切换模型开启新会话延续本对话",
/** `/model` switch: command description, picker title, the staged target's description and remove button, the switch-origin banner, and the empty-body auto message. */
switchModel: "切换模型,发送时开启新会话延续本对话",
switchModelTitle: "切换模型",
modelSwitchTargetTitle: (model: string) => `发送后换用 ${model} 延续本对话`,
modelSwitchRemove: "移除切换模型",
/** Why Send is disabled with a model switch staged: the fork branches off a Trace this Session is still writing. */
modelSwitchBusyHint: "本轮结束后才能切换模型:新会话要从当前会话的记录接续",
modelSwitchFrom: (prevModel?: string) =>
prevModel ? `已切换模型(原为 ${prevModel}),延续原会话` : "已切换模型,延续原会话",
/** /model 切换且正文为空时自动发送的首条消息正文(与 skillsAutoMessage 同一约定)。 */
/** First message body auto-sent when `/model` is staged and the composer is empty (same convention as skillsAutoMessage). */
modelSwitchAutoMessage: "换用新模型继续这段对话",
scheduledFrom: (name: string) => `由定时任务「${name}」触发`,
emptyGreeting: "开始一段新对话",
+139
View File
@@ -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" });
});
});
-141
View File
@@ -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" });
});
});
+26 -1
View File
@@ -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"]) {