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:
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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: "开始一段新对话",
|
||||
|
||||
@@ -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