diff --git a/packages/core/src/omnimessage/markers/origin-blocks.ts b/packages/core/src/omnimessage/markers/origin-blocks.ts index 9e0d316..1f5a96f 100644 --- a/packages/core/src/omnimessage/markers/origin-blocks.ts +++ b/packages/core/src/omnimessage/markers/origin-blocks.ts @@ -90,10 +90,10 @@ function fieldLines(body: string, keys: readonly string[]): Array<[string, strin } // --------------------------------------------------------------------------- -// [handoff_from] — @-mention handoff into a new conversation +// [handoff_from] — handoff into a new conversation with another agent // --------------------------------------------------------------------------- -/** Origin info for an @-handoff new conversation: source agent is always present; the source Session is omitted while it's still a draft. */ +/** Origin info for a handoff's new conversation: source agent is always present; the source Session is omitted while it's still a draft. */ export interface HandoffOrigin { agentId: string; agentName?: string; @@ -103,11 +103,16 @@ export interface HandoffOrigin { } /** - * First message of an @-handoff new conversation (English): the `[handoff_from]` block states - * that this conversation was opened by an @ mention and carries the source agent / Session / - * Workspace, so the @-mentioned agent knows its origin (e.g. defaulting to the source agent as - * its working target, or reaching source files via the Workspace path). The parenthetical - * label is omitted when the display name/title equals the id or is absent. + * First message of a handoff's new conversation (English): the `[handoff_from]` block states + * that another conversation handed this one over (the Web composer's `/agent` command) and + * carries the source agent / Session / Workspace, so the receiving agent knows its origin + * (e.g. defaulting to the source agent as its working target, or reaching source files via the + * Workspace path). The parenthetical label is omitted when the display name/title equals the id + * or is absent. + * + * The prose is deliberately trigger-agnostic — the tag and the fields are a persisted format + * (old Traces still render through this parser), so the wording must survive the composer + * swapping how a handoff is started, as it did when `/agent` replaced the `@` mention. */ export function buildHandoffMessage(origin: HandoffOrigin): string { const name = @@ -121,7 +126,7 @@ export function buildHandoffMessage(origin: HandoffOrigin): string { return markerBlock( MARKER_TAGS.handoffFrom, [ - "This conversation was opened by @-mentioning you from another conversation; its origin is listed below and the user's message, if any, follows. When the request refers to an agent, session, or files without naming them, it means this origin.", + "The user handed this conversation to you from another one; its origin is listed below and the user's message, if any, follows. When the request refers to an agent, session, or files without naming them, it means this origin.", ...lines, ].join("\n"), ); diff --git a/packages/core/src/omnimessage/markers/tags.ts b/packages/core/src/omnimessage/markers/tags.ts index e9e3dbc..df5a79a 100644 --- a/packages/core/src/omnimessage/markers/tags.ts +++ b/packages/core/src/omnimessage/markers/tags.ts @@ -21,7 +21,7 @@ export const MARKER_TAGS = { goal: "goal", /** Skill invocation block prefixed to a user message (Web composer). */ useSkills: "use_skills", - /** @-handoff origin block, first message of the delegated conversation (Web). */ + /** `/agent` handoff origin block, first message of the delegated conversation (Web). */ handoffFrom: "handoff_from", /** Scheduled-task trigger origin block (server scheduler). */ scheduledTask: "scheduled_task", diff --git a/packages/core/test/markers.test.ts b/packages/core/test/markers.test.ts index 1487ce2..6183ca5 100644 --- a/packages/core/test/markers.test.ts +++ b/packages/core/test/markers.test.ts @@ -5,7 +5,7 @@ * agent's stored compaction prompt) while producers only ever emit the square form. * * Behavior covered here used to live next to each call site — engine (`extractSummary`), - * omnimessage (`userSteeringText`), web (`skill-use` / `agent-mentions`) — and moved with the + * omnimessage (`userSteeringText`), web (`skill-use` / `agent-handoff`) — and moved with the * consolidation; the host-side tests keep covering the rendering/wiring around them. */ import { describe, expect, it } from "vitest"; diff --git a/packages/docs/content/server-api.en.md b/packages/docs/content/server-api.en.md index 61c10d2..ee5f9fd 100644 --- a/packages/docs/content/server-api.en.md +++ b/packages/docs/content/server-api.en.md @@ -249,7 +249,7 @@ interface ApprovalDecisionRequest { } ``` -The Web's `/model` switch has no dedicated endpoint: like the @ handoff, it composes the ordinary APIs above — session creation opens a new Session for the same Agent (the chosen model, the source Workspace carried over), then POST /tasks sends a first message opening with a `[model_switch_from]` source block (the source session id, its `tracePath`, the Workspace, and the previous model pair); the model reads that Trace file itself when it needs the earlier history. +The Web's `/model` switch has no dedicated endpoint: like the `/agent` handoff, it composes the ordinary APIs above — session creation opens a new Session for the same Agent (the chosen model, the source Workspace carried over), then POST /tasks sends a first message opening with a `[model_switch_from]` source block (the source session id, its `tracePath`, the Workspace, and the previous model pair); the model reads that Trace file itself when it needs the earlier history. ## Streaming (SSE) diff --git a/packages/docs/content/server-api.zh.md b/packages/docs/content/server-api.zh.md index 18a19da..49813ca 100644 --- a/packages/docs/content/server-api.zh.md +++ b/packages/docs/content/server-api.zh.md @@ -247,7 +247,7 @@ interface ApprovalDecisionRequest { } ``` -Web 的 `/model` 模型切换没有专用接口:它按 @ handoff 的方式复用上面的普通接口——先用会话创建接口在同一 Agent 下新建 Session(选定新模型并沿用源 Workspace),再 POST /tasks 发送以 `[model_switch_from]` 源块开头的首条消息(源会话 id、其 `tracePath`、Workspace 与原模型二元组),模型需要早前历史时自行读取该 Trace 文件。 +Web 的 `/model` 模型切换没有专用接口:它按 `/agent` 交接的方式复用上面的普通接口——先用会话创建接口在同一 Agent 下新建 Session(选定新模型并沿用源 Workspace),再 POST /tasks 发送以 `[model_switch_from]` 源块开头的首条消息(源会话 id、其 `tracePath`、Workspace 与原模型二元组),模型需要早前历史时自行读取该 Trace 文件。 ## 流式接口(SSE) diff --git a/packages/docs/content/sessions-and-traces.en.md b/packages/docs/content/sessions-and-traces.en.md index 1daf5e6..372afa1 100644 --- a/packages/docs/content/sessions-and-traces.en.md +++ b/packages/docs/content/sessions-and-traces.en.md @@ -81,7 +81,7 @@ Special case: if the latest Trace file ends with a completed compaction, that co ## Model switch (/model) -The Web's `/model` command changes models the way the @ handoff does: it creates a new Session under the same Agent via the ordinary session-creation API (the chosen model, **the source session's Workspace** — so files stay reachable), and the first message opens with a `[model_switch_from]` source block — the source session id, the absolute path of its latest Trace file, the Workspace, and the previous model pair — followed by whatever the user typed. The history is **not injected** into the new context: some models require thinking payloads and `fidelity` byte-for-byte when history is replayed, which cannot cross models — instead the model reads the source Trace file itself (JSONL, one message envelope per line) when it needs the earlier context. The source session and its Trace are untouched. +The Web's `/model` command changes models the way the `/agent` handoff does: picking a model stages it in the composer, and sending creates a new Session under the same Agent via the ordinary session-creation API (the chosen model, **the source session's Workspace** — so files stay reachable), and the first message opens with a `[model_switch_from]` source block — the source session id, the absolute path of its latest Trace file, the Workspace, and the previous model pair — followed by whatever the user typed. The history is **not injected** into the new context: some models require thinking payloads and `fidelity` byte-for-byte when history is replayed, which cannot cross models — instead the model reads the source Trace file itself (JSONL, one message envelope per line) when it needs the earlier context. The source session and its Trace are untouched. ## Field fidelity diff --git a/packages/docs/content/sessions-and-traces.zh.md b/packages/docs/content/sessions-and-traces.zh.md index ecc3baf..c69cead 100644 --- a/packages/docs/content/sessions-and-traces.zh.md +++ b/packages/docs/content/sessions-and-traces.zh.md @@ -81,7 +81,7 @@ Trace 是恢复的唯一事实来源,没有独立的会话数据库需要与 ## 模型切换(/model) -Web 的 `/model` 命令按 @ handoff 的方式换模型:用普通的会话创建接口在同一 Agent 下新建一个 Session(选定新模型,**沿用源会话的 Workspace**,文件因此保持可达),首条消息以 `[model_switch_from]` 源块开头——携带源会话 id、其最新 Trace 文件的绝对路径、Workspace 与原模型二元组,用户输入的剩余文字紧随其后。历史**不注入**新上下文:部分模型回放历史时要求 thinking 与 `fidelity` 逐字一致,跨模型注入不可行——模型需要早前上下文时按路径自行读取源 Trace 文件(JSONL,每行一个消息信封)。源会话与其 Trace 不受任何影响。 +Web 的 `/model` 命令按 `/agent` 交接的方式换模型:选中模型只是在输入框暂存,发送时才用普通的会话创建接口在同一 Agent 下新建一个 Session(选定新模型,**沿用源会话的 Workspace**,文件因此保持可达),首条消息以 `[model_switch_from]` 源块开头——携带源会话 id、其最新 Trace 文件的绝对路径、Workspace 与原模型二元组,用户输入的剩余文字紧随其后。历史**不注入**新上下文:部分模型回放历史时要求 thinking 与 `fidelity` 逐字一致,跨模型注入不可行——模型需要早前上下文时按路径自行读取源 Trace 文件(JSONL,每行一个消息信封)。源会话与其 Trace 不受任何影响。 ## 字段保真 diff --git a/packages/docs/content/web-app.en.md b/packages/docs/content/web-app.en.md index f9a5277..74c8c24 100644 --- a/packages/docs/content/web-app.en.md +++ b/packages/docs/content/web-app.en.md @@ -33,7 +33,7 @@ The interface language (中文 / English / system) and theme (light / dark / sys ### Creating a Conversation -A new conversation starts as a draft: pick the Agent, the Workspace (via a server-side directory browser), the approval mode, the model, and the thinking level before sending the first message. The Session is created on first send, and from then on its model and Workspace are locked. Switching the thinking level or the model in the draft makes the switched-to value the new default: the level is written back to the selected Agent's `model.thinking_level` immediately, and the picked model carries over as the next conversation's default. Inside an active session the thinking level is a per-turn parameter: the composer's picker starts out showing the Agent config's level and auto-follows it until touched (sends omit the level, so config edits keep taking effect); a pick sticks for the session and rides on every subsequent send (never written back to the Agent config); the model stays locked per session — the `/model` command switches models the way the @ handoff does: it opens a new session for the same Agent on the picked model, keeping the current Workspace, whose first message carries a `[model_switch_from]` source block (source session id, Trace file path, previous model) followed by whatever was left in the composer (an interface-language auto-line when empty). In the new session that block collapses into a "switched model" banner linking back to the source conversation, and the model reads the source Trace file itself when it needs the earlier history. +A new conversation starts as a draft: pick the Agent, the Workspace (via a server-side directory browser), the approval mode, the model, and the thinking level before sending the first message. The Session is created on first send, and from then on its model and Workspace are locked. Switching the thinking level or the model in the draft makes the switched-to value the new default: the level is written back to the selected Agent's `model.thinking_level` immediately, and the picked model carries over as the next conversation's default. Inside an active session the thinking level is a per-turn parameter: the composer's picker starts out showing the Agent config's level and auto-follows it until touched (sends omit the level, so config edits keep taking effect); a pick sticks for the session and rides on every subsequent send (never written back to the Agent config); the model stays locked per session — the `/model` command switches models the way the `/agent` handoff does: picking a model stages it as a chip in the composer, and **sending** opens a new session for the same Agent on the picked model, keeping the current Workspace, whose first message carries a `[model_switch_from]` source block (source session id, Trace file path, previous model) followed by whatever the composer held (an interface-language auto-line when empty). In the new session that block collapses into a "switched model" banner linking back to the source conversation, and the model reads the source Trace file itself when it needs the earlier history. There are four approval modes: `allow-all`, `deny-all`, `read-only` (only read-only tools pass), and `always-ask`. See [Tools and Approvals](/tools). @@ -47,9 +47,9 @@ There are four approval modes: `allow-all`, `deny-all`, `read-only` (only read-o ### Input and Shortcuts - Enter sends, Shift+Enter inserts a newline, and images can be pasted; -- Typing `/` opens the slash menu: trigger context compaction (`/compact`) or toggle installed Skills — chosen Skills are sent along with the message in a `[use_skills]` block; +- Typing `/` opens the slash menu: trigger context compaction (`/compact`), hand the conversation over to another Agent (`/agent`), switch the model (`/model`) — both switch commands appear in an active session only, since a draft has nothing to switch and picks its Agent and model up front — or toggle installed Skills — chosen Skills are sent along with the message in a `[use_skills]` block; - While a Task is running the input stays live and the toolbar keeps a single action button: an empty composer shows **Stop**, and typing turns it into **Send**, whose behavior follows the **mid-run send mode** from the toolbar's More-settings popover (a compact extensible settings panel, also available in draft state; the choice is remembered): **Steer** (default) delivers the text mid-run as a `[user_steering]` user message with the next turn, **Queue** holds the whole message server-side as a follow-up and auto-sends it as an ordinary new message when the run finishes (an "N queued" hint shows near the input until then; the queue survives page reloads); -- Typing `@` mentions another Agent to hand the conversation over to it; +- `/agent` and `/model` stage their pick instead of acting on it: the chosen Agent or model becomes a chip above the text body and nothing is sent yet, so you keep typing — Enter/Send is what hands the conversation over (a new chat for that Agent) or forks it onto the chosen model, carrying the text along; with an empty composer a default message is filled in, and the chip's × cancels. Both chips are cached with the draft, so they survive a reload or a trip to another conversation together with the text. A model fork additionally waits for the Session to be idle — it continues from the Session's Trace, which a running turn or a compaction is still writing — and a line above the composer says so while it waits; - When human approval is required, tool calls show inline allow/deny buttons in the message stream; the approval mode can be changed mid-Session; - While the engine waits out a reconnect backoff (≥2s), the retry line shows a live countdown to the next attempt with inline **Retry now** (skips the remaining wait) and **Give up** (the ordinary abort) controls; - When the model API rejects the Session's credentials (an authentication failure), the composer grays out and disables — recoverably: the Session pins only the model reference, and credentials come from the current Project config. The notice's primary button opens the Models page; saving a new API key there unlocks the composer by itself (open tabs unlock live via a `credentials_updated` event, and after a reload the composer stays unlocked because the credential update is newer than the recorded auth failure). A "Retry" button clears the state manually for another attempt (it re-arms if the key is still bad), a completed request always clears it, and "New Session" remains as the escape to a fresh draft. The disabled composer keeps its draft selectable, so a long message that failed to send can still be copied out. diff --git a/packages/docs/content/web-app.zh.md b/packages/docs/content/web-app.zh.md index 0a654e5..6ed93e6 100644 --- a/packages/docs/content/web-app.zh.md +++ b/packages/docs/content/web-app.zh.md @@ -33,7 +33,7 @@ penguin web ### 新建会话 -新会话从草稿开始:先选择 Agent、Workspace(服务器端目录浏览器选取)、审批模式、模型与思考等级,再发送第一条消息。Session 在首次发送时才真正创建,此后该会话的模型与 Workspace 即被锁定。草稿里切换思考等级或模型时,切换后的值即成为新的默认:思考等级立即写回所选 Agent 的 `model.thinking_level`,所选模型则作为下一个新会话的默认延续。进行中的会话里,思考等级是逐轮参数:输入区拾取器初始显示 Agent 配置的档位并自动跟随(未选择时发送不携带档位,配置修改持续生效),选定后即固定为该会话档位、随每次发送下发(不写回 Agent 配置);模型仍在会话内锁定,改用 `/model` 命令切换模型——与 @ handoff 同一方式:在同一 Agent 下新建一个使用所选模型、沿用当前 Workspace 的会话并跳转,其首条消息携带 `[model_switch_from]` 源块(源会话 id、Trace 文件路径、原模型),输入框剩余文字随之发出(为空时发一句界面语言的自动消息);新会话中该源块折叠为一条“已切换模型”横幅,可点击回到原会话,模型需要早前历史时按路径自行读取源 Trace 文件。 +新会话从草稿开始:先选择 Agent、Workspace(服务器端目录浏览器选取)、审批模式、模型与思考等级,再发送第一条消息。Session 在首次发送时才真正创建,此后该会话的模型与 Workspace 即被锁定。草稿里切换思考等级或模型时,切换后的值即成为新的默认:思考等级立即写回所选 Agent 的 `model.thinking_level`,所选模型则作为下一个新会话的默认延续。进行中的会话里,思考等级是逐轮参数:输入区拾取器初始显示 Agent 配置的档位并自动跟随(未选择时发送不携带档位,配置修改持续生效),选定后即固定为该会话档位、随每次发送下发(不写回 Agent 配置);模型仍在会话内锁定,改用 `/model` 命令切换模型——与 `/agent` 交接同一方式:选中模型只是在输入框暂存为一枚 chip,**发送时**才在同一 Agent 下新建一个使用所选模型、沿用当前 Workspace 的会话并跳转,其首条消息携带 `[model_switch_from]` 源块(源会话 id、Trace 文件路径、原模型),输入框中的文字随之发出(为空时发一句界面语言的自动消息);新会话中该源块折叠为一条“已切换模型”横幅,可点击回到原会话,模型需要早前历史时按路径自行读取源 Trace 文件。 审批模式共四种:`allow-all`(全部放行)、`deny-all`(全部拒绝)、`read-only`(仅放行只读工具)、`always-ask`(每次询问),详见[工具与审批](/tools)。 @@ -47,9 +47,9 @@ penguin web ### 输入与快捷操作 - Enter 发送,Shift+Enter 换行,支持粘贴图片; -- 输入 `/` 打开快捷菜单:触发上下文压缩(`/compact`),或勾选已安装的 Skill——所选 Skill 会以 `[use_skills]` 块随消息发送; +- 输入 `/` 打开快捷菜单:触发上下文压缩(`/compact`)、把会话交接给其他 Agent(`/agent`)、切换模型(`/model`)——两个切换命令都只在进行中的会话里提供,草稿没有可切换的对话,Agent 与模型本就在草稿页选定——或勾选已安装的 Skill——所选 Skill 会以 `[use_skills]` 块随消息发送; - Task 运行期间输入框保持可用,工具条只保留一个操作按钮:输入框为空时是**停止**,一旦输入内容即变为**发送**,其行为遵循工具条「更多设置」弹出分组中的**运行中发送方式**(一个可扩展的设置面板,草稿态同样可设,选择会被记忆):**插话**(默认)把文字以 `[user_steering]` 用户消息随下一轮送达运行中的 Agent;**排队** 把整条消息暂存在服务端,本轮结束后自动作为普通新消息发出(期间在输入框附近显示「N 条已排队」提示;队列存放在服务端,刷新页面不丢失); -- 输入 `@` 提及其他 Agent,将会话交接给它; +- `/agent` 与 `/model` 都是暂存而非立即生效:选中的 Agent 或模型只在文本区上方留下一枚 chip,此时不发送任何内容,可以继续输入——按 Enter / 点发送才真正交接(为该 Agent 新开一个对话)或换用所选模型继续本对话,输入的文字随之带走;正文为空时自动填入默认消息,点 chip 上的 × 即可取消。两枚 chip 都随草稿缓存,刷新页面或切到别的会话再回来时与文字一同恢复;其中切换模型还需等待会话空闲——新会话要从本会话的 Trace 接续,而运行中的一轮或压缩仍在写入——等待期间输入框上方会给出说明; - 需要人工审批时,工具调用在消息流中内联显示“允许 / 拒绝”按钮;审批模式在会话中途可随时调整; - 引擎在重连退避等待(≥2 秒)期间,重试提示行会实时倒计时到下一次尝试,并内联提供**立即重试**(跳过剩余等待)与**放弃**(普通中断)两个按钮; - 模型 API 拒绝该 Session 的凭据(鉴权失败)时,输入框会置灰禁用——但可恢复:Session 锁定的只是模型引用,凭据取自当前 Project 配置。提示条的主按钮跳转到模型配置页;在那里保存新的 API key 后输入框会自动解锁(已打开的标签页经 `credentials_updated` 事件即时解锁;刷新后也保持解锁,因为凭据更新时间晚于记录的鉴权失败时间)。「重试」按钮可手动清除该状态再试一次(key 仍无效时会重新变灰),一次成功的请求总会清除该状态,「新建会话」仍作为跳到全新草稿的出口。禁用态的输入框保留草稿且可选中——发送失败的长消息仍能复制出来。 diff --git a/packages/skills/skills/agent-optimization/SKILL.md b/packages/skills/skills/agent-optimization/SKILL.md index ea0f1d8..47eb198 100644 --- a/packages/skills/skills/agent-optimization/SKILL.md +++ b/packages/skills/skills/agent-optimization/SKILL.md @@ -3,8 +3,8 @@ name: agent-optimization description: Improve an Agent State from direct feedback or versioned multi-Case Benchmark scores and score-linked Traces. short_description: Improve an Agent from feedback or measured Benchmark results. short_description_zh: 根据反馈或 Benchmark 结果改进 Agent。 -version: 5 -updated: 2026-07-26T00:00:00Z +version: 6 +updated: 2026-07-29T00:00:00Z --- # Agent Optimization @@ -19,7 +19,7 @@ Benchmark mode requires a top-level Session with `run_subagent`, a complete base ## Pick the target Agent -A one-shot request normally names the target. A delegated request begins with `Caller agent: `, and an @-mention handoff contains `[handoff_from]`. When one-shot mode has no explicit target, use that caller or origin; if neither exists, ask. Benchmark mode always requires an explicit Test Agent and Benchmark. +A one-shot request normally names the target. A delegated request begins with `Caller agent: `, and an `/agent` handoff contains `[handoff_from]`. When one-shot mode has no explicit target, use that caller or origin; if neither exists, ask. Benchmark mode always requires an explicit Test Agent and Benchmark. Resolve paths from the Environment's App Data Dir without recursively discovering the Project: diff --git a/packages/web/e2e/draft.spec.mjs b/packages/web/e2e/draft.spec.mjs index c41d1fd..8d138bf 100644 --- a/packages/web/e2e/draft.spec.mjs +++ b/packages/web/e2e/draft.spec.mjs @@ -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(); }); diff --git a/packages/web/src/features/chat/agent-handoff.ts b/packages/web/src/features/chat/agent-handoff.ts new file mode 100644 index 0000000..5fc1c43 --- /dev/null +++ b/packages/web/src/features/chat/agent-handoff.ts @@ -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"; +} diff --git a/packages/web/src/features/chat/agent-mentions.ts b/packages/web/src/features/chat/agent-mentions.ts deleted file mode 100644 index a75a77b..0000000 --- a/packages/web/src/features/chat/agent-mentions.ts +++ /dev/null @@ -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 `@` 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 `@`, 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 - * `@`, 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() }; -} diff --git a/packages/web/src/features/chat/chat-input.tsx b/packages/web/src/features/chat/chat-input.tsx index 0135a79..b750f62 100644 --- a/packages/web/src/features/chat/chat-input.tsx +++ b/packages/web/src/features/chat/chat-input.tsx @@ -17,15 +17,23 @@ * session), shown as a read-only tag from session_meta; * `/` opens the slash command menu (`/compact` compresses context, replacing the button; each * installed skill gets its own entry; pressing Enter on `/` toggles that skill's - * selection without sending). Matching is positional like `@`: a slash opens the menu from any - * caret position, running a command removes just that token, and Escape only dismisses the menu — + * selection without sending). Matching is positional: a slash opens the menu from any caret + * position, running a command removes just that token, and Escape only dismisses the menu — * the rest of the draft is never touched; - * `@` opens the agent selection menu; once picked it becomes a fixed highlighted target chip - * above the text body (only one allowed, picking again replaces it; removed via backspace or the - * x button); only a leading `@` at the start of the text counts — typing or pasting text starting - * with `@` also works the same way, while an `@` in the middle of the text is just plain - * text. Sending doesn't use the current Session: it opens a new chat for the target agent instead, - * and the text body carries no `@` marker; + * `/agent` and `/model` are the two **switch** commands, both offered in an active Session + * only — a draft has no conversation to switch, and picks its Agent and model in the draft + * page's own selectors. Both are staged rather than immediate: running one consumes its token + * and opens a picker (agents / models), and the pick becomes a highlighted chip above the text + * body instead of switching on the spot. The user + * keeps typing; **Enter/Send** performs the switch — an agent chip hands the conversation off to + * a new chat for that agent (the current Session is not sent to), a model chip forks this + * conversation onto the picked model. A model fork additionally waits for this Session to be + * idle (it branches off a Trace that a run or a compaction is still appending to) and says so + * above the composer rather than just disabling Send. With an empty text body the default + * auto-message is filled in. Only one chip at a time (picking either clears the other, picking + * the model already in use clears the staging, and both are exclusive with goal mode); a chip is + * removed via backspace at the start of the text or its x button, and both are cached with the + * draft so they survive a session switch or reload along with the text they belong to; * The bottom toolbar provides a searchable multi-select skills dropdown (styled like the model * selector: a top search box filtering by name and localized description, plus a checklist; * clicking a row toggles its selection without closing the menu; the button = book icon + label + @@ -49,7 +57,7 @@ * centering is decided by the page. */ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import type { ChangeEvent, ClipboardEvent, KeyboardEvent, ReactNode } from "react"; +import type { ChangeEvent, ClipboardEvent, KeyboardEvent, ReactNode, RefObject } from "react"; import type { AgentSummary, ApprovalMode, @@ -63,6 +71,8 @@ import { S } from "../../lib/strings"; import { humanizeTokens } from "../../lib/format"; import { resolveContextWindow } from "../../lib/context"; import { useLocale } from "../../state/locale"; +import { agentDisplayName } from "../../state/project"; +import { AgentAvatar } from "../../components/ui/agent-avatar"; import { Dropdown } from "../../components/ui/dropdown"; import { GlyphIcon } from "../../components/ui/glyph-icon"; import { noAutofill } from "../../components/ui/input"; @@ -76,7 +86,7 @@ import { sameModelRef, visibleChatModels, } from "../models/model-grouping"; -import { filterAgents, matchMention, splitLeadingMention } from "./agent-mentions"; +import { filterAgents, stagedSendRoute } from "./agent-handoff"; import { matchSlash, removeSlashToken } from "./slash-token"; import { SELECTABLE_THINKING_LEVELS, thinkingLevelLabel } from "./thinking-level"; import { @@ -233,6 +243,114 @@ function modelLabel(m: ModelInfo): string { const NO_KEY_ICON = "M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4M2 2l20 20"; +/** + * Candidate panel shared by every picker in this file (the model dropdown / `/model` switch + * picker and the `/agent` handoff picker): the search box, the internal scroll cap, the row + * chrome, the keyboard navigation and the "current entry" marker slot all live here, so the + * two pickers can differ only in what a row *contains* (provider logo vs Agent avatar) and in + * what they hang below the list (`footer`, e.g. the model list's "show all" expander). + * + * Keyboard navigation deliberately starts with **no** row highlighted: the search box is + * autofocused, and pre-highlighting a row would repaint a panel that has looked the same since + * before this control existed. ArrowDown/ArrowUp begin the navigation, and Enter/Tab commits — + * the highlighted row if there is one, otherwise the top match, which is what makes "type a few + * letters, press Enter" work. Escape is NOT handled here: each host closes its own panel at the + * window level (an IME-safe handler for the switch pickers, Dropdown's for the model dropdown). + */ +function PickerList({ + items, + itemKey, + isCurrent, + query, + onQueryChange, + searchPlaceholder, + emptyText, + onPick, + renderRow, + footer, +}: { + items: T[]; + /** Stable React key AND identity for the highlighted row. */ + itemKey: (item: T) => string; + /** Marks the entry already in effect (the session's model / its Agent): renders the ✓ slot and the emphasized row style. */ + isCurrent?: (item: T) => boolean; + query: string; + onQueryChange: (query: string) => void; + searchPlaceholder: string; + /** Shown in place of the list when the query matches nothing. */ + emptyText: string; + onPick: (item: T) => void; + /** The row's own content, left of the ✓ slot. */ + renderRow: (item: T) => ReactNode; + /** Pinned below the scroll area (mirroring the search box above it). */ + footer?: ReactNode; +}) { + // -1 = nothing highlighted yet (see the note above); reset whenever the candidate set changes. + const [active, setActive] = useState(-1); + const activeKey = active >= 0 && active < items.length ? itemKey(items[active]!) : null; + const onKeyDown = (e: KeyboardEvent) => { + if (items.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((i) => (i + 1) % items.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((i) => (i <= 0 ? items.length - 1 : i - 1)); + return; + } + // Same guard as the composer's own Enter handling: an IME commit must not be read as a pick. + if (((e.key === "Enter" && !e.shiftKey) || e.key === "Tab") && !e.nativeEvent.isComposing) { + e.preventDefault(); + onPick(items[active >= 0 ? active : 0]!); + } + }; + return ( +
+ {/* Quick search (autofocused: it also owns the keyboard while the panel is up) */} +
+ { + onQueryChange(e.target.value); + setActive(-1); + }} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + {...noAutofill} + className="w-full rounded border border-transparent bg-transparent px-1 py-0.5 text-xs text-gray-700 placeholder:text-gray-400 focus:outline-none dark:text-gray-200 dark:placeholder:text-gray-500" + /> +
+
+ {items.length === 0 &&

{emptyText}

} + {items.map((item) => { + const key = itemKey(item); + const current = isCurrent?.(item) ?? false; + return ( + + ); + })} +
+ {footer} +
+ ); +} + /** * Model candidate panel (search box + grouped list + "show all" expander) shared by the * draft-state ModelSelect dropdown and the in-session `/model` switch picker. Search and @@ -272,81 +390,65 @@ function ModelMenuList({ : visibleChatModels(models, { showAll: true, query, selected: value, defaultModel }).length - visible.length; return ( - <> - {/* Quick search: supports model id / display name / provider name */} -
- setQuery(e.target.value)} - placeholder={S.models.searchPlaceholder} - aria-label={S.models.searchPlaceholder} - {...noAutofill} - className="w-full rounded border border-transparent bg-transparent px-1 py-0.5 text-xs text-gray-700 placeholder:text-gray-400 focus:outline-none dark:text-gray-200 dark:placeholder:text-gray-500" - /> -
-
- {visible.length === 0 && ( -

{S.models.noSearchResults}

- )} - {visible.map((m) => ( - - ))} -
- {/* Bottom expander row (pinned below the scroll area, mirroring the search box on top): - reveals the models hidden by the configured-key filter in place — the menu stays open - and the selection is untouched. */} - {hiddenCount > 0 && ( -
- -
+ )} + {/* Key-less rows (visible via show-all / selected / default / no-key-at-all) carry a + struck-through key icon (the "no key" text lives in the title/aria-label). */} + {!hasConfiguredKey(m) && ( + + + + )} + {sameModelRef(m, defaultModel) && ( + + {S.models.default} + + )} + )} - + // Bottom expander row (pinned below the scroll area, mirroring the search box on top): + // reveals the models hidden by the configured-key filter in place — the menu stays open + // and the selection is untouched. + {...(hiddenCount > 0 + ? { + footer: ( +
+ +
+ ), + } + : {})} + /> ); } @@ -431,6 +533,88 @@ function ModelSelect({ ); } +/** + * Agent candidate panel for the `/agent` switch picker — the agent-side counterpart of + * ModelMenuList, and now literally the same panel (PickerList: search, scroll cap, keyboard + * navigation, current-entry marker). Only the row differs: the Agent avatar (the same identity + * tile the draft Agent picker uses), the agentId in monospace — the id is what identifies an + * Agent everywhere else in the app — and the display name after it when it differs. The + * conversation's own Agent is marked like the model list marks the session's model; picking it + * is still a real action (a fresh conversation with the same Agent), not a no-op. + */ +function AgentMenuList({ + agents, + currentAgentId, + onPick, +}: { + agents: AgentSummary[]; + /** The Agent this conversation already belongs to (marked ✓); undefined while it is unknown. */ + currentAgentId?: string; + onPick: (agent: AgentSummary) => void; +}) { + const [query, setQuery] = useState(""); + return ( + a.agentId} + isCurrent={(a) => a.agentId === currentAgentId} + query={query} + onQueryChange={setQuery} + // Quick search: supports agentId / display name + searchPlaceholder={S.chat.agentSearchPlaceholder} + emptyText={S.chat.agentsNoMatch} + onPick={onPick} + renderRow={(a) => ( + <> + + {a.agentId} + {a.name && a.name !== a.agentId && ( + + {a.name} + + )} + + )} + /> + ); +} + +/** + * Popup frame shared by the two `/` switch pickers (`/model`, `/agent`): the upward-opening + * panel and its title bar. It opens upward from the composer and is height-capped to the room + * actually measured above it (see upwardMaxH), so it can never render off-screen; the panel has + * no trigger button of its own, so dismissal (click-outside / Escape) is handled by the host. + */ +function SwitchPickerPanel({ + panelRef, + maxHeight, + title, + children, +}: { + panelRef: RefObject; + maxHeight: number | undefined; + title: string; + children: ReactNode; +}) { + return ( +
+
+ {title} +
+ {children} +
+ ); +} + /** Spark glyph for the thinking-level picker (24x24 line path, consistent with the toolbar icon set). */ const SPARK_ICON = "M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z"; @@ -921,6 +1105,7 @@ export function ChatInput({ modeSaving, autoFocus, agents, + currentAgentId, skills, initialSkills, onSkillsChange, @@ -929,6 +1114,8 @@ export function ChatInput({ onTextChange, initialHandoffTargetId, onHandoffTargetChange, + initialPendingModelRef, + onPendingModelChange, modelAuthDead = false, onOpenModels, onRetryModelAuth, @@ -965,11 +1152,12 @@ export function ChatInput({ /** Server-reported queued follow-up count (from task_state): renders the "N queued" hint until they auto-send. */ queuedFollowUps?: number; /** - * Used instead of onSend when an @ target is present (chip or a leading @ typed manually): - * opens a new chat for the target agent (the text body carries no @ marker, and the current - * Session receives no message). Returns whether it succeeded (draft kept on failure). + * Used instead of onSend when an `/agent` target chip is staged: opens a new chat for the + * target agent (the current Session receives no message). Returns whether it succeeded + * (draft kept on failure). Supplied for an active Session only — a draft has no conversation + * to hand over, so `/agent` is not offered there (same gating as `/model`'s onSwitchModel). */ - onHandoff: (target: AgentSummary, input: TaskInputPart[]) => Promise; + onHandoff?: (target: AgentSummary, input: TaskInputPart[]) => Promise; onStop: () => Promise; onCompact: () => Promise; /** Currently selected model reference ((provider, modelId) is the unique key); null = not yet chosen. */ @@ -984,10 +1172,10 @@ export function ChatInput({ onChangeModel?: (ref: ModelRefDto) => void; /** * Session state: model switch via the `/model` command — forks the session onto the picked - * model (a NEW session carrying this conversation) and navigates there; any text remaining - * after the command token is posted as the new session's first task. Returns whether it - * succeeded (draft kept on failure). Only passed for an active session (the command is - * additionally gated on not running/compacting); picking the current model is a no-op. + * model (a NEW session carrying this conversation) and navigates there; the draft written + * after the pick is posted as the new session's first task. Returns whether it succeeded + * (draft kept on failure). Only passed for an active session (the command is additionally + * gated on not running/compacting); picking the current model is a no-op. */ onSwitchModel?: (ref: ModelRefDto, input: TaskInputPart[]) => Promise; /** Project default model (marked "default" on the selector's candidate item). */ @@ -1028,8 +1216,10 @@ export function ChatInput({ onChangeApprovalMode: (mode: ApprovalMode) => void; modeSaving: boolean; autoFocus?: boolean; - /** Agent list of the current Project: typing `@` opens the agent selection popup. */ + /** Agent list of the current Project: the `/agent` command's candidates (without any, the command isn't offered). */ agents: AgentSummary[]; + /** The Agent this composer already belongs to (the Session's, or the draft's selection): marked as the current entry in the `/agent` picker. */ + currentAgentId?: string; /** * Skills installed on the current Agent (in session state, fetched by chat-page keyed on the * Session's Agent; in draft state, fetched by draft-view keyed on the selected Agent; a failed @@ -1050,16 +1240,25 @@ export function ChatInput({ /** Draft's initial text (restored on mount; paired with onTextChange for draft auto-caching). */ initialText?: string; /** - * Callback when the user edits the text body (including paths that rewrite the text such as @ - * selection / slash clearing); the clear after a successful send does **not** call back — at + * Callback when the user edits the text body (including paths that rewrite the text such as + * running a slash command); the clear after a successful send does **not** call back — at * that point the parent has already cleared the draft cache entirely, and calling back would * resurrect it. */ onTextChange?: (text: string) => void; - /** Draft restore: the agentId of the @ handoff target (resolved once agents are ready; discarded if stale). */ + /** Draft restore: the agentId of the staged handoff target (resolved once agents are ready; discarded if stale). */ initialHandoffTargetId?: string; - /** Callback when the @ handoff target changes (selected/removed; the clear after a successful send does not call back, same as onTextChange). */ + /** Callback when the staged handoff target changes (picked/removed; the clear after a successful send does not call back, same as onTextChange). */ onHandoffTargetChange?: (agentId: string | null) => void; + /** + * Draft restore: the staged `/model` switch target (resolved once models are ready; discarded + * when that model is gone or is the one this session already runs). Cached for the same + * reason as the handoff target — the composer text survives an unmount, so its chip must too, + * or Enter would post the message to the current session on the old model. + */ + initialPendingModelRef?: ModelRefDto; + /** Callback when the staged model switch changes (picked/removed; the clear after a successful send does not call back, same as onTextChange). */ + onPendingModelChange?: (ref: ModelRefDto | null) => void; /** * Session state: the Session's model credentials failed authentication (abort with * status "auth") and the Project's credentials have not been updated since (the parent @@ -1089,26 +1288,31 @@ export function ChatInput({ const [images, setImages] = useState([]); const [busy, setBusy] = useState(false); const [slashIndex, setSlashIndex] = useState(0); - // Slash token start where Escape closed the menu (mirrors mentionDismissed: the menu stays shut for that one token). + // Slash token start where Escape closed the menu: it stays shut for that one token. const [slashDismissed, setSlashDismissed] = useState(null); - // /model switch picker (session state): opened by the /model command. The command consumes - // its slash token immediately (same as /compact), so closing the picker — Escape, click - // outside, or the picked-current-model no-op — can never re-open the slash menu, and there - // is no stale token range to recompute at pick time; whatever text remains is the draft - // (and becomes the new session's first message on a successful pick). + // Switch pickers (opened by /model — session state — and /agent). Each command consumes its + // slash token immediately (same as /compact), so closing a picker — Escape, click outside, or + // the picked-current-model no-op — can never re-open the slash menu, and there is no stale + // token range to recompute at pick time; whatever text remains is the draft (and becomes the + // new session's first message once the staged switch is sent). const [modelSwitchOpen, setModelSwitchOpen] = useState(false); const modelSwitchRef = useRef(null); + const [agentSwitchOpen, setAgentSwitchOpen] = useState(false); + const agentSwitchRef = useRef(null); // Anchor for the popups that open upward, and the room actually available above them. const anchorRef = useRef(null); const [upwardMaxH, setUpwardMaxH] = useState(); - // @ handoff target (chip, fixed at the front of the input); only one allowed, picking again replaces it directly. + // Staged handoff target from /agent (chip, fixed at the front of the input); only one allowed, picking again replaces it directly. const [target, setTarget] = useState(null); + // Staged model switch from /model (chip too), cached in the draft exactly like the handoff + // target: the composer's text is cached and this component is keyed by session id, so a chip + // kept only in component state would disappear on a session switch while the text it belongs + // to came back — and Enter would then post that text to the current session on the old model. + const [pendingModel, setPendingModel] = useState(null); // Selected skills (dropdown checklist, multi-select): initial value comes from draft restore (quick-invoke pre-selection), cleared on successful send. const [selectedSkills, setSelectedSkills] = useState(initialSkills ?? []); - // @ mention: cursor position (tracked via onChange/onSelect), candidate highlight, and the mention start where Escape closes it. + // Cursor position (tracked via onChange/onSelect): the slash menu matches the token at the caret. const [caret, setCaret] = useState(0); - const [mentionIndex, setMentionIndex] = useState(0); - const [mentionDismissed, setMentionDismissed] = useState(null); const textareaRef = useRef(null); // Short placeholder on narrow screens: a long hint would wrap and get clipped in a single-line textarea. const [narrow] = useState(() => window.matchMedia("(max-width: 767px)").matches); @@ -1116,8 +1320,8 @@ export function ChatInput({ const running = status === "running"; const compacting = status === "compacting"; // Goal mode (engaged via the "+" menu or /goal): the text body becomes the objective. It is - // exclusive with the @ handoff target (engaging either clears the other) and with images - // (the objective is re-injected every round as plain text); selected skills ride the + // exclusive with a staged /agent or /model switch (engaging either clears the other) and with + // images (the objective is re-injected every round as plain text); selected skills ride the // round-1 message as a [use_skills] block, exactly like a normal send. const [goalOn, setGoalOn] = useState(false); const [goalBudgetText, setGoalBudgetText] = useState(""); @@ -1131,9 +1335,22 @@ export function ChatInput({ goalBudget !== null && goalBudget !== UNLIMITED_BUDGET ? S.chat.goalBudgetValue(humanizeTokens(goalBudget)) : S.chat.goalBudgetUnlimited; - // Sending is also allowed with only an @ target (chip) or skills selected and no text: a handoff's - // first message may be just a [handoff_from] source block; with skills and empty text, the sent - // text automatically falls back to S.chat.skillsAutoMessage (see send). Goal mode instead + /** + * Where a send with a staged chip would go — and, for a `/model` fork, whether it may go at + * all right now (see stagedSendRoute): a fork branches a NEW session off this session's + * Trace, so it waits for the session to be idle. Both the eligibility below and the send path + * read this one value, so the button and what the button does can't disagree. + */ + const stagedRoute = stagedSendRoute({ + handoffTarget: target !== null, + pendingModel: pendingModel !== null, + canSwitchModel: onSwitchModel !== undefined, + sessionBusy: running || compacting, + }); + // Sending is also allowed with only a staged switch chip (/agent or /model) or skills selected + // and no text: a handoff's first message may be just a [handoff_from] source block, and the + // empty-text fallbacks fill in the rest (S.chat.skillsAutoMessage with skills selected, + // S.chat.modelSwitchAutoMessage for a staged model switch — see sendNormal). Goal mode instead // requires a text objective and a parseable budget — and an open editor showing an invalid // draft disables Send outright: combined with the editor refusing to close over an invalid // draft (below), no click sequence can fire a goal with a stale committed budget. @@ -1150,6 +1367,7 @@ export function ChatInput({ : text.trim().length > 0 || images.length > 0 || target !== null || + pendingModel !== null || selectedSkills.length > 0); /** @@ -1193,9 +1411,10 @@ export function ChatInput({ }, [goalBudgetDraft]); /** - * Engage/exit goal mode; engaging clears the @ target and images (genuinely exclusive: - * a handoff opens another session, and the server rejects non-text goal input). Selected - * skills stay — they ride the round-1 message as a [use_skills] block, like a normal send. + * Engage/exit goal mode; engaging clears any staged switch chip and the images (genuinely + * exclusive: a handoff or a model switch opens another session, and the server rejects + * non-text goal input). Selected skills stay — they ride the round-1 message as a + * [use_skills] block, like a normal send. */ const toggleGoal = useCallback( (on: boolean) => { @@ -1206,18 +1425,21 @@ export function ChatInput({ setGoalBudgetText(""); setTarget(null); onHandoffTargetChange?.(null); + setPendingModel(null); + onPendingModelChange?.(null); // Images can't ride a goal (the server rejects non-text goal input): clear any already // attached, or canSend would stay silently false with the objective looking ready. setImages([]); } }, - [onHandoffTargetChange], + [onHandoffTargetChange, onPendingModelChange], ); // Mid-run steering: while running, Enter/send queues plain text for the running agent // (delivered between turns as a [user_steering] user message). Text only — images / skills / - // @ target stay in the draft for a later normal send (an @ target also blocks steering: a - // leading mention means a handoff, not a message to this agent). + // a staged switch stay in the draft for a later normal send (a staged /agent or /model chip + // also blocks steering: the text belongs to the conversation the switch is about to open, not + // to the agent running here). // `!goalOn`: with the goal chip engaged the text is an OBJECTIVE — steering it into a run // that happens to be active (e.g. a schedule fired) would silently repurpose it. const canSteer = @@ -1227,6 +1449,7 @@ export function ChatInput({ !modelAuthDead && onSteer !== undefined && target === null && + pendingModel === null && text.trim().length > 0; // Mid-run send mode (owner directive): the user chooses between "steer" (delivered // mid-run as a [user_steering] input) and "follow-up" (held server-side and auto-sent as @@ -1240,15 +1463,23 @@ export function ChatInput({ localStorage.setItem(STEER_MODE_KEY, mode); }; const followUpMode = steerMode === "followup" && onQueueFollowUp !== undefined; - // A follow-up is a full normal message: the whole draft (text / images / skills / handoff) - // is eligible, same content rule as canSend. + // A follow-up is a full normal message: the whole draft (text / images / skills / a staged + // switch) is eligible, same content rule as canSend. + // `stagedRoute !== "blocked"`: a staged model fork is never eligible mid-run — the follow-up + // path composes the whole draft and then hands it to onSwitchModel rather than to the queue, + // so without this gate Enter would fork off a Trace that is still being written. const canFollowUp = running && !busy && !goalOn && !modelAuthDead && followUpMode && - (text.trim().length > 0 || images.length > 0 || target !== null || selectedSkills.length > 0); + stagedRoute !== "blocked" && + (text.trim().length > 0 || + images.length > 0 || + target !== null || + pendingModel !== null || + selectedSkills.length > 0); // The single action button's mode: while running, an **empty** composer means Stop // (abort); as soon as there is something to send it becomes the send button (steer or // follow-up per the remembered mode). Idle/compacting is always send. @@ -1259,6 +1490,7 @@ export function ChatInput({ text.trim().length === 0 && images.length === 0 && target === null && + pendingModel === null && selectedSkills.length === 0; // Queued hint: shown after a successful steer until the message shows up in the stream // (steeringDeliveredCount increases past the baseline captured at queue time) or the run @@ -1327,6 +1559,23 @@ export function ChatInput({ }, ] : []), + // Agent handoff: same shape as /model — the command consumes its token and opens the + // agent picker, whose pick is staged as a chip and only acted on at send time. Gated the + // same way too: the parent passes onHandoff for an active Session only, because a draft + // has nothing to hand over (and already picks its Agent in the draft page's own + // selector). Candidates must exist, or the picker would open empty. + ...(onHandoff && agents.length > 0 + ? [ + { + cmd: "/agent", + desc: S.chat.switchAgent, + run: () => { + clearInput(); + setAgentSwitchOpen(true); + }, + }, + ] + : []), // Each installed skill gets its own entry: `/` toggles that skill's selection (without sending), description follows the UI language. ...skillSlashItems(skills, locale).map((s) => ({ cmd: s.cmd, @@ -1341,6 +1590,7 @@ export function ChatInput({ onCompact, onSwitchModel, models, + agents, onTextChange, skills, locale, @@ -1348,11 +1598,14 @@ export function ChatInput({ toggleGoal, goalOn, ]); - // Positional matching (like @ mentions): a slash opens the menu from any caret position; - // running a command removes just the token, leaving the rest of the text intact. Doesn't - // reopen after Escape until the caret sits on a different token; suppressed while the - // /model picker is open (its trigger token is still in the text). - const slashTok = !running && !compacting && !modelSwitchOpen ? matchSlash(text, caret) : null; + // Positional matching: a slash opens the menu from any caret position; running a command + // removes just the token, leaving the rest of the text intact. Doesn't reopen after Escape + // until the caret sits on a different token; suppressed while a switch picker is open (the + // picker took over the interaction, and its own search box owns the keyboard). + const slashTok = + !running && !compacting && !modelSwitchOpen && !agentSwitchOpen + ? matchSlash(text, caret) + : null; slashMatchRef.current = slashTok; const slashMatches = slashTok && slashTok.start !== slashDismissed @@ -1361,27 +1614,29 @@ export function ChatInput({ const slashOpen = slashMatches.length > 0; const activeSlash = slashMatches[Math.min(slashIndex, slashMatches.length - 1)]; - // @ subagent menu: the `@prefix` currently being typed at the cursor drives candidate - // filtering (the slash menu and the /model picker take priority; doesn't reopen after Escape). - const mention = - !running && !compacting && !slashOpen && !modelSwitchOpen ? matchMention(text, caret) : null; - const mentionMatches = - mention && mention.start !== mentionDismissed ? filterAgents(agents, mention.query) : []; - const mentionOpen = mentionMatches.length > 0; - const activeMention = mentionMatches[Math.min(mentionIndex, mentionMatches.length - 1)]; - - // Close the /model picker on click-outside / Escape (same convention as Dropdown; the - // panel has no trigger button of its own, so the handling lives here). + // Close a switch picker on click-outside / Escape (same convention as Dropdown; these panels + // have no trigger button of their own, so the handling lives here). Only one can be open at a + // time — the slash menu that opens them is suppressed while either is up. useEffect(() => { - if (!modelSwitchOpen) return; + if (!modelSwitchOpen && !agentSwitchOpen) return; + // Dismissing the panel puts the caret back where the user was typing: the picker's search + // box stole the focus when it opened, and without this it would be left on . + const closeAll = () => { + setModelSwitchOpen(false); + setAgentSwitchOpen(false); + textareaRef.current?.focus(); + }; // globalThis.* event types: the React ones imported above would shadow the DOM ones here. const onClick = (e: globalThis.MouseEvent) => { - if (modelSwitchRef.current && !modelSwitchRef.current.contains(e.target as Node)) { - setModelSwitchOpen(false); - } + const panel = modelSwitchOpen ? modelSwitchRef.current : agentSwitchRef.current; + if (panel && !panel.contains(e.target as Node)) closeAll(); }; const onKey = (e: globalThis.KeyboardEvent) => { - if (e.key === "Escape") setModelSwitchOpen(false); + // `isComposing`: Escape while an IME candidate list is up means "drop the candidates", + // not "close the picker". Closing there would be unrecoverable — the command already + // consumed its `/agent` / `/model` token, so the user's remaining draft is all they have + // and the picker is the only way back to the pick they were making. + if (e.key === "Escape" && !e.isComposing) closeAll(); }; window.addEventListener("mousedown", onClick); window.addEventListener("keydown", onKey); @@ -1389,44 +1644,59 @@ export function ChatInput({ window.removeEventListener("mousedown", onClick); window.removeEventListener("keydown", onKey); }; - }, [modelSwitchOpen]); + }, [modelSwitchOpen, agentSwitchOpen]); + + /** Stage a model as the /model chip (null = drop it), keeping the draft cache in step. */ + const stageModel = (m: ModelInfo | null) => { + setPendingModel(m); + onPendingModelChange?.(m ? { provider: m.provider, modelId: m.modelId } : null); + }; /** - * /model pick: the CURRENT model is a no-op (close only), and the run state is re-checked - * — the picker may have survived a status flip (a task/compaction starting while it was - * open). Otherwise the pick opens a new session on the chosen model via onSwitchModel, - * with the first-task input assembled **like a normal send**: the remaining draft text - * (wrapped with the selected skills; an interface-language auto-line when empty — same - * convention as the skills auto message) plus the attached images. On failure the draft - * is kept so the user can retry. + * /model pick: **stages** the model as a chip instead of switching on the spot — the user + * keeps typing and Enter/Send performs the fork (see sendNormal), so the message that opens + * the new session is the one they meant to write. Picking the CURRENT model **clears** the + * staging: forking a session onto the model it already runs is nothing but a lost + * conversation, so that pick can only mean "never mind, stay here" — leaving an earlier pick + * armed would fork onto it on the next Enter, the opposite of what was just asked for. + * Exclusive with a staged handoff target and with goal mode (the latest pick wins). */ - const pickSwitchModel = async (m: ModelInfo) => { + const pickSwitchModel = (m: ModelInfo) => { setModelSwitchOpen(false); - if (!onSwitchModel || busy || running || compacting) return; - if (sameModelRef(m, modelRef)) return; - const rest = textRef.current.trim(); - const bodyText = - rest || - (selectedSkills.length > 0 - ? S.chat.skillsAutoMessage(selectedSkills) - : S.chat.modelSwitchAutoMessage); - const body = buildSkillsMessage(selectedSkills, bodyText); - const input: TaskInputPart[] = [{ type: "text", text: body }]; - for (const url of images) input.push({ type: "image_url", imageUrl: url }); - setBusy(true); - try { - const ok = await onSwitchModel({ provider: m.provider, modelId: m.modelId }, input); - if (ok) { - // Consumed into the new session's first task (the parent has already discarded the - // draft cache — no change callbacks here, same as send()). - setText(""); - setImages([]); - setSelectedSkills([]); - } - } finally { - setBusy(false); - textareaRef.current?.focus(); + textareaRef.current?.focus(); + if (sameModelRef(m, modelRef)) { + stageModel(null); + return; } + stageModel(m); + setTarget(null); + onHandoffTargetChange?.(null); + setGoalOn(false); + }; + + /** + * /agent pick: stages the target agent as the handoff chip — nothing is sent yet, and the + * draft text is left alone (Enter/Send hands it to the new chat; an empty body still opens + * one, carrying just the [handoff_from] block). Exclusive with a staged model switch and with + * goal mode, exactly like the model pick above; the target is cached in the draft so the chip + * survives a reload. + */ + const pickHandoffTarget = (agent: AgentSummary) => { + setAgentSwitchOpen(false); + setTarget(agent); + onHandoffTargetChange?.(agent.agentId); + stageModel(null); + setGoalOn(false); + textareaRef.current?.focus(); + }; + + /** Drop whichever switch chip is staged (the chips' x buttons, and Backspace at the start of the text). */ + const clearSwitchTarget = () => { + if (target !== null) { + setTarget(null); + onHandoffTargetChange?.(null); + } + stageModel(null); }; // The menus above are drawn upward (`bottom-full`) from the composer, so their ceiling is @@ -1434,7 +1704,7 @@ export function ChatInput({ // top edge sits well below the viewport's. A static `40vh` cap can't know that distance and // clipped the first rows on shorter windows, so measure the real gap when a menu opens. useEffect(() => { - if (!slashOpen && !mentionOpen && !modelSwitchOpen) return; + if (!slashOpen && !modelSwitchOpen && !agentSwitchOpen) return; const measure = () => { const el = anchorRef.current; if (!el) return; @@ -1452,7 +1722,7 @@ export function ChatInput({ measure(); window.addEventListener("resize", measure); return () => window.removeEventListener("resize", measure); - }, [slashOpen, mentionOpen, modelSwitchOpen]); + }, [slashOpen, modelSwitchOpen, agentSwitchOpen]); /** Auto-grow the textarea (caps at roughly 6 lines, scrolls internally beyond that). */ const autoGrow = () => { @@ -1478,8 +1748,8 @@ export function ChatInput({ // Cursor placement on mount: move it to the end of a restored draft (by default the browser // places the cursor at the start when focusing a textarea that already has content), so typing - // continues the text naturally, and sync the caret state to match (the @ mention menu filters - // by cursor position). + // continues the text naturally, and sync the caret state to match (the slash menu matches the + // token at the cursor). useEffect(() => { const el = textareaRef.current; if (el && el.value.length > 0) { @@ -1510,51 +1780,54 @@ export function ChatInput({ onSkillsChange?.(next); }, [skills, selectedSkills, onSkillsChange]); - // Restore the cached @ handoff target: resolved once by id when agents becomes ready for + /** + * The two chips are restored from the draft cache by two effects that fire whenever their own + * list finishes loading — and `agents` and `models` are separate fetches, so either can land + * first, possibly after the user has already staged something by hand. `staged` is what keeps + * that from painting two chips at once (which sendNormal would silently resolve in favour of + * the handoff): a restore only fills an EMPTY slot. When the user has staged a chip or turned + * goal mode on in the meantime, that live intent is newer than the cached one and wins — the + * restore is dropped, not merely deferred, exactly as one pick drops the other. + * + * Only one of the two can be cached at a time anyway (each pick clears the other's cache + * entry), so in the ordinary case this changes nothing. + */ + const staged = target !== null || pendingModel !== null || goalOn; + + // Restore the cached handoff target: resolved once by id when agents becomes ready for // the first time (discarded if stale); a chip the user manually removes afterward is not restored again. const handoffRestored = useRef(false); useEffect(() => { if (handoffRestored.current || !initialHandoffTargetId || agents.length === 0) return; handoffRestored.current = true; + if (staged) return; const restored = agents.find((a) => a.agentId === initialHandoffTargetId); if (restored) setTarget(restored); - }, [agents, initialHandoffTargetId]); + }, [agents, initialHandoffTargetId, staged]); + + // Restore the cached /model switch target, mirroring the handoff restore above: resolved once + // against the model list when it first becomes ready. Dropped when that model is no longer + // configured, or when it is the model this session already runs on (the cache outlived a + // fork), since staging either would leave a chip that can only lose the conversation. + const pendingModelRestored = useRef(false); + useEffect(() => { + if (pendingModelRestored.current || !initialPendingModelRef || !models || models.length === 0) { + return; + } + pendingModelRestored.current = true; + if (staged || !onSwitchModel) return; + const restored = models.find((m) => sameModelRef(m, initialPendingModelRef)); + if (restored && !sameModelRef(restored, modelRef)) setPendingModel(restored); + }, [models, initialPendingModelRef, modelRef, onSwitchModel, staged]); /** - * Select a candidate: set it as the @ target chip (fixed at the front of the input, picking - * again replaces it), and remove the `@token` that triggered the menu (`mention.start..end`, - * including any leftover token fragment to the right of the cursor) along with one adjacent - * space from the text body. - */ - const insertMention = (agent: AgentSummary) => { - const el = textareaRef.current; - if (!mention || !el) return; - let { start, end } = mention; - if (el.value[end] === " ") end++; - else if (start > 0 && el.value[start - 1] === " ") start--; - const value = el.value.slice(0, start) + el.value.slice(end); - // Mutate the DOM synchronously before writing back to state (same value on re-render, cursor - // preserved), avoiding a race between async cursor restoration and the next keystroke. - el.value = value; - el.setSelectionRange(start, start); - el.focus(); - setTarget(agent); - onHandoffTargetChange?.(agent.agentId); - setGoalOn(false); // exclusive with goal mode: picking an @ target exits it (latest wins) - setText(value); - onTextChange?.(value); - setCaret(start); - setMentionIndex(0); - }; - - /** - * The full normal send path (task / handoff), also the follow-up queue path and the - * fallback target when a steer hits the completion race: assembles the [use_skills] - * block, images and @ handoff from the whole draft; `post` decides where a non-handoff - * message goes (default: onSend; follow-up mode: onQueueFollowUp). Deliberately not - * gated on `running` — the caller decides (send() gates the normal path; the steering - * fallback calls this directly after the server said 409 not_running, when the local - * `status` may still lag behind). + * The full normal send path (task / handoff / model switch), also the follow-up queue path + * and the fallback target when a steer hits the completion race: assembles the [use_skills] + * block, the images and the staged switch from the whole draft; `post` decides where a + * message that switches nothing goes (default: onSend; follow-up mode: onQueueFollowUp). + * Deliberately not gated on `running` — the caller decides (send() gates the normal path; + * the steering fallback calls this directly after the server said 409 not_running, when the + * local `status` may still lag behind). */ // `post` accepts onSend's goal parameter so onSend can be its default; the follow-up queue // (fewer params) is assignable too. Non-goal calls always pass null. @@ -1562,11 +1835,10 @@ export function ChatInput({ post: (input: TaskInputPart[], goal: { budget: number } | null) => Promise = onSend, ) => { const t = text.trim(); - // Goal mode: the trimmed text is the objective (no images, no @ handoff — cleared/blocked - // while the chip is on; a leading @ stays plain text). Selected skills prefix the round-1 - // message as a [use_skills] block, exactly like a normal send — the server strips leading - // marker blocks when recording the objective, and rounds after the first re-inject the - // objective alone. + // Goal mode: the trimmed text is the objective (no images, no staged switch — both are + // cleared when the chip goes on). Selected skills prefix the round-1 message as a + // [use_skills] block, exactly like a normal send — the server strips leading marker blocks + // when recording the objective, and rounds after the first re-inject the objective alone. if (goalOn) { setBusy(true); try { @@ -1584,28 +1856,49 @@ export function ChatInput({ } return; } - // @ target = the chip (selected via menu), or a leading `@` typed/pasted manually - // (an @ in the middle of the text is plain text); with a target present, this becomes a - // handoff to a new chat, the current Session isn't sent to, and the text carries no @ marker. - const lead = target ? { agent: target, rest: t } : splitLeadingMention(t, agents); - // With skills selected and an empty text body: the sent text automatically falls back to a - // localized invocation sentence generated per the UI language. - const rest = lead ? lead.rest : t; + // A staged switch chip (from /agent or /model) redirects the send away from the current + // Session: an agent target hands the draft to a NEW chat for that agent, a model target + // forks this conversation onto that model. The two are mutually exclusive by construction + // (picking either clears the other); the model chip only exists where onSwitchModel does. + // "blocked" = a staged fork while this Session is running or compacting: canSend/canFollowUp + // already refuse, but this path is deliberately not gated on run state (the steering + // completion race calls it directly), so refuse here too rather than fall through to `post` + // — that would deliver the message to the very Session the user was switching away from. + if (stagedRoute === "blocked") return; + const switchModel = stagedRoute === "model" ? pendingModel : null; + // Empty text body: fall back to an auto-line rather than sending nothing — the localized + // skills invocation when skills are selected, otherwise the model-switch line for a staged + // switch. A handoff needs no fallback: its first message may legitimately be nothing but + // the [handoff_from] source block. const bodyText = - selectedSkills.length > 0 && rest === "" ? S.chat.skillsAutoMessage(selectedSkills) : rest; - // With non-empty selected skills: the text body is replaced with a [use_skills] block + the text (the handoff branch wraps rest the same way). + t !== "" + ? t + : selectedSkills.length > 0 + ? S.chat.skillsAutoMessage(selectedSkills) + : switchModel + ? S.chat.modelSwitchAutoMessage + : t; + // With non-empty selected skills: the text body is replaced with a [use_skills] block + the text (every branch wraps its body the same way). const body = buildSkillsMessage(selectedSkills, bodyText); const input: TaskInputPart[] = []; if (body) input.push({ type: "text", text: body }); for (const url of images) input.push({ type: "image_url", imageUrl: url }); setBusy(true); try { - const ok = lead ? await onHandoff(lead.agent, input) : await post(input, null); + const ok = target + ? await onHandoff!(target, input) + : switchModel + ? await onSwitchModel!( + { provider: switchModel.provider, modelId: switchModel.modelId }, + input, + ) + : await post(input, null); // Only clear the draft after a successful send: on failure (network / conflict / server error) keep the user's input and images. if (ok) { setText(""); setImages([]); setTarget(null); + setPendingModel(null); setSelectedSkills([]); } } finally { @@ -1618,15 +1911,16 @@ export function ChatInput({ if (running) { // Follow-up branch: the whole draft goes out through the normal composition path, // but posted with queueIfBusy — the server holds it and auto-sends once this run - // finishes (an @ handoff still opens a new chat directly: the target session isn't - // the one running). + // finishes (a staged switch still opens its new chat directly: neither the handoff + // target nor the model fork is the session that is running). if (followUpMode) { if (!canFollowUp) return; await sendNormal(onQueueFollowUp!); return; } // Steering branch: queue the trimmed text for the running agent; only the text is - // sent and cleared — attached images / selected skills stay for a normal send. + // sent and cleared — attached images / selected skills stay for a normal send (a + // staged switch chip blocks this branch outright, see canSteer). if (!canSteer) return; const steerText = text.trim(); setBusy(true); @@ -1678,38 +1972,15 @@ export function ChatInput({ return; } } - if (mentionOpen) { - if (e.key === "ArrowDown") { - e.preventDefault(); - setMentionIndex((i) => (i + 1) % mentionMatches.length); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setMentionIndex((i) => (i - 1 + mentionMatches.length) % mentionMatches.length); - return; - } - if (((e.key === "Enter" && !e.shiftKey) || e.key === "Tab") && !e.nativeEvent.isComposing) { - e.preventDefault(); - if (activeMention) insertMention(activeMention); - return; - } - if (e.key === "Escape") { - // Only closes the popup, doesn't clear the input (the `@token` is part of the text body), reopens if the user keeps typing. - setMentionDismissed(mention?.start ?? null); - return; - } - } - // Backspace at the start of the text: removes the @ target chip (consistent with common chip-input interaction). + // Backspace at the start of the text: removes the staged switch chip (consistent with common chip-input interaction). if ( e.key === "Backspace" && - target !== null && + (target !== null || pendingModel !== null) && e.currentTarget.selectionStart === 0 && e.currentTarget.selectionEnd === 0 ) { e.preventDefault(); - setTarget(null); - onHandoffTargetChange?.(null); + clearSwitchTarget(); return; } if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { @@ -1798,52 +2069,37 @@ export function ChatInput({ search + configured-key-first grouping + "show all"; the current model is marked and picking it is a no-op. The /model token was already consumed when the command ran, so cancelling (Escape / click outside) keeps the remaining draft and cannot re-open - the slash menu. */} + the slash menu. A pick only stages the chip below — the switch happens on send. */} {modelSwitchOpen && models && ( -
-
- {S.chat.switchModelTitle} -
void pickSwitchModel(m)} + onPick={pickSwitchModel} /> -
+ )} - {/* @ subagent menu (triggered by typing @; interaction matches the slash menu) */} - {mentionOpen && ( -
- {mentionMatches.map((a, i) => ( - - ))} -
+ + )} {images.length > 0 && ( @@ -1878,6 +2134,16 @@ export function ChatInput({

)} + {/* A staged /model fork that has to wait for this Session to go idle (a run started from + outside the composer, or a compaction): the Send button is disabled either way, and + this is the line that says why — the chip stays staged and goes out on the next Enter + once the Session settles. */} + {stagedRoute === "blocked" && ( +

+ {S.chat.modelSwitchBusyHint} +

+ )} + {/* Mid-run steering queued: lightweight hint until the steering message appears in the stream (or the run ends). */} {steerPending && ( @@ -1950,10 +2216,10 @@ export function ChatInput({ modelAuthDead ? " opacity-50 grayscale" : "" }`} > - {/* Chip row above the text body: the @ handoff target (fixed at the front — send-time - @ semantics stay leading-only) followed by the selected skills, mirroring the - agent chip's look. Remove buttons recolor the x on hover (no background wash). */} - {(target !== null || selectedSkills.length > 0 || goalOn) && ( + {/* Chip row above the text body: the staged switch target (an /agent handoff or a + /model fork — never both) followed by the selected skills, all sharing the same + chip look. Remove buttons recolor the x on hover (no background wash). */} + {(target !== null || pendingModel !== null || selectedSkills.length > 0 || goalOn) && (
{/* Goal-mode chip: the budget stays compact as a value button; its editor is a fixed upward popover so it never covers the objective textarea below. */} @@ -2068,15 +2334,24 @@ export function ChatInput({ )} + {/* Staged /agent handoff target: the Agent avatar (the identity tile used + everywhere Agents are picked) + its id, so the chip reads as "this goes to that + Agent" without spelling the sentence out. */} {target !== null && ( - @{target.agentId} + + {target.agentId} )} + {/* Staged /model switch: provider logo + model name, matching the composer's own + model display; sending forks the conversation onto it. */} + {pendingModel !== null && ( + + + {modelLabel(pendingModel)} + + + )} {selectedSkills.map((name) => { const meta = skills.find((sk) => sk.name === name); return ( @@ -2129,22 +2426,16 @@ export function ChatInput({ onTextChange?.(value); setCaret(caretNow); setSlashIndex(0); - setMentionIndex(0); // Closing via Escape only persists for "the same token": continuing to type within - // that slash command / mention won't reopen the menu; it re-opens once the cursor is - // no longer on that token (deleted, moved away, or replaced by a new one). + // that slash command won't reopen the menu; it re-opens once the cursor is no longer + // on that token (deleted, moved away, or replaced by a new one). setSlashDismissed((d) => { if (d === null) return null; const m = matchSlash(value, caretNow); return m && m.start === d ? d : null; }); - setMentionDismissed((d) => { - if (d === null) return null; - const m = matchMention(value, caretNow); - return m && m.start === d ? d : null; - }); }} - // Cursor movement (arrow keys/click) syncs to caret: the @ menu filters by the prefix at the cursor. + // Cursor movement (arrow keys/click) syncs to caret: the slash menu matches the token at the cursor. onSelect={(e) => setCaret(e.currentTarget.selectionStart ?? 0)} onKeyDown={onKeyDown} onPaste={onPaste} @@ -2235,10 +2526,10 @@ export function ChatInput({ {/* Help text: shown only when the card is wide enough (@lg); it never competes for space on phones, where the group scrolls instead. */} - {S.chat.slashHint} · {S.chat.mentionHint} + {S.chat.slashHint}
diff --git a/packages/web/src/features/chat/chat-page.tsx b/packages/web/src/features/chat/chat-page.tsx index 689f66f..c913d3a 100644 --- a/packages/web/src/features/chat/chat-page.tsx +++ b/packages/web/src/features/chat/chat-page.tsx @@ -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} /> ); diff --git a/packages/web/src/features/chat/draft-cache.ts b/packages/web/src/features/chat/draft-cache.ts index 1b0beb6..e95c822 100644 --- a/packages/web/src/features/chat/draft-cache.ts +++ b/packages/web/src/features/chat/draft-cache.ts @@ -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; + 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; - 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. diff --git a/packages/web/src/features/chat/draft-view.tsx b/packages/web/src/features/chat/draft-view.tsx index 6790618..455270c 100644 --- a/packages/web/src/features/chat/draft-view.tsx +++ b/packages/web/src/features/chat/draft-view.tsx @@ -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(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( - 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 => { - 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) */} diff --git a/packages/web/src/features/chat/handoff-banner.tsx b/packages/web/src/features/chat/handoff-banner.tsx index eeefd17..339f5aa 100644 --- a/packages/web/src/features/chat/handoff-banner.tsx +++ b/packages/web/src/features/chat/handoff-banner.tsx @@ -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 's chat"; + * - `HandoffBanner` (`[handoff_from]`, the /agent handoff): "Handed off from '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 = diff --git a/packages/web/src/features/chat/message-item.tsx b/packages/web/src/features/chat/message-item.tsx index 47ce8dc..024b4d1 100644 --- a/packages/web/src/features/chat/message-item.tsx +++ b/packages/web/src/features/chat/message-item.tsx @@ -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 ; // 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. diff --git a/packages/web/src/features/chat/scheduled-banner.tsx b/packages/web/src/features/chat/scheduled-banner.tsx index 14be729..bbc73a0 100644 --- a/packages/web/src/features/chat/scheduled-banner.tsx +++ b/packages/web/src/features/chat/scheduled-banner.tsx @@ -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 ( diff --git a/packages/web/src/features/chat/slash-token.ts b/packages/web/src/features/chat/slash-token.ts index 5ef085b..8b047e3 100644 --- a/packages/web/src/features/chat/slash-token.ts +++ b/packages/web/src/features/chat/slash-token.ts @@ -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). */ diff --git a/packages/web/src/features/chat/use-session-draft.ts b/packages/web/src/features/chat/use-session-draft.ts index d143770..2aa2d44 100644 --- a/packages/web/src/features/chat/use-session-draft.ts +++ b/packages/web/src/features/chat/use-session-draft.ts @@ -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(initial.handoffAgentId ?? null); + const switchModelRef = useRef(initial.switchModelRef ?? null); const skillsRef = useRef(initial.skills ?? []); const timer = useRef(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, + }; } diff --git a/packages/web/src/features/skills/skills-page.tsx b/packages/web/src/features/skills/skills-page.tsx index 786a79b..c802eed 100644 --- a/packages/web/src/features/skills/skills-page.tsx +++ b/packages/web/src/features/skills/skills-page.tsx @@ -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. */ diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts index 0fced7e..5c0ffcb 100644 --- a/packages/web/src/lib/strings-en.ts +++ b/packages/web/src/lib/strings-en.ts @@ -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` diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index 113fd52..03a86f5 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -715,8 +715,13 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题( `发送后交接给 ${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 技能),深色/浅色主题( `使用 ${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: "开始一段新对话", diff --git a/packages/web/test/agent-handoff.test.ts b/packages/web/test/agent-handoff.test.ts new file mode 100644 index 0000000..3d8d112 --- /dev/null +++ b/packages/web/test/agent-handoff.test.ts @@ -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[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" }); + }); +}); diff --git a/packages/web/test/agent-mentions.test.ts b/packages/web/test/agent-mentions.test.ts deleted file mode 100644 index d844a8d..0000000 --- a/packages/web/test/agent-mentions.test.ts +++ /dev/null @@ -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" }); - }); -}); diff --git a/packages/web/test/draft-cache.test.ts b/packages/web/test/draft-cache.test.ts index 013e800..f2ea24e 100644 --- a/packages/web/test/draft-cache.test.ts +++ b/packages/web/test/draft-cache.test.ts @@ -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"]) {