feat(mods): run prompt.submit hooks in headless runs (#15905)

* feat(mods): run prompt.submit hooks in headless runs

Mods' prompt.submit hooks can rewrite, add context to, or drop each headless prompt before UserPromptSubmit, @ processing and the transcript see it. Terminals keep skipping these hooks until they raise the event. Includes the design document for the four steps.

* test(mods): update bundled worker load response expectation

* fix(mods): preserve downstream prompt submit outcomes

* fix(mods): isolate prompt fallback from host continuations

* fix(mods): preserve latest local prompt outcome in recovery

* fix(mods): hear only a message's text and take any answer after next

A stream-json message with an image block reached the prompt.submit
hooks as its serialized blocks, so they were skipped as too long or saw
JSON noise, and a rewrite carried that JSON into humanInput and
turn.start. The hooks now hear its text blocks; a rewrite replaces them
and the other blocks follow it.

A hook's answer after its next changes nothing, but it was still
validated, so a hook that only watches and returns nothing, or spreads
the event back, failed and logged a skip on every prompt. Only a drop
there is now checked.

* fix(mods): keep prompt.submit to typed text and drop empty rewrites

- Show only the rewritten text in the transcript and UserPromptSubmit,
  not the serialized non-text blocks of a stream-json message.
- Skip prompt.submit for SDK messages that answer a tool call or carry
  tool blocks, and for messages without text.
- Drop a prompt the hooks rewrote to nothing instead of sending the
  model an empty prompt.

* fix(mods): send each prompt context once on its own

The Mods' prompt.submit context and UserPromptSubmit's additionalContext
share one hidden block, and the block was only skipped when the
conversation already held it whole. A Mod whose context changes every
turn made every block new, so an unchanged UserPromptSubmit context went
to the model again each turn.

The injected part now records the contexts it joins, and a context counts
as delivered when an earlier block holds it, so only the contexts the
conversation lacks are sent. A block without that record, as after
resume, still counts as one context.

* fix(mods): keep a message's blocks in the transcript after a rewrite

A stream-json message with blocks other than text is recorded with those
blocks serialized after its text. A prompt.submit rewrite recorded the
rewritten text alone, so the same kind of message showed two shapes in the
transcript. The rewritten prompt is now recorded with the blocks after it.

Adds the headless E2E test plan and its results.

---------

Co-authored-by: Shaojin Wen <[email protected]>
This commit is contained in:
qqqysandShaojin Wen authored and GitHub committed 2026-11-02 09:14:14 +00:00
1 parent 1e250b391b
commit 9c96f5aa82
22 files changed
+1683 -47

No files matched your search

@@ -0,0 +1,78 @@
# Mod prompt.submit in headless runs
Design: [docs/design/2026-10-30-mod-prompt-submit.md](../../docs/design/2026-10-30-mod-prompt-submit.md).
## Setup
- Build the CLI and bundle it: `npm run build && npm run bundle`.
- Use an isolated `HOME` whose `.qwen/settings.json` sets `experimental.mods` to `true`, selects `openai` auth and points `OPENAI_BASE_URL` at a loopback OpenAI-compatible fixture that answers `OK` and records every request.
- Install a guard Mod with `qwen extensions install ./guard-mod --consent`. Its `hooks/register.js`:
```javascript
export function register(on) {
on('prompt.submit', async ($, e, next) => {
$.ui.log('submit:' + JSON.stringify(e));
if (/AKIA[0-9A-Z]{16}/.test(e.text)) return { drop: 'the prompt holds an AWS access key' };
if (e.text === 'boom') throw new Error('guard broke');
if (e.text === 'mine') return { text: 'answered without next' };
const entered = await next({
...e,
text: e.text.replace(/\bprod\b/g, 'production'),
context: [
...(e.context ?? []),
e.text.startsWith('note') ? 'Mod note: ' + e.text : 'Mod context: <be brief>',
],
});
$.ui.log('entered:' + JSON.stringify(entered));
return entered;
}).catch(($, e, next) => (next.called ? next(e) : { drop: 'the guard failed' }));
on('turn.start', ($, e, next) => {
$.ui.log('turn.start:' + e.text);
return next(e);
});
}
```
## Test groups
### Rewrite and context
- `qwen -p 'deploy prod now' --output-format json`: exit 0. The model reads `deploy production now` followed by the hidden block `Mod context: &lt;be brief&gt;`. `turn.start` hears `deploy production now`. The transcript user record has `displayText: "deploy production now"` and the block as `hookContext`.
### Drop
- `qwen -p 'key AKIAABCDEFGHIJKLMNOP' --output-format json`: exit 1, no model request, error result `Prompt dropped by a hook: the prompt holds an AWS access key`.
- The same prompt in text output: exit 1, the reason on stderr.
- `-p boom`, where the guard throws and its `.catch` drops: exit 1, no model request, `Prompt dropped by a hook: the guard failed`.
- `-p mine`, where the guard answers without `next`: exit 1, no model request, `Prompt not submitted: a hook answered without passing the prompt on`.
### stream-json
- A message holding a key, then `ship prod`: the first message ends with the error result, the session goes on, and the second reaches the model as `ship production`.
- A message `[text 'ship prod', image block]`: the model reads `ship production` with the serialized image block after it, and the transcript `displayText` keeps the block after the rewritten text, the same shape it has without a rewrite.
### Context sent once per context
Add a settings `UserPromptSubmit` command hook that prints `settings-note`, then send `note one`, `note two`, `note two` in one stream-json session:
- `note one`: block `Mod note: note one\n\nsettings-note`.
- `note two`: block `Mod note: note two` only; the unchanged `settings-note` is not sent again.
- `note two` again: no block; both contexts are already in the conversation.
## Results (2026-11-02, branch head after merging main)
Built CLI, real fixture server, all groups above pass:
| Run | Result |
| --- | --- |
| rewrite (json) | exit 0, model reads `deploy production now` + context block, transcript `displayText` rewritten |
| drop (json / text) | exit 1, 0 model requests, drop reason as error result / on stderr |
| guard throws, `.catch` drops | exit 1, 0 model requests, `the guard failed` |
| answer without `next` | exit 1, 0 model requests, `Prompt not submitted: ...` |
| stream-json drop then rewrite | first message error, second sent as `ship production` |
| stream-json rewrite with an image block | transcript `displayText` = `ship production\n{"type":"image",...}` |
| context sent once per context | blocks `["Mod note: note one\n\nsettings-note"]`, `["Mod note: note two"]`, `[]` |
## Baseline
`main` at `619a601b39`, bundled locally, skips every `prompt.submit` hook at startup (`Hooks for unsupported events will not run: prompt.submit`): the key prompts reach the model with exit 0, and `turn.start` hears the original text. The global `qwen` 0.25.1 on this machine is a standalone binary, which the fixture driver (it starts the CLI with `node`) cannot run, so the baseline comes from the `main` bundle.
+114
View File
@@ -0,0 +1,114 @@
# Mod prompt submission
[English](2026-10-30-mod-prompt-submit.md) | [简体中文](2026-10-30-mod-prompt-submit.zh-CN.md)
Status: proposed. This stage follows the turn events in #15443 and is tracked in #13749. It follows the Claude Code 2.1.310 plugin API declarations of `prompt.submit` and `$.prompt.submit`.
## Problem and current behavior
Mods written for Claude Code act on the prompts a person sends:
- A guard drops a prompt that holds a credential, with `{ drop: reason }`.
- A Mod rewrites a prompt, for example to expand a shorthand, with `next({ ...e, text })`.
- A Mod attaches context the model reads beside the prompt and the person never sees, with `next({ ...e, context: [...] })`.
- A Mod sends a prompt of its own once the session is idle, with `$.prompt.submit`.
Qwen Code skips `prompt.submit` hooks at load and reports `Hooks for unsupported events will not run: prompt.submit`. A guard written there silently does not run.
## Goals and non-goals
Goals, in four pull requests:
1. Core and headless runs: the event, its rules and its context. This document lands with it.
2. The Ink terminal.
3. The OpenTUI terminal.
4. `$.prompt.submit`.
Non-goals:
- Slash commands, which Claude Code raises with `command`. They come with a later step on command rows.
- Prompts the person did not type: background notifications, scheduled prompts, teammate messages, goal continuations and the continuation of an interrupted turn. Claude Code marks some of these with `isMeta`.
- Prompts typed while a turn runs that steer it at a model boundary.
- `attachments` and `$.prompt.media`.
## Proposed changes
### 1. When it fires
`prompt.submit` fires for each prompt a person submits, after the host has its text and before anything reads it: before UserPromptSubmit, before `@` references are read and before the user message is recorded or shown. Only a host that raises the event loads its hooks; any other host skips them at startup with the usual line, so a guard is never silently bypassed. Step 1 raises it in headless runs, for the `-p` prompt and each stream-json user message.
### 2. Input
| Field | Value |
| --------- | ---------------------------------------------------------------------------------------------- |
| `text` | the prompt as submitted, with pasted text expanded; for a stream-json message, its text blocks |
| `context` | absent as the host raises it |
| `wait` | `false` |
| `origin` | `{ kind: 'sdk' }` in headless runs; `{ kind: 'composer' }` in the terminals |
| `turnId` | absent until a terminal raises the event for a prompt typed during a turn |
A hook passes `text` and `context` on to `next`; `origin`, `wait` and `turnId` are pinned. `context` is a list of texts.
### 3. What enters
The prompt that enters is the one that arrives beneath the hooks, as in Claude Code: the worker records the `text` and `context` that reach the bottom of the chain. `next(e)` resolves to them with the origin. What a hook answers after its `next` resolved changes nothing, and only a drop there is checked.
The chain settles one of three ways:
- the prompt arrived beneath the hooks: it enters with its text and context;
- a hook answered `{ drop: reason }`: the prompt is dropped by a hook;
- the chain settled without reaching the bottom, because a hook answered `{ text }` without `next`: the prompt is not submitted, as Claude Code's `a hook answered without passing the prompt on`.
A hook that answers a drop after calling `next` fails, as in Claude Code: its `.catch` is asked, or it is skipped and its last `next` stands. A hook that fails or times out is skipped, and a failed worker lets the prompt through unchanged, so a broken plugin never blocks a prompt.
### 4. Rewrite and context
A rewritten text replaces the prompt everywhere the host keeps it: the text sent to the model, the text `@` processing reads, the `turn.start` text, the transcript record and its display text. A stream-json message that also has blocks other than text reaches the model as text with those blocks serialized; the hooks hear only its text blocks, and a rewrite replaces them, with the other blocks after it, in the model's text and in the transcript alike, as without a rewrite.
The context joins UserPromptSubmit's `additionalContext` in the hidden block core already appends after the prompt, ahead of it, escaped the same way. The client takes it as a new send option, `promptContext`, which only the prompt's own UserQuery spends. The transcript records the block as hook context, so resume hides it and compaction keeps it the way it keeps UserPromptSubmit's.
### 5. Drop
A drop stops the prompt before UserPromptSubmit and before any model request, and starts no turn. The reason is one line, kept to its first 4,096 characters with an ellipsis after, as Claude Code keeps it. Headless runs end with the error result `Prompt dropped by a hook: <reason>`, or `Prompt not submitted: a hook answered without passing the prompt on`, and exit code 1. A stream-json session goes on to its next message.
### 6. Limits
A prompt longer than half a Mod payload (128 KiB as JSON) reaches no hook, and the line `prompt.submit hook skipped: the prompt is too long for a Mod payload` appears where transcript logs do. Claude Code passes it whole. A rewrite or context that makes a hook's answer larger than a Mod payload fails that hook.
## Design decisions
- **The host raises the event.** Each host already holds the submitted text before it expands or records it. Raising the event there keeps the rewrite ahead of every use of the text without changing core's send.
- **UserPromptSubmit runs after the Mod hooks, in core.** Claude Code runs both in one admission and lets a settings hook's block beneath turn `next(e)` into `{ drop }`. Here the bottom of the chain does not wait for UserPromptSubmit, which runs later in the send and can take as long as its commands. A Mod that must know whether UserPromptSubmit blocked a prompt can read `turn.start`, which only an admitted prompt raises.
- **Hosts opt in.** The load request says whether the host raises `prompt.submit`, as it names the sites it draws for `ui.render`, so each step enables only the host it adds.
- **The context shares the hook context block.** It reaches the model, the transcript and resume the way UserPromptSubmit's context does, so no new record kind is needed. A context the conversation already holds word for word is not sent again, as for UserPromptSubmit. Each Mod context and UserPromptSubmit's count on their own, so a Mod context that changes every turn does not make an unchanged UserPromptSubmit context repeat.
## Differences from Claude Code
These go into the user guide:
- Until steps 2 and 3, only headless runs raise `prompt.submit`.
- Slash commands and prompts the person did not type raise none, so `command` and `isMeta` never appear.
- `attachments` never appears, and `$.prompt.media` does not exist.
- A UserPromptSubmit block does not turn `next(e)` into `{ drop }`, and `next(e)` resolves before the turn starts.
- A context the conversation already holds word for word is not sent again, each Mod context and UserPromptSubmit's on its own.
- A prompt longer than 128 KiB as JSON reaches no hook.
- A headless drop is an error result with exit code 1.
## Validation and acceptance
- Real QuickJS worker tests:
- which plugins hear the event, and a host that raises none skipping its hooks;
- the prompt and context that arrive beneath the hooks, and an answer after `next` that changes nothing;
- a hook's drop, and a hook that answers without `next`;
- a downstream drop's reason survives a post-next text answer across plugins, within one plugin, and during recovery; a caught late drop does not replace a later local continuation's outcome or mark a host continuation as called;
- a drop after `next`, a changed `origin` or `wait`, a context that is not a list, a wrong answer and a throw, each skipped;
- a `.catch` that drops for a failed guard.
- Mod session tests: no hooks, the origin, a rewrite with context, a drop's one line and its 4,096 characters, a failed worker, a cancelled prompt, and a prompt too long for a payload.
- Client test: the Mods' context ahead of UserPromptSubmit's, escaped, with and without UserPromptSubmit hooks, and only for a new prompt.
- Headless tests: the rewritten prompt and its context reaching the send and `turn.start`, a drop in JSON and text, and no event for a slash command or a notification.
- The built CLI against a fixture server in headless JSON, text and stream-json: a rewrite that the model request and the transcript both hold, context in the hidden block, a drop, and a guard whose `.catch` drops.
## Risks and open questions
- A slow hook delays the prompt by up to its budget, 10 seconds a hook.
- Masking a slash command's arguments needs `command.run` today; `prompt.submit` with `command` comes later.
@@ -0,0 +1,114 @@
# Mod 提示提交
[English](2026-10-30-mod-prompt-submit.md) | [简体中文](2026-10-30-mod-prompt-submit.zh-CN.md)
状态:提议中。本阶段接在 #15443 的回合事件之后,由 #13749 跟踪。依据是 Claude Code 2.1.310 插件 API 对 `prompt.submit` 和 `$.prompt.submit` 的声明。
## 问题与现状
为 Claude Code 写的 Mod 会处理用户发出的提示:
- 守卫用 `{ drop: reason }` 拦下含有凭据的提示。
- Mod 用 `next({ ...e, text })` 改写提示,例如展开缩写。
- Mod 用 `next({ ...e, context: [...] })` 附上模型能读到、用户看不到的上下文。
- Mod 用 `$.prompt.submit` 在会话空闲后发出自己的提示。
Qwen Code 在加载时跳过 `prompt.submit` hook,并提示 `Hooks for unsupported events will not run: prompt.submit`。写在这里的守卫不会运行。
## 目标与非目标
目标,分四个 PR:
1. core 和 headless:事件本身、规则和上下文。本文随它合入。
2. Ink 终端。
3. OpenTUI 终端。
4. `$.prompt.submit`。
非目标:
- 斜杠命令。Claude Code 对它们带 `command` 触发,留到后续处理命令行的步骤。
- 不是用户输入的提示:后台通知、定时提示、队友消息、goal 续跑、中断回合的续跑。Claude Code 对其中一部分标 `isMeta`。
- 回合进行中输入、在模型边界插入当前回合的提示。
- `attachments` 和 `$.prompt.media`。
## 方案
### 1. 触发时机
用户每提交一个提示,在宿主拿到文本之后、任何地方读取它之前触发 `prompt.submit`:在 UserPromptSubmit 之前,在读取 `@` 引用之前,在记录或显示用户消息之前。只有会触发该事件的宿主才加载它的 hook;其他宿主在启动时照常跳过并给出提示,守卫不会被悄悄绕过。第 1 步在 headless 运行中触发:`-p` 的提示和 stream-json 的每条用户消息。
### 2. 输入
| 字段 | 值 |
| --------- | ------------------------------------------------------------ |
| `text` | 提交的提示,粘贴内容已展开;stream-json 消息只取其文本块 |
| `context` | 宿主触发时不带 |
| `wait` | `false` |
| `origin` | headless 为 `{ kind: 'sdk' }`;终端为 `{ kind: 'composer' }` |
| `turnId` | 不带,直到终端为回合进行中输入的提示触发该事件 |
hook 可以把改过的 `text` 和 `context` 传给 `next`;`origin`、`wait`、`turnId` 不可改。`context` 是文本列表。
### 3. 最终提交什么
和 Claude Code 一样,提交的是穿过所有 hook 到达链底的提示:worker 记录到达链底的 `text` 和 `context`。`next(e)` 解析为它们,外加 origin。hook 在 `next` 返回之后再给出的答复不起作用,这时只检查它是不是 drop。
链的结果有三种:
- 提示到达链底:带着它的文本和上下文提交;
- 某个 hook 答复 `{ drop: reason }`:提示被 hook 丢弃;
- 链没有到达链底就结束了,因为某个 hook 没调 `next` 就答复了 `{ text }`:提示没有提交,对应 Claude Code 的 `a hook answered without passing the prompt on`。
和 Claude Code 一样,调过 `next` 之后再答复 drop 的 hook 算失败:先问它的 `.catch`,否则跳过它,保留它最后一次 `next` 的结果。失败或超时的 hook 被跳过;worker 出错时提示原样通过。坏掉的插件不会拦住提示。
### 4. 改写与上下文
改写后的文本在宿主保存提示的每一处都替换掉原文:发给模型的文本、`@` 处理读取的文本、`turn.start` 的文本、transcript 记录及其显示文本。stream-json 消息若还带有非文本块,这些块会序列化后随文本发给模型;hook 只听到其中的文本块,改写替换的也是文本块,其他块接在改写后的文本之后;发给模型的文本和 transcript 都是这样,与没有改写时一致。
上下文并入 core 已有的隐藏块,即追加在提示之后、存放 UserPromptSubmit `additionalContext` 的那一块,排在后者之前,转义方式相同。client 通过新的发送选项 `promptContext` 接收它,只有该提示自己的 UserQuery 会用掉它。transcript 把这一块记作 hook 上下文,因此恢复会话时它仍然隐藏,压缩时也和 UserPromptSubmit 的上下文一样保留。
### 5. 丢弃
drop 在 UserPromptSubmit 和任何模型请求之前拦下提示,不开始回合。原因只保留一行,最多 4,096 个字符,超出部分以省略号结尾,与 Claude Code 相同。headless 运行以错误结果 `Prompt dropped by a hook: <reason>`(或 `Prompt not submitted: a hook answered without passing the prompt on`)结束,退出码为 1。stream-json 会话继续处理下一条消息。
### 6. 限制
超过 Mod 负载一半(JSON 128 KiB)的提示不经过任何 hook,并在 transcript 日志出现的位置给出 `prompt.submit hook skipped: the prompt is too long for a Mod payload`。Claude Code 会完整传递。改写或上下文让 hook 的答复超过 Mod 负载时,该 hook 算失败。
## 设计决策
- **由宿主触发事件。** 每个宿主在展开或记录提示之前就已拿到提交的文本。在这里触发,改写就能先于文本的每一次使用,且不必改 core 的发送流程。
- **UserPromptSubmit 在 Mod hook 之后、在 core 里运行。** Claude Code 把两者放在同一次准入里,下层设置 hook 的拦截会让 `next(e)` 变成 `{ drop }`。这里链底不等 UserPromptSubmit:它在发送流程更靠后的位置运行,耗时取决于它的命令。需要知道 UserPromptSubmit 是否拦下提示的 Mod 可以看 `turn.start`,只有获准的提示才会触发它。
- **宿主自行声明。** 加载请求说明宿主是否触发 `prompt.submit`,就像它为 `ui.render` 列出绘制的位置一样。每一步只为它新增的宿主开启。
- **上下文共用 hook 上下文块。** 它到达模型、进入 transcript、在恢复时的处理都和 UserPromptSubmit 的上下文一致,不需要新的记录类型。会话里已有一字不差的同一上下文时不再发送,与 UserPromptSubmit 相同。每条 Mod 上下文和 UserPromptSubmit 的上下文分别判断,所以每轮都变的 Mod 上下文不会让没变的 UserPromptSubmit 上下文重复发送。
## 与 Claude Code 的差异
以下内容写进用户指南:
- 第 2、3 步之前,只有 headless 运行触发 `prompt.submit`。
- 斜杠命令和不是用户输入的提示不触发它,所以不会出现 `command` 和 `isMeta`。
- 不会出现 `attachments`,也没有 `$.prompt.media`。
- UserPromptSubmit 的拦截不会让 `next(e)` 变成 `{ drop }`;`next(e)` 在回合开始之前就返回。
- 会话里已有一字不差的同一上下文时不再发送,每条 Mod 上下文和 UserPromptSubmit 的上下文分别判断。
- 超过 JSON 128 KiB 的提示不经过任何 hook。
- headless 的 drop 是错误结果,退出码为 1。
## 验证与验收
- 真实 QuickJS worker 测试:
- 哪些插件收到该事件,以及不触发它的宿主跳过它的 hook;
- 到达链底的提示和上下文,以及 `next` 之后的答复不起作用;
- hook 的 drop,以及不调 `next` 就答复的 hook;
- 跨插件、同一插件内和恢复路径中,`next` 返回后的文本答复都保留下层 drop 的原因;被捕获的迟到 drop 不覆盖后续本地 continuation 的结果,也不把宿主 continuation 标记为已调用;
- `next` 之后 drop、改动 `origin` 或 `wait`、上下文不是列表、答复格式错误、抛错,均被跳过;
- 守卫失败时由 `.catch` 丢弃。
- Mod session 测试:没有 hook、origin、带上下文的改写、drop 的单行和 4,096 字符、worker 出错、提示被取消、超过负载的提示。
- client 测试:Mod 的上下文排在 UserPromptSubmit 的上下文之前并已转义;有无 UserPromptSubmit hook 两种情况;只用于新提示。
- headless 测试:改写后的提示和上下文到达发送和 `turn.start`;JSON 和文本模式下的 drop;斜杠命令和通知不触发事件。
- 构建后的 CLI 对接 fixture 服务器,在 headless JSON、文本和 stream-json 下验证:模型请求和 transcript 都是改写后的文本;上下文在隐藏块里;drop;守卫失败时由 `.catch` 丢弃。
## 风险与待定问题
- 慢 hook 会让提示延迟,最多为它的预算,每个 hook 10 秒。
- 目前遮蔽斜杠命令的参数要靠 `command.run`;带 `command` 的 `prompt.submit` 留到以后。
+85 -6
View File
@@ -137,6 +137,7 @@ this recovery exception.
| API events | `command.register`, `ui.log`, `ui.invalidate` and the session and clock methods pass through the middleware chain with host-supplied origin |
| Session | `$.plugin`, read-only `$.session` facts, `$.clock.now` and `$.clock.sleep`, described in [Session facts and clock](#session-facts-and-clock) |
| Turns | `turn.start`, `turn.complete` and `$.turn.abort` for the prompts a person submits, described in [Turn events](#turn-events) |
| Prompts | `prompt.submit` in headless runs, described in [Submitted prompts](#submitted-prompts) |
| Logging | `$.ui.log` returns void and queues delivery; transcript logs use stderr in text mode or `system/ui_log` in JSON modes; debug logs stay in the debug logger; neither becomes model input |
| Elements | `$.ui.resolve`, `$.ui.invalidate` and the `h` and `Fragment` globals, described in [Elements](#elements) |
| Drawing | `ui.render` for the band above the prompt in the Ink and OpenTUI terminals, described in [The band above the prompt](#the-band-above-the-prompt) |
@@ -157,18 +158,18 @@ command names are matched against the resulting name.
## Middleware and recovery
Patterns select among the engine events `session.start`, `session.end`,
`command.run`, `tool.call`, `tool.check`, `turn.start`, `turn.complete` and, in the Ink and OpenTUI terminals, `ui.render`, and the API event of each supported
`command.run`, `tool.call`, `tool.check`, `turn.start`, `turn.complete`, in headless runs `prompt.submit`, in the Ink and OpenTUI terminals `ui.render`, and the API event of each supported
`$` method, which `$.session.version()` lists. Use an exact name, a prefix such as `command.*`,
`*`, or a single negation such as `!session.*`. The patterns `!*` and `!!...` are
rejected.
Claude Code 2.1.310 also defines events that Qwen Code does not run yet, such as
`prompt.submit` or `classic.Stop`, and `ui.render` runs only where a host draws
it. A registration for one of them,
`prompt.fill` or `classic.Stop`. `prompt.submit` runs only in headless runs, and
`ui.render` only where a host draws it. A registration for one of them,
or for a prefix such as `prompt.*` that matches none of the events above, loads
but never runs, so the rest of the Mod keeps working. At startup each plugin
reports its skipped patterns once, where transcript logs appear:
`Hooks for unsupported events will not run: prompt.submit, ui.render`. A skipped
`Hooks for unsupported events will not run: prompt.fill, ui.render`. A skipped
hook never runs, including a guard and its `.catch`. Names that Claude Code does
not define, such as `tool.calls`, still reject initialization.
@@ -200,8 +201,9 @@ registrations with matchers, including equal matchers. Each wildcard or negated
pattern can be registered once. Within a plugin, registration order determines
the chain; consumers run before their declared dependency providers. A handler
can answer early, including at `session.start`, preventing later handlers from
running. Only command `args` can be rewritten on `command.run`; lifecycle and
command identity fields stay pinned.
running. Only command `args` can be rewritten on `command.run`, and only a
prompt's `text` and `context` on `prompt.submit`; lifecycle and command identity
fields stay pinned.
## Tool interception
@@ -625,6 +627,83 @@ ending. In a headless run the turn ends with the error result
its next message. Qwen Code has no `$.prompt.submit` yet, so a Mod cannot send a
prompt after it ends a turn.
## Submitted prompts
In headless runs, `prompt.submit` fires for each prompt a person submits, before
UserPromptSubmit hooks and before `@` references are read. A hook can rewrite the
prompt, attach context the model reads beside it, or drop it:
```javascript
export function register(on) {
on('prompt.submit', ($, e, next) => {
if (/AKIA[0-9A-Z]{16}/.test(e.text))
return { drop: 'the prompt holds an AWS access key' };
return next({
...e,
text: e.text.replace(/\bprod\b/g, 'production'),
context: [...(e.context ?? []), 'Answer in British English.'],
});
});
}
```
The hook receives:
| Field | Value |
| --------- | ------------------------------------------------------------------------------------- |
| `text` | the prompt as submitted; for a stream-json message, its text blocks |
| `context` | absent; each hook that attaches context passes the list on with its own entries added |
| `wait` | `false` |
| `origin` | `{kind: 'sdk'}` |
`origin`, `wait` and `turnId` are pinned. The prompt that enters is the one that
arrives beneath the hooks: `next(e)` resolves to `{text, context?, origin}`, and
what a hook answers after its `next` resolved changes nothing. The rewritten text
is what the model reads, what `turn.start` hears and what the transcript keeps.
In a stream-json message with blocks other than text, the rewrite replaces its
text blocks and the other blocks follow it.
The context goes to the model after the prompt, in the block that holds
UserPromptSubmit's context, ahead of it, and is never shown as part of the
prompt.
`{drop: reason}` stops the prompt before UserPromptSubmit and before any model
request. The run ends with the error result `Prompt dropped by a hook: <reason>`
and exit code 1; a stream-json session goes on to its next message. The reason
is one line, kept to its first 4,096 characters. A hook that answers without
calling `next` and without a drop also stops the prompt, with the result
`Prompt not submitted: a hook answered without passing the prompt on`. A hook
that calls `next` and then answers a drop fails, and its `next` stands.
A hook that fails or times out is skipped, so a broken guard never blocks a
prompt. Give a guard a `.catch` that drops instead:
```javascript
on('prompt.submit', guard).catch(($, e, next) =>
next.called ? next(e) : { drop: 'the guard failed' },
);
```
A prompt longer than 128 KiB as JSON reaches no hook, and the line
`prompt.submit hook skipped: the prompt is too long for a Mod payload` appears
where transcript logs do.
These differ from Claude Code:
- `prompt.submit` runs only in headless runs; the Ink and OpenTUI terminals skip
its hooks at startup.
- Slash commands, background notifications and the continuation of an
interrupted turn raise no `prompt.submit`, so `command` and `isMeta` never
appear.
- `attachments` never appears, and `$.prompt.media` does not exist.
- A UserPromptSubmit hook that blocks the prompt beneath does not turn `next(e)`
into `{drop}`: the Mod hooks have settled by the time it runs.
- `next(e)` resolves once the hooks beneath have answered, not once the turn
starts.
- A context the conversation already holds word for word is not sent again.
Each Mod context and UserPromptSubmit's context count on their own, so a
context that changes every turn does not make the others repeat.
- `$.prompt.submit` does not exist yet.
## Elements
`$.ui.resolve(e)` returns the element table of the terminal: a frozen object
@@ -752,6 +752,66 @@ describe('runNonInteractiveStreamJson', () => {
}
});
it('passes the text blocks apart from the others only when a message has both', async () => {
const image = {
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'AAAA' },
};
const mixed = createUserMessage('');
mixed.message.content = [
{ type: 'text', text: 'Describe' },
image,
{ type: 'text', text: 'briefly' },
] as CLIUserMessage['message']['content'];
const textOnly = createUserMessage('');
textOnly.message.content = [{ type: 'text', text: 'Hello' }];
mockInputReader.read = async function* () {
yield mixed;
await vi.waitFor(() =>
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1),
);
yield textOnly;
};
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
const [first, second] = runNonInteractiveMock.mock.calls;
expect(first[2]).toBe(`Describe\n${JSON.stringify(image)}\nbriefly`);
expect(first[4].promptParts).toEqual({
text: 'Describe\nbriefly',
blocks: [JSON.stringify(image)],
});
expect(second[2]).toBe('Hello');
expect(second[4]).not.toHaveProperty('promptParts');
});
it('marks a message that answers a tool call as one no person typed', async () => {
const result = createUserMessage('');
result.message.content = [
{ type: 'tool_result', tool_use_id: 't1', content: 'ok' },
] as CLIUserMessage['message']['content'];
const scoped = createUserMessage('Go on');
scoped.parent_tool_use_id = 't1';
const typed = createUserMessage('Hello');
mockInputReader.read = async function* () {
for (const message of [result, scoped, typed]) {
const calls = runNonInteractiveMock.mock.calls.length;
yield message;
await vi.waitFor(() =>
expect(runNonInteractiveMock).toHaveBeenCalledTimes(calls + 1),
);
}
};
await runNonInteractiveStreamJson(config, '');
const [first, second, third] = runNonInteractiveMock.mock.calls;
expect(first[4].fromTool).toBe(true);
expect(second[4].fromTool).toBe(true);
expect(third[4]).not.toHaveProperty('fromTool');
});
it('seeds the promptId counter past turns the resumed transcript claims', async () => {
// A resumed headless chain reuses the session id, so restarting the
// counter at 1 would re-mint ids the previous run already persisted —
@@ -544,6 +544,7 @@ class Session {
private async processUserMessage(userMessage: CLIUserMessage): Promise<void> {
const input = extractUserMessageText(userMessage);
const promptParts = splitUserMessageBlocks(userMessage);
const humanInput = readHumanInputFromSdkMessage(userMessage);
if (!input) {
debugLogger.debug('[Session] No text content in user message');
@@ -559,6 +560,8 @@ class Session {
try {
await runNonInteractive(this.config, this.settings, input, promptId, {
...(humanInput ? { humanInput } : {}),
...(promptParts ? { promptParts } : {}),
...(isToolMessage(userMessage) ? { fromTool: true } : {}),
modSession: this.modSession,
abortController: turnAbortController,
adapter: this.outputAdapter,
@@ -1109,6 +1112,47 @@ class Session {
}
}
/**
* Whether a user message answers a tool call or carries tool blocks, so no
* person typed it.
*/
function isToolMessage(message: CLIUserMessage): boolean {
if (message.parent_tool_use_id != null) return true;
const content = message.message.content;
return (
Array.isArray(content) &&
content.some(
(block) =>
!!block &&
typeof block === 'object' &&
'type' in block &&
(block.type === 'tool_result' || block.type === 'tool_use'),
)
);
}
/**
* The text blocks and the other blocks of a message that has both kinds, the
* other blocks serialized as `extractUserMessageText` does.
*/
function splitUserMessageBlocks(
message: CLIUserMessage,
): { text: string; blocks: string[] } | undefined {
const content = message.message.content;
if (!Array.isArray(content)) return undefined;
const text: string[] = [];
const blocks: string[] = [];
for (const block of content) {
if (!block || typeof block !== 'object') continue;
if ('type' in block && block.type === 'text' && 'text' in block) {
if (typeof block.text === 'string' && block.text) text.push(block.text);
} else {
blocks.push(JSON.stringify(block));
}
}
return blocks.length ? { text: text.join('\n'), blocks } : undefined;
}
function extractUserMessageText(message: CLIUserMessage): string | null {
const content = message.message.content;
if (typeof content === 'string') {
+182 -1
View File
@@ -489,6 +489,7 @@ describe('runNonInteractive', () => {
syncSession: vi.fn().mockResolvedValue(undefined),
hasToolHooks: true,
toolCall: () => undefined,
submitPrompt: vi.fn(async (text: string) => ({ text })),
} as unknown as ModSession);
}
@@ -6720,6 +6721,16 @@ describe('runNonInteractive', () => {
return turn;
},
);
const submitPrompt = vi.fn(
async (
text: string,
_signal: AbortSignal,
): Promise<
{ text: string; context?: string[] } | { drop: string }
> => ({
text,
}),
);
vi.spyOn(ModSession, 'create').mockImplementation(
() =>
({
@@ -6735,9 +6746,10 @@ describe('runNonInteractive', () => {
hasToolHooks: shared,
toolCall: () => undefined,
startTurn,
submitPrompt,
}) as unknown as ModSession,
);
return { order, turn, startTurn, abort: () => abort() };
return { order, turn, startTurn, submitPrompt, abort: () => abort() };
};
// Admits the prompt, as core does once UserPromptSubmit lets it through.
const admitting = (events: () => AsyncGenerator<ServerLlmStreamEvent>) =>
@@ -6778,6 +6790,175 @@ describe('runNonInteractive', () => {
expect(order.slice(0, 3)).toEqual(['start', 'complete', 'close']);
});
it('sends the prompt and context the prompt.submit hooks passed on', async () => {
setupMetricsMock();
const { order, startTurn, submitPrompt } = installModTurn();
submitPrompt.mockImplementation(async (text) => {
order.push('submit');
return { text: text.toUpperCase(), context: ['note'] };
});
admitting(() =>
createStreamFromEvents([
{
type: LlmEventType.Finished,
value: { reason: undefined, usageMetadata: undefined },
},
]),
);
await runNonInteractive(mockConfig, mockSettings, 'Fix it', 'rewrite');
expect(submitPrompt).toHaveBeenCalledExactlyOnceWith(
'Fix it',
mockLlmClient.sendMessageStream.mock.calls[0][1],
);
const [request, , , options] =
mockLlmClient.sendMessageStream.mock.calls[0];
expect(JSON.stringify(request)).toContain('FIX IT');
expect(JSON.stringify(request)).not.toContain('Fix it');
expect(options).toMatchObject({
submittedPrompt: 'FIX IT',
promptContext: ['note'],
});
expect(startTurn.mock.calls[0][0]).toBe('FIX IT');
expect(order.slice(0, 2)).toEqual(['submit', 'start']);
});
it('lets the hooks hear and rewrite only the text of a message with other blocks', async () => {
setupMetricsMock();
const { startTurn, submitPrompt } = installModTurn();
submitPrompt.mockImplementation(async (text) => ({
text: text.toUpperCase(),
}));
admitting(() =>
createStreamFromEvents([
{
type: LlmEventType.Finished,
value: { reason: undefined, usageMetadata: undefined },
},
]),
);
const image = '{"type":"image"}';
await runNonInteractive(
mockConfig,
mockSettings,
`Fix it\n${image}`,
'blocks',
{
humanInput: { text: 'Fix it' },
promptParts: { text: 'Fix it', blocks: [image] },
},
);
expect(submitPrompt).toHaveBeenCalledExactlyOnceWith(
'Fix it',
expect.any(AbortSignal),
);
const [request, , , options] =
mockLlmClient.sendMessageStream.mock.calls[0];
expect(request).toEqual([{ text: `FIX IT\n${image}` }]);
expect(options).toMatchObject({
submittedPrompt: `FIX IT\n${image}`,
humanInput: { text: 'FIX IT' },
});
expect(startTurn.mock.calls[0][0]).toBe('FIX IT');
});
it.each([OutputFormat.JSON, OutputFormat.TEXT])(
'ends a run whose prompt a hook dropped with a result in %s',
async (format) => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(format);
setupMetricsMock();
const { startTurn, submitPrompt } = installModTurn();
submitPrompt.mockResolvedValue({
drop: 'Prompt dropped by a hook: no secrets',
});
const exitCode = await runNonInteractive(
mockConfig,
mockSettings,
'a secret',
'dropped',
);
expect(exitCode).toBe(1);
expect(mockLlmClient.sendMessageStream).not.toHaveBeenCalled();
expect(startTurn).not.toHaveBeenCalled();
if (format === OutputFormat.JSON) {
const messages = JSON.parse(
processStdoutSpy.mock.calls.map((call) => String(call[0])).join(''),
);
expect(messages.at(-1)).toMatchObject({
type: 'result',
is_error: true,
error: { message: 'Prompt dropped by a hook: no secrets' },
});
} else {
expect(processStderrSpy).toHaveBeenCalledWith(
'Prompt dropped by a hook: no secrets\n',
);
}
},
);
it('raises no prompt.submit for a message no person typed or without text', async () => {
setupMetricsMock();
const { submitPrompt } = installModTurn();
mockLlmClient.sendMessageStream.mockImplementation(() =>
createStreamFromEvents([]),
);
const result = '{"type":"tool_result","tool_use_id":"t1"}';
await runNonInteractive(mockConfig, mockSettings, result, 'tool', {
promptParts: { text: '', blocks: [result] },
fromTool: true,
});
await runNonInteractive(
mockConfig,
mockSettings,
`Done\n${result}`,
'tool-text',
{
promptParts: { text: 'Done', blocks: [result] },
fromTool: true,
},
);
const image = '{"type":"image"}';
await runNonInteractive(mockConfig, mockSettings, image, 'image', {
promptParts: { text: '', blocks: [image] },
});
expect(submitPrompt).not.toHaveBeenCalled();
expect(mockLlmClient.sendMessageStream).toHaveBeenCalledTimes(3);
});
it('raises no prompt.submit for a slash command or a notification', async () => {
setupMetricsMock();
const { submitPrompt } = installModTurn();
mockGetCommands.mockReturnValue([
{
name: 'review',
description: 'review',
kind: CommandKind.FILE,
action: vi.fn().mockResolvedValue({
type: 'submit_prompt',
content: [{ text: 'Review the diff' }],
}),
},
]);
mockLlmClient.sendMessageStream.mockImplementation(() =>
createStreamFromEvents([]),
);
await runNonInteractive(mockConfig, mockSettings, '/review', 'slash');
await runNonInteractive(mockConfig, mockSettings, 'done', 'note', {
sendMessageType: SendMessageType.Notification,
});
expect(submitPrompt).not.toHaveBeenCalled();
});
it('starts no turn for a prompt core never admits', async () => {
setupMetricsMock();
const { startTurn } = installModTurn();
+68 -5
View File
@@ -429,6 +429,17 @@ async function emitNonInteractiveFinalMessage(params: {
*/
export interface RunNonInteractiveOptions {
humanInput?: HumanPromptInput;
/**
* A structured SDK message whose `input` also carries blocks that are not
* text: its text blocks, which the Mods' prompt.submit hooks hear, and its
* other blocks as `input` serializes them, which follow a rewritten text.
*/
promptParts?: { text: string; blocks: string[] };
/**
* An SDK message no person typed: it carries tool blocks or answers a tool
* call. The Mods' prompt.submit hooks do not hear it.
*/
fromTool?: boolean;
modSession?: ModSession;
abortController?: AbortController;
adapter?: JsonOutputAdapterInterface;
@@ -1174,7 +1185,7 @@ export async function runNonInteractive(
options.userMessage,
);
const userMessageContent = options.userMessage?.message.content;
const submittedPrompt =
let submittedPrompt =
typeof userMessageContent === 'string'
? userMessageContent
: Array.isArray(userMessageContent)
@@ -1188,6 +1199,11 @@ export async function runNonInteractive(
// submitted prompt runs on the chosen model without a session switch.
let inlineModelOverride: string | undefined;
let promptAdmission: UserPromptAdmission | undefined;
// The prompt as the Mods' prompt.submit hooks passed it on, the text
// they rewrote it to, and the context they attached for the model.
let prompt = input;
let rewrittenText: string | undefined;
let promptContext: string[] | undefined;
if (options.continueInterrupted) {
// Read the full history, not a bounded tail: the Retry send path in
@@ -1399,8 +1415,49 @@ export async function runNonInteractive(
}
if (!slashHandled) {
// The hooks hear what was typed, not the serialized blocks.
const heard = options.promptParts?.text ?? input;
if (
modSession &&
!options.fromTool &&
heard.trim() &&
(options.sendMessageType ?? SendMessageType.UserQuery) ===
SendMessageType.UserQuery
) {
let submitted: Awaited<ReturnType<ModSession['submitPrompt']>>;
try {
submitted = await modSession.submitPrompt(
heard,
abortController.signal,
);
} catch (error) {
if (abortController.signal.aborted) await routeAbort();
throw error;
}
if ('drop' in submitted) {
await emitNonInteractiveFinalMessage({
message: submitted.drop,
isError: true,
adapter,
config,
startTimeMs: startTime,
beforeEmit: settleBeforeTerminalOutput,
});
return 1;
}
if (submitted.text !== heard) {
rewrittenText = submitted.text;
prompt = options.promptParts
? [rewrittenText, ...options.promptParts.blocks].join('\n')
: rewrittenText;
// The transcript keeps the same shape it has without a
// rewrite: a message's other blocks stay after its text.
submittedPrompt = prompt;
}
promptContext = submitted.context;
}
const { processedQuery, shouldProceed } = await handleAtCommand({
query: input,
query: prompt,
config,
onDebugMessage: () => {},
messageId: Date.now(),
@@ -1419,7 +1476,7 @@ export async function runNonInteractive(
}
if (!initialPartList) {
initialPartList = [{ text: input }];
initialPartList = [{ text: prompt }];
}
// Inject a worktree context notice into the model's first prompt.
@@ -2901,7 +2958,7 @@ export async function runNonInteractive(
modTurn = modSession.startTurn(
options.continueInterrupted
? ''
: (options.humanInput?.text ?? input),
: (rewrittenText ?? options.humanInput?.text ?? input),
abortController.signal,
() => {
if (abortController.signal.aborted) return false;
@@ -2919,8 +2976,14 @@ export async function runNonInteractive(
!isSlashCommand(input) &&
options.humanInput &&
!isSlashCommand(options.humanInput.text) && {
humanInput: options.humanInput,
humanInput:
rewrittenText !== undefined
? { text: rewrittenText }
: options.humanInput,
}),
...(isFirstTurn &&
sendType === SendMessageType.UserQuery &&
promptContext?.length && { promptContext }),
...(isFirstTurn &&
sendType === SendMessageType.UserQuery &&
!options.continueInterrupted &&
@@ -14,8 +14,11 @@ import { convertClaudePluginPackage } from '@qwen-code/qwen-code-core/extension/
import { OutputFormat } from '@qwen-code/qwen-code-core/output/types.js';
import {
MOD_END_TIMEOUT_MS,
MOD_MAX_BYTES,
type ModCommand,
type ModLog,
type ModPromptSubmit,
type ModPromptSubmitted,
type ModSessionFacts,
type ModTurnComplete,
type ModTurnStart,
@@ -106,6 +109,14 @@ describe('Mod session ownership', () => {
end: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
turnHooks: { start: false, complete: [] as string[] },
promptHooks: [] as string[],
submitPrompt:
vi.fn<
(
input: ModPromptSubmit,
signal: AbortSignal,
) => Promise<ModPromptSubmitted>
>(),
startTurn:
vi.fn<(input: ModTurnStart, signal: AbortSignal) => Promise<void>>(),
completeTurn:
@@ -302,6 +313,7 @@ describe('Mod session ownership', () => {
});
const commands = await session.loadCommands(signal);
expect(mocks.create).toHaveBeenCalledTimes(1);
expect(mocks.create.mock.calls[0][10]).toBe(true);
expect(await commands[0].action!({} as CommandContext, 'hello')).toEqual({
type: 'message',
messageType: 'info',
@@ -357,6 +369,8 @@ describe('Mod session ownership', () => {
presentation,
);
expect(mocks.create.mock.calls[0][5]).toBe('terminal');
// The terminals raise no prompt.submit yet.
expect(mocks.create.mock.calls[0][10]).toBe(false);
});
it.each([false, true])(
@@ -674,6 +688,98 @@ describe('Mod session ownership', () => {
return { ...loaded, abortTurn, ended };
};
const loadPrompts = async () => {
const loaded = setup();
loaded.runtime.promptHooks = ['hello-mod'];
await loaded.session.initialize(new AbortController().signal);
return loaded;
};
it('passes a prompt through when no hook hears prompt.submit', async () => {
const { session, runtime } = setup();
await session.initialize(new AbortController().signal);
expect(await session.submitPrompt('hi', turnSignal)).toEqual({
text: 'hi',
});
expect(runtime.submitPrompt).not.toHaveBeenCalled();
});
it('enters the prompt and context the hooks passed on', async () => {
const { session, runtime } = await loadPrompts();
runtime.submitPrompt.mockResolvedValue({ text: 'HI', context: ['note'] });
expect(await session.submitPrompt('hi', turnSignal)).toEqual({
text: 'HI',
context: ['note'],
});
expect(runtime.submitPrompt).toHaveBeenCalledWith(
{ text: 'hi', wait: false, origin: { kind: 'sdk' } },
expect.any(AbortSignal),
);
});
it('drops a prompt the hooks rewrote to nothing', async () => {
const { session, runtime } = await loadPrompts();
for (const text of ['', ' \n ']) {
runtime.submitPrompt.mockResolvedValueOnce({ text, context: ['note'] });
expect(await session.submitPrompt('hi', turnSignal)).toEqual({
drop: 'Prompt dropped by a hook: it left the prompt empty',
});
}
});
it('says why a prompt was dropped, in one line of 4,096 characters', async () => {
const { session, runtime } = await loadPrompts();
runtime.submitPrompt.mockResolvedValueOnce({
drop: 'it holds\na secret\u001b[31m',
byHook: true,
});
expect(await session.submitPrompt('hi', turnSignal)).toEqual({
drop: 'Prompt dropped by a hook: it holds a secret',
});
runtime.submitPrompt.mockResolvedValueOnce({
drop: 'x'.repeat(5000),
byHook: true,
});
expect(await session.submitPrompt('hi', turnSignal)).toEqual({
drop: `Prompt dropped by a hook: ${'x'.repeat(4096)}…`,
});
runtime.submitPrompt.mockResolvedValueOnce({
drop: 'a hook answered without passing the prompt on',
byHook: false,
});
expect(await session.submitPrompt('hi', turnSignal)).toEqual({
drop: 'Prompt not submitted: a hook answered without passing the prompt on',
});
});
it('lets a prompt through when the hooks fail, but not when it is cancelled', async () => {
const { session, runtime } = await loadPrompts();
runtime.submitPrompt.mockRejectedValueOnce(new Error('worker failed'));
expect(await session.submitPrompt('hi', turnSignal)).toEqual({
text: 'hi',
});
const cancel = new AbortController();
runtime.submitPrompt.mockImplementationOnce(async (_input, signal) => {
cancel.abort();
signal.throwIfAborted();
return { text: 'unreached' };
});
await expect(session.submitPrompt('hi', cancel.signal)).rejects.toThrow();
});
it('runs no hook on a prompt too long for a Mod payload, and says so', async () => {
const { session, runtime, adapter } = await loadPrompts();
const long = 'x'.repeat(MOD_MAX_BYTES);
expect(await session.submitPrompt(long, turnSignal)).toEqual({
text: long,
});
expect(runtime.submitPrompt).not.toHaveBeenCalled();
expect(adapter.emitSystemMessage).toHaveBeenLastCalledWith('ui_log', {
plugin: 'hello-mod',
text: 'prompt.submit hook skipped: the prompt is too long for a Mod payload',
});
});
it('starts no turn when no hook hears the turn events', async () => {
const { session, runtime } = setup();
await session.initialize(new AbortController().signal);
+76
View File
@@ -35,6 +35,7 @@ import {
import type {
ModCommandPresentation,
ModLog,
ModPromptSubmit,
ModSessionFacts,
ModTurnComplete,
ModTurnStart,
@@ -403,6 +404,8 @@ export class ModSession implements ICommandLoader {
sites.length
? { sites, invalidate: () => this.requestRender() }
: undefined,
// The terminals raise no prompt.submit yet.
!this.terminal,
);
this.mods.push(
...ordered.map(({ extension }) => ({
@@ -698,6 +701,79 @@ export class ModSession implements ICommandLoader {
};
}
/**
* Raises `prompt.submit` for a prompt a person submitted, before
* UserPromptSubmit. Returns the prompt that enters, with the context the
* hooks attached, or the line saying why it was dropped. A hook that fails
* never blocks a prompt, and neither does a prompt too long for a Mod
* payload, which no hook hears. `signal` is the prompt's own.
*/
async submitPrompt(
text: string,
signal: AbortSignal,
): Promise<{ text: string; context?: string[] } | { drop: string }> {
const plugins = this.runtime?.promptHooks ?? [];
if (!plugins.length || this.closing) return { text };
const input: ModPromptSubmit = {
text,
wait: false,
origin: { kind: this.terminal ? 'composer' : 'sdk' },
};
if (fitTurnText(text).truncated) {
this.pendingLogs.push({
text: 'prompt.submit hook skipped: the prompt is too long for a Mod payload',
to: 'transcript',
plugin: plugins.join('+'),
sessionId: this.sessionId,
});
this.flushLogs();
return { text };
}
const sessionId = this.sessionId;
const task = this.toolQueue.then(async () => {
signal.throwIfAborted();
if (
this.closing ||
sessionId !== this.config.getSessionId() ||
!this.allowed() ||
!this.active()
)
return { text };
try {
return await this.runtime!.submitPrompt(
input,
AbortSignal.any([signal, this.lifetime.signal]),
);
} catch (error) {
signal.throwIfAborted();
logger.debug(`Mod prompt.submit: ${String(error)}`);
return { text };
} finally {
this.flushLogs();
}
});
this.toolQueue = task.catch(() => {});
const result = await task;
if (!('drop' in result)) {
// A rewrite to nothing would send the model an empty prompt.
if (!result.text.trim() && text.trim())
return { drop: 'Prompt dropped by a hook: it left the prompt empty' };
return result;
}
if (!result.byHook) return { drop: `Prompt not submitted: ${result.drop}` };
// A drop's reason is one line, kept to its first 4,096 characters.
const reason = (this.terminal ? sanitizeTerminalLine : stripAnsiAndControl)(
result.drop.replace(/\r?\n/g, ' ').trim(),
);
if (cpLen(reason) > 4096)
logger.debug(
`${plugins.join('+')}: prompt.submit hook: a drop of ${cpLen(reason)} characters, kept up to its first 4096`,
);
return {
drop: `Prompt dropped by a hook: ${cpLen(reason) > 4096 ? `${cpSlice(reason, 0, 4096)}…` : reason}`,
};
}
/**
* Starts a main-loop turn once its prompt is admitted, before its first
* model request. `signal` is the turn's own: cancelling the turn ends its
+88
View File
@@ -13883,6 +13883,42 @@ Other open files:
expect(promptId).toBe('prompt-hook-context-tag');
});
it('puts the Mods prompt context ahead of the hook context, escaped', async () => {
const recordUserMessage = vi.fn();
vi.mocked(mockConfig.getChatRecordingService).mockReturnValue({
recordUserMessage,
recordCronPrompt: vi.fn(),
recordAttributionSnapshot: vi.fn(),
} as unknown as ReturnType<Config['getChatRecordingService']>);
mockTurnRunFn.mockReturnValue(textTurn('ok'));
const tag = (context: string) =>
`<qwen:user-prompt-submit-context>\n${context}\n</qwen:user-prompt-submit-context>`;
// Without UserPromptSubmit hooks the Mods' context still enters.
await run([{ text: 'my prompt' }], 'prompt-mod-context', {
type: SendMessageType.UserQuery,
promptContext: ['<mod> note', ' '],
});
expect(
recordUserMessage.mock.calls[0]![0].map(
(part: { text: string }) => part.text,
),
).toEqual(['my prompt', tag('&lt;mod&gt; note')]);
expect(recordUserMessage.mock.calls[0]![2]).toEqual({
displayText: 'my prompt',
hookContext: '&lt;mod&gt; note',
});
promptSubmitBus(extraHookContext());
await run([{ text: 'next prompt' }], 'prompt-both-contexts', {
type: SendMessageType.UserQuery,
promptContext: ['first', 'second'],
});
expect(getLastTurnRequestText()).toContain(
tag('first\n\nsecond\n\nextra hook context'),
);
});
it.each([
{ name: 'with a stable catalog', catalogOn: true },
{ name: 'without a catalog', catalogOn: false },
@@ -14090,6 +14126,58 @@ Other open files:
);
});
it('counts the Mods context and the hook context as delivered each on its own', async () => {
installHistoryChat([
{
role: 'user',
parts: [
{ text: 'earlier prompt' },
createUserPromptSubmitContextPart(
'mod one\n\nextra hook context',
['mod one', 'extra hook context'],
),
],
},
]);
promptSubmitBus(extraHookContext());
const recordUserMessage = vi.fn();
vi.mocked(mockConfig.getChatRecordingService).mockReturnValue({
recordUserMessage,
recordCronPrompt: vi.fn(),
recordAttributionSnapshot: vi.fn(),
} as unknown as ReturnType<Config['getChatRecordingService']>);
mockTurnRunFn.mockReturnValue(textTurn('ok'));
const tag = (context: string) =>
`<qwen:user-prompt-submit-context>\n${context}\n</qwen:user-prompt-submit-context>`;
// A Mod context that changed is sent; the unchanged hook context is
// not sent again beside it.
await run([{ text: 'my prompt' }], 'prompt-mod-context-changed', {
type: SendMessageType.UserQuery,
promptContext: ['mod two'],
});
expect(
recordUserMessage.mock.calls[0]![0].map(
(part: { text: string }) => part.text,
),
).toEqual(['my prompt', tag('mod two')]);
expect(recordUserMessage.mock.calls[0]![2]).toEqual({
displayText: 'my prompt',
hookContext: 'mod two',
});
// Both already delivered: nothing is appended.
await run([{ text: 'again' }], 'prompt-mod-context-same', {
type: SendMessageType.UserQuery,
promptContext: ['mod one'],
});
expect(
recordUserMessage.mock.calls[1]![0].map(
(part: { text: string }) => part.text,
),
).toEqual(['again']);
});
it('still appends an unchanged block to a request with no parts of its own', async () => {
installHistoryChat([
{
+70 -24
View File
@@ -212,7 +212,7 @@ import { logApiRetry } from '../telemetry/loggers.js';
import { shouldUsePlanOnlyReminderInSubagentContext } from '../agents/runtime/subagent-plan-tool-policy.js';
import {
createUserPromptSubmitContextPart,
hasUserPromptSubmitContext,
deliveredUserPromptSubmitContexts,
} from '../hooks/user-prompt-submit-context.js';
import {
TrustedUserAnswers,
@@ -319,6 +319,12 @@ export interface SendMessageOptions {
* Retry with the prompt id it was issued for; any other use throws.
*/
promptAdmission?: UserPromptAdmission;
/**
* What the Mods' `prompt.submit` hooks attached for the model to read
* beside this UserQuery's prompt. It joins UserPromptSubmit's context in
* the hidden block the person never sees, ahead of it.
*/
promptContext?: readonly string[];
/** A UserQuery running beside an active turn, without replacing its state. */
isConcurrentSideQuery?: boolean;
/**
@@ -4229,6 +4235,8 @@ export class LlmClient {
let userPromptRecordPayload: UserPromptRecordPayload | undefined;
/** Hook context appended to this turn's request, if any. */
let appendedHookContext: string | undefined;
/** The separate contexts `appendedHookContext` joins. */
let appendedHookContexts: readonly string[] | undefined;
/** Hook context suppressed as already delivered; compression may need it back. */
let suppressedHookContext: string | undefined;
let hooksEnabled: boolean;
@@ -4244,6 +4252,14 @@ export class LlmClient {
? options.submittedPrompt
: undefined;
let verdict: (UserPromptSubmitDecision & { prompt: string }) | undefined;
const promptContext =
messageType === SendMessageType.UserQuery
? options?.promptContext?.filter((text) => text.trim())
: undefined;
if (options?.promptContext) {
// Spent here, as the admission below is.
options = { ...options, promptContext: undefined };
}
if (options?.promptAdmission) {
const { promptAdmission } = options;
// Spent here: the continuation sends below re-spread `options`.
@@ -4281,8 +4297,8 @@ export class LlmClient {
};
}
if (verdict) {
if (verdict.blockReason !== undefined) {
if (verdict || promptContext?.length) {
if (verdict?.blockReason !== undefined) {
if (goalPermit) {
userPromptSubmitFailureMessage = 'Goal turn finalization failed';
const runtime = await loadGoalRuntime(true);
@@ -4341,39 +4357,66 @@ export class LlmClient {
// distinguishable from user-authored text in model history, resume,
// and offline transcript analysis. `getAdditionalContext()` escapes
// `<`/`>`, so hook output cannot forge the closing tag.
// An unchanged payload is already in history from an earlier turn, so
// re-appending it would duplicate it once per turn for the rest of the
// session; the earlier copy still carries the same text. History is
// scanned newest-first over the raw entries, so the check stays off
// the deep-cloning accessor on this per-turn path.
// A context already in history from an earlier turn is not sent
// again, or it would repeat once per turn for the rest of the
// session; the earlier copy still carries the same text. Each context
// counts on its own, the Mods' contexts and UserPromptSubmit's, so a
// Mod whose context changes every turn does not make an unchanged
// UserPromptSubmit context repeat. History is scanned newest-first
// over the raw entries, so the check stays off the deep-cloning
// accessor on this per-turn path.
// A request with no parts of its own keeps the block, which is then
// all that keeps it non-empty (`toParts` rejects an empty array).
const additionalContext = verdict.additionalContext;
if (additionalContext) {
// The Mods' context comes first, as their hooks run above the
// settings hooks, and is escaped the same way.
const contexts = [
...new Set([
...(promptContext ?? []).map((text) =>
text.replace(/</g, '&lt;').replace(/>/g, '&gt;'),
),
...(verdict?.additionalContext ? [verdict.additionalContext] : []),
]),
];
if (contexts.length > 0) {
const requestArray = Array.isArray(request) ? request : [request];
const alreadyDelivered =
requestArray.length > 0 &&
Boolean(
this.getChat().findLastHistoryEntry((entry) =>
hasUserPromptSubmitContext(entry, additionalContext),
),
);
if (!alreadyDelivered) {
const undelivered = new Set(contexts);
if (requestArray.length > 0) {
this.getChat().findLastHistoryEntry((entry) => {
for (const context of deliveredUserPromptSubmitContexts(entry)) {
undelivered.delete(context);
}
return undelivered.size === 0;
});
}
const pending = contexts.filter((context) =>
undelivered.has(context),
);
const delivered = contexts.filter(
(context) => !undelivered.has(context),
);
const additionalContext = pending.join('\n\n');
if (pending.length > 0) {
request = [
...requestArray,
createUserPromptSubmitContextPart(additionalContext),
createUserPromptSubmitContextPart(additionalContext, pending),
];
appendedHookContext = additionalContext;
} else {
suppressedHookContext = additionalContext;
appendedHookContexts = pending;
}
if (delivered.length > 0) {
suppressedHookContext = delivered.join('\n\n');
}
if (messageType === SendMessageType.UserQuery) {
// A suppressed turn appends nothing, so it records no
// hookContext, but the user-authored projection still belongs in
// the transcript.
userPromptRecordPayload = {
displayText: submittedPrompt ?? verdict.prompt,
hookContext: alreadyDelivered ? '' : additionalContext,
displayText:
submittedPrompt ??
verdict?.prompt ??
preHookUserPromptText ??
'',
hookContext: additionalContext,
};
}
}
@@ -4964,7 +5007,10 @@ export class LlmClient {
// that history carries the same provenance resume restores from the
// recorded payload.
requestToSend[requestToSend.length - 1] =
createUserPromptSubmitContextPart(appendedHookContext);
createUserPromptSubmitContextPart(
appendedHookContext,
appendedHookContexts,
);
}
if (ideContextText) {
const firstText = requestToSend.find(
@@ -7,6 +7,7 @@
import { describe, it, expect } from 'vitest';
import {
createUserPromptSubmitContextPart,
deliveredUserPromptSubmitContexts,
hasUserPromptSubmitContext,
isUserPromptSubmitContextPartText,
restoreUserPromptSubmitContextReceipt,
@@ -268,3 +269,43 @@ describe('isUserPromptSubmitContextPartText', () => {
expect(isUserPromptSubmitContextPartText('')).toBe(false);
});
});
describe('deliveredUserPromptSubmitContexts', () => {
it('lists a joined block context by context, and an unstamped one whole', () => {
expect(
deliveredUserPromptSubmitContexts({
role: 'user',
parts: [
{ text: 'prompt' },
createUserPromptSubmitContextPart('a\n\nb', ['a', 'b']),
createUserPromptSubmitContextPart('c\n\nd'),
],
}),
).toEqual(['a', 'b', 'c\n\nd']);
});
it('ignores blocks without the receipt, a leading block and model entries', () => {
const block = createUserPromptSubmitContextPart('a');
expect(
deliveredUserPromptSubmitContexts({
role: 'user',
parts: [{ text: 'prompt' }, { text: wrapUserPromptSubmitContext('a') }],
}),
).toEqual([]);
expect(
deliveredUserPromptSubmitContexts({
role: 'user',
parts: [block, { text: 'prompt' }],
}),
).toEqual([]);
expect(
deliveredUserPromptSubmitContexts({
role: 'model',
parts: [{ text: 'x' }, block],
}),
).toEqual([]);
expect(
deliveredUserPromptSubmitContexts({ role: 'user', parts: [block] }),
).toEqual(['a']);
});
});
@@ -6,6 +6,8 @@
import {
isUserPromptSubmitContextPartText as isUserPromptSubmitContextPartTextInternal,
USER_PROMPT_SUBMIT_CONTEXT_CLOSE,
USER_PROMPT_SUBMIT_CONTEXT_OPEN,
wrapUserPromptSubmitContext,
} from '../utils/transcript-records.js';
@@ -35,13 +37,30 @@ type UserPromptSubmitContextPart = { text: string } & Record<symbol, unknown>;
*/
const userPromptSubmitContextReceipt = Symbol('userPromptSubmitContextReceipt');
/** Builds the tagged part the injector appends, stamped with its receipt. */
/**
* The separate contexts a block joins, when it joins more than one: the
* Mods' contexts and UserPromptSubmit's. Like the receipt, only the live part
* carries it; a resumed block counts as one context.
*/
const userPromptSubmitContextSegments = Symbol(
'userPromptSubmitContextSegments',
);
/**
* Builds the tagged part the injector appends, stamped with its receipt.
* `segments` names the contexts `context` joins, so each one is found
* delivered on its own later.
*/
export function createUserPromptSubmitContextPart(
context: string,
segments?: readonly string[],
): UserPromptSubmitContextPart {
return {
text: wrapUserPromptSubmitContext(context),
[userPromptSubmitContextReceipt]: true,
...(segments && segments.length > 1
? { [userPromptSubmitContextSegments]: [...segments] }
: {}),
};
}
@@ -126,3 +145,33 @@ export function hasUserPromptSubmitContext(
part[userPromptSubmitContextReceipt] === true,
);
}
/**
* The contexts the injector-owned blocks of `entry` delivered, by the same
* evidence as `hasUserPromptSubmitContext`: each block's joined contexts one
* by one when it was stamped with them, otherwise its whole context.
*/
export function deliveredUserPromptSubmitContexts(entry: unknown): string[] {
const candidate = entry as { role?: unknown; parts?: unknown } | null;
if (candidate?.role !== 'user' || !Array.isArray(candidate.parts)) {
return [];
}
const parts = candidate.parts as Array<UserPromptSubmitContextPart | null>;
const prefix = `${USER_PROMPT_SUBMIT_CONTEXT_OPEN}\n`;
const suffix = `\n${USER_PROMPT_SUBMIT_CONTEXT_CLOSE}`;
return parts.flatMap((part, index) => {
if (
!(index > 0 || parts.length === 1) ||
part?.[userPromptSubmitContextReceipt] !== true ||
typeof part.text !== 'string' ||
!part.text.startsWith(prefix) ||
!part.text.endsWith(suffix)
) {
return [];
}
const segments = part[userPromptSubmitContextSegments];
return Array.isArray(segments)
? (segments as string[])
: [part.text.slice(prefix.length, -suffix.length)];
});
}
+35 -2
View File
@@ -35,6 +35,7 @@ export const MOD_EVENTS = [
'clock.sleep',
'command.register',
'command.run',
'prompt.submit',
'session.cwd',
'session.end',
'session.id',
@@ -55,8 +56,12 @@ export const MOD_EVENTS = [
'ui.log',
'ui.render',
] as const;
// Events a host raises only where it draws: the load request names the sites.
export const MOD_HOST_EVENTS: readonly ModEvent[] = ['ui.render'];
// Events only some hosts raise: the load request names the sites a host
// draws, and whether it raises `prompt.submit`.
export const MOD_HOST_EVENTS: readonly ModEvent[] = [
'prompt.submit',
'ui.render',
];
// `$` methods that read rather than dispatch, so no hook hears them.
export const MOD_READS = ['ui.resolve'] as const;
export type ModEvent = (typeof MOD_EVENTS)[number];
@@ -127,6 +132,18 @@ export function validateInput(
!isDeepStrictEqual({ ...value, args: '' }, { ...original, args: '' })
)
throw new Error('Mod hook changed pinned event fields.');
} else if (event === 'prompt.submit') {
if (
!only(value, ['text', 'context', 'origin', 'wait', 'turnId']) ||
!text(value['text']) ||
!validContext(value['context']) ||
['origin', 'wait', 'turnId'].some(
(key) => !isDeepStrictEqual(value[key], original[key]),
)
)
throw new Error(
'Mod prompt.submit takes { text, context } as a list of texts; origin, wait and turnId are pinned.',
);
} else if (event === 'command.register') {
if (
Object.keys(value).some(
@@ -252,6 +269,16 @@ export function validateResult(
valid =
Object.keys(value).every((key) => key === echoed) &&
typeof value[echoed] === 'string';
else if (event === 'prompt.submit')
valid =
'drop' in value
? only(value, ['drop']) && text(value['drop'])
: only(value, ['text', 'context', 'origin']) &&
text(value['text']) &&
validContext(value['context']) &&
// An answer may leave the origin out, never set another.
(value['origin'] === undefined ||
isDeepStrictEqual(value['origin'], input?.['origin']));
else if (event === 'turn.complete')
valid =
only(value, ['text', 'usage']) &&
@@ -268,6 +295,10 @@ export function validateResult(
if (!valid) throw new Error('Unsupported ' + event + ' result fields.');
}
// What the model reads beside a prompt: a list of texts.
const validContext = (value: unknown) =>
value === undefined || (Array.isArray(value) && value.every(text));
function validUsage(value: unknown): boolean {
if (
!record(value) ||
@@ -395,5 +426,7 @@ export interface ModInvocation {
recovering: boolean;
aborted: boolean;
last?: Promise<unknown>;
// A local late-drop fallback does not count as a host continuation.
promptFallback?: Promise<unknown>;
next: (input: ModInput, frames: ModFrame[]) => Promise<unknown>;
}
+21 -3
View File
@@ -254,21 +254,39 @@ export const MOD_GUEST_FACTORY = String.raw`
run.signal = signalFor(run);
runs.set(run.id, run);
run.selected = hooks.filter(h => patternMatches(h.event, run.event) && !run.leftOut.includes(h.id));
// A caught late-drop error must not leave a stale plugin fallback.
const promptFallbacks = new Map();
const invoke = async (index, input, frames) => {
const hook = run.selected[index];
if (!hook) return call(run.id, frames, 'next', input);
run.inputs.set(hook.id, input);
if (hook.matcher !== undefined && !matches(hook.matcher, input)) return invoke(index + 1, input, frames);
const nested = [...frames, hook.id];
let last;
const next = nextFor(run, async e => {
status(run.id, 'validate', encode(e));
const result = await invoke(index + 1, frozen(e), nested);
last = invoke(index + 1, frozen(e), nested);
const result = await last;
if (!result) throw new Error('Mod hook returned no result');
return result;
});
return hook.handler(api(run.id, nested), frozen(input), next);
const answer = await hook.handler(api(run.id, nested), frozen(input), next);
if (run.event !== 'prompt.submit' || !last) return answer;
// What a hook answers after its next is not checked: only a drop counts.
const below = await last;
if (object(answer) && 'drop' in answer && (!object(below) || answer.drop !== below.drop)) {
const error = new Error('a drop after its next(e) was answered');
promptFallbacks.set(error, below);
throw error;
}
return below;
};
return encode(await invoke(0, run.input, []));
try {
return encode(await invoke(0, run.input, []));
} catch (error) {
if (promptFallbacks.has(error)) status(run.id, 'promptFallback', encode(promptFallbacks.get(error)));
throw error;
}
},
recover: async json => {
const recovery = parse(json);
+70 -5
View File
@@ -204,14 +204,24 @@ class Realm {
const status = this.vm.newFunction('modStatus', (id, key, input) => {
const call = calls.get(this.vm.getNumber(id));
const name = this.vm.getString(key);
if (name === 'validate' || name === 'validateResult') {
if (
name === 'validate' ||
name === 'validateResult' ||
name === 'promptFallback'
) {
if (!call?.live || callAbandoned(call))
throw new Error('Mod continuation is no longer active.');
const json = this.vm.getString(input);
if (name !== 'validate' && Buffer.byteLength(json) > MOD_MAX_BYTES)
throw new Error('Mod result is too large.');
assertModDepth(json, 'Mod value');
const value = JSON.parse(json);
if (name === 'validateResult') validateResult(call.event, value);
else validateInput(call.event, value, call.input);
if (name === 'validate') validateInput(call.event, value, call.input);
else {
validateResult(call.event, value, call.input);
if (name === 'promptFallback')
call.promptFallback = Promise.resolve(value);
}
return this.vm.undefined;
}
if (name === 'aborted')
@@ -507,6 +517,7 @@ async function dispatch(
assertLive(call);
recovery = {
...call,
last: call.last ? (call.promptFallback ?? call.last) : undefined,
id: ++serial,
budget: new ModBudget(1000),
live: true,
@@ -529,6 +540,9 @@ async function dispatch(
);
if (value !== undefined) {
validateResult(event, value, call.input);
await assertDropBeforeNext(event, value, recovery.last ?? call.last);
if (event === 'prompt.submit' && (recovery.last ?? call.last))
return await (recovery.last ?? call.last);
if (event === 'tool.check')
return await ownCheck(
value as ModToolCheckResult,
@@ -556,7 +570,10 @@ async function dispatch(
text: event + ' hook skipped: ' + why.slice(0, 2000),
});
}
return await (recovery.last ?? call.last ?? next(input, frames));
return await (recovery.last ??
call.last ??
call.promptFallback ??
next(input, frames));
}
} finally {
call.live = false;
@@ -574,6 +591,26 @@ async function dispatch(
}
}
// A prompt a hook passed on has entered beneath it, so the hook cannot then
// drop it; it fails, and its last next stands. Handing on the drop its next
// resolved to is no drop of its own.
async function assertDropBeforeNext(
event: ModEvent,
value: unknown,
last: Promise<unknown> | undefined,
): Promise<void> {
if (
event !== 'prompt.submit' ||
!last ||
!record(value) ||
!('drop' in value)
)
return;
const below = await last.catch(() => undefined);
if (!record(below) || below['drop'] !== value['drop'])
throw new Error('a drop after its next(e) was answered');
}
// A tree that does not validate is told in Claude Code's words, unprefixed.
const failure = (error: unknown) =>
error instanceof ModRenderShapeError ? error.message : String(error);
@@ -881,7 +918,11 @@ port.on('message', async (request: ModWorkerRequest) => {
version = request.version;
sites = request.sites ?? [];
supported = MOD_EVENTS.filter(
(event) => !MOD_HOST_EVENTS.includes(event) || sites.length > 0,
(event) =>
!MOD_HOST_EVENTS.includes(event) ||
(event === 'prompt.submit'
? request.prompts === true
: sites.length > 0),
);
for (const plugin of request.plugins) {
assertLive();
@@ -938,6 +979,7 @@ port.on('message', async (request: ModWorkerRequest) => {
commands: [...commands.values()],
tools: hooked('tool.call').length + hooked('tool.check').length > 0,
turnStart: hooked('turn.start').length > 0,
promptSubmit: hooked('prompt.submit'),
turnComplete: hooked('turn.complete'),
render: hooked('ui.render'),
};
@@ -977,6 +1019,29 @@ port.on('message', async (request: ModWorkerRequest) => {
[],
async (e) => ({ turnId: e['turnId'] }),
);
} else if (request.type === 'prompt-submit') {
// The prompt that enters is the one that arrives beneath the hooks.
let arrived: ModInput | undefined;
const settled = await dispatch(
'prompt.submit',
{ ...request.input },
engineOrigin,
[],
async (e) => {
arrived = {
text: e['text'],
...(e['context'] !== undefined && { context: e['context'] }),
};
return { ...arrived, origin: e['origin'] };
},
);
result =
record(settled) && typeof settled['drop'] === 'string'
? { drop: settled['drop'], byHook: true }
: (arrived ?? {
drop: 'a hook answered without passing the prompt on',
byHook: false,
});
} else if (request.type === 'render') {
if (!sites.includes(request.input.component))
throw new Error('This host draws no ' + request.input.component + '.');
@@ -95,6 +95,7 @@ describe('ModRuntime root budget protocol', () => {
json: JSON.stringify({
tools: true,
turnStart: false,
promptSubmit: [],
turnComplete: [],
render: [],
commands: [{ plugin: 'mod', name: 'hello', description: 'Hello' }],
+318
View File
@@ -813,6 +813,8 @@ describe('ModRuntime with the real QuickJS worker', () => {
'1.2.3',
undefined,
abortTurn,
undefined,
true,
);
runtimes.push(runtime);
return runtime;
@@ -1022,6 +1024,322 @@ describe('ModRuntime with the real QuickJS worker', () => {
]);
});
const prompt = {
text: 'Fix it',
wait: false,
origin: { kind: 'composer' as const },
};
it('names the plugins whose hooks hear prompt.submit', async () => {
const runtime = await createTurns([
`export function register(on) { on('prompt.submit', ($, e, next) => next(e)); }`,
`export function register(on) { on('turn.start', ($, e, next) => next(e)); }`,
`export function register(on) { on('prompt.*', ($, e, next) => next(e)); }`,
]);
expect(runtime.promptHooks).toEqual(['mod-0', 'mod-2']);
expect((await createTurns([fixture()])).promptHooks).toEqual([]);
// A host that raises no prompt.submit skips its hooks and says so.
const terminal = await createTerminal([
`export function register(on) { on('prompt.submit', ($, e, next) => next(e)); }`,
]);
expect(terminal.promptHooks).toEqual([]);
expect(logs.at(-1)?.text).toBe(
'Hooks for unsupported events will not run: prompt.submit',
);
});
it('enters the prompt and the context that arrive beneath the hooks', async () => {
const runtime = await createTurns([
`export function register(on) {
on('prompt.submit', async ($, e, next) => {
$.ui.log('top:' + JSON.stringify(e));
const entered = await next({...e, text: e.text + '!', context: ['a']});
$.ui.log('entered:' + JSON.stringify(entered));
// What a hook answers after its next resolved changes nothing.
return {...entered, text: 'later', context: ['late']};
});
}`,
`export function register(on) {
on('prompt.submit', ($, e, next) =>
next({...e, text: e.text.toUpperCase(), context: [...e.context, 'b']}));
}`,
]);
expect(await runtime.submitPrompt(prompt, signal())).toEqual({
text: 'FIX IT!',
context: ['a', 'b'],
});
expect(logs.map((log) => log.text)).toEqual([
'top:{"text":"Fix it","wait":false,"origin":{"kind":"composer"}}',
'entered:{"text":"FIX IT!","context":["a","b"],"origin":{"kind":"composer"}}',
]);
});
it('drops a prompt for a hook, and for one that does not pass it on', async () => {
const runtime = await createTurns([
`export function register(on) {
on('prompt.submit', async ($, e, next) => {
if (e.text === 'answer') return {text: 'mine'};
const below = await next(e);
$.ui.log('below:' + JSON.stringify(below));
return below;
});
}`,
`export function register(on) {
on('prompt.submit', ($, e, next) =>
e.text.includes('secret') ? {drop: 'it holds a secret'} : next(e));
}`,
]);
expect(
await runtime.submitPrompt({ ...prompt, text: 'a secret' }, signal()),
).toEqual({ drop: 'it holds a secret', byHook: true });
expect(
await runtime.submitPrompt({ ...prompt, text: 'answer' }, signal()),
).toEqual({
drop: 'a hook answered without passing the prompt on',
byHook: false,
});
expect(logs.map((log) => log.text)).toEqual([
'below:{"drop":"it holds a secret"}',
]);
});
it.each([false, true])(
'retains a downstream drop after a text answer (same plugin: %s)',
async (samePlugin) => {
const handlers = [
`on('prompt.submit', async ($, e, next) => {
await next(e);
return {text: e.text};
});`,
`on('prompt.*', () => ({drop: 'secret guard'}));`,
];
const runtime = await createTurns(
(samePlugin ? [handlers.join('\n')] : handlers).map(
(handler) => `export function register(on) { ${handler} }`,
),
);
expect(await runtime.submitPrompt(prompt, signal())).toEqual({
drop: 'secret guard',
byHook: true,
});
expect(logs).toEqual([]);
},
);
it.each([false, true])(
'retains a downstream drop after a catch answers (next already called: %s)',
async (called) => {
const runtime = await createTurns([
`export function register(on) {
on('prompt.submit', async ($, e, next) => {
${called ? 'await next(e);' : ''}
throw new Error('broken');
}).catch(async ($, e, next) => {
if (!next.called) await next(e);
return {text: e.text};
});
}`,
`export function register(on) {
on('prompt.submit', () => ({drop: 'secret guard'}));
}`,
]);
expect(await runtime.submitPrompt(prompt, signal())).toEqual({
drop: 'secret guard',
byHook: true,
});
expect(logs).toEqual([]);
},
);
it.each([false, true])(
'retains the downstream outcome when rejecting a late drop (same plugin: %s)',
async (samePlugin) => {
const handlers = [
`on('prompt.submit', async ($, e, next) => {
await next(e);
return {drop: 'too late'};
});`,
`on('prompt.*', ($, e) =>
e.text === 'drop' ? {drop: 'secret guard'} : {text: 'held'});`,
];
const runtime = await createTurns(
(samePlugin ? [handlers.join('\n')] : handlers).map(
(handler) => `export function register(on) { ${handler} }`,
),
);
expect(
await runtime.submitPrompt({ ...prompt, text: 'drop' }, signal()),
).toEqual({ drop: 'secret guard', byHook: true });
expect(
await runtime.submitPrompt({ ...prompt, text: 'answer' }, signal()),
).toEqual({
drop: 'a hook answered without passing the prompt on',
byHook: false,
});
expect(logs.map((log) => log.text)).toEqual([
expect.stringContaining('a drop after its next(e) was answered'),
expect.stringContaining('a drop after its next(e) was answered'),
]);
},
);
it.each([false, true])(
'retains the latest local next after catching a late drop (first reaches host: %s)',
async (reachesHost) => {
const runtime = await createTurns([
`export function register(on) {
on('prompt.submit', async ($, e, next) => {
try { await next({...e, text: 'first'}); }
catch (error) { $.ui.log(error.message); }
return next({...e, text: 'second'});
});
on('prompt.*', async ($, e, next) => {
const below = await next(e);
return e.text === 'first' ? {drop: 'too late'} : below;
});
on('*', {text: /^(first|second)$/}, ($, e, next) => {
$.ui.log('guard:' + e.text);
${reachesHost ? "if (e.text === 'first') return next(e);" : ''}
return {drop: e.text + ' guard'};
});
}`,
]);
expect(await runtime.submitPrompt(prompt, signal())).toEqual({
drop: 'second guard',
byHook: true,
});
expect(logs.map((log) => log.text)).toEqual([
'guard:first',
'a drop after its next(e) was answered',
'guard:second',
]);
},
);
it.each([false, true])(
'retains a local drop after an earlier host next when rejecting a late drop (catch replays: %s)',
async (caught) => {
const runtime = await createTurns([
`export function register(on) {
on('prompt.submit', async ($, e, next) => {
$.ui.log('first:' + JSON.stringify(await next({...e, text: 'first'})));
$.ui.log('second:' + JSON.stringify(await next({...e, text: 'second'})));
return {drop: 'too late'};
})${
caught
? `.catch(($, e, next) => {
$.ui.log('called:' + next.called);
return next(e);
})`
: ''
};
on('prompt.*', ($, e, next) =>
e.text === 'second' ? {drop: 'second guard'} : next(e));
}`,
]);
expect(await runtime.submitPrompt(prompt, signal())).toEqual({
drop: 'second guard',
byHook: true,
});
expect(logs.map((log) => log.text)).toEqual([
'first:{"text":"first","origin":{"kind":"composer"}}',
'second:{"drop":"second guard"}',
caught
? 'called:true'
: expect.stringContaining('a drop after its next(e) was answered'),
]);
},
);
it('keeps next.called false for a late drop within one plugin', async () => {
const runtime = await createTurns([
`export function register(on) {
on('prompt.submit', async ($, e, next) => {
await next(e);
return {drop: 'too late'};
}).catch(($, e, next) => {
$.ui.log('called:' + next.called);
return {text: e.text};
});
on('prompt.*', () => ({drop: 'secret guard'}));
}`,
]);
expect(await runtime.submitPrompt(prompt, signal())).toEqual({
drop: 'a hook answered without passing the prompt on',
byHook: false,
});
expect(logs.map((log) => log.text)).toEqual(['called:false']);
});
it('takes any answer after next from a hook that only watches', async () => {
const runtime = await createTurns([
`export function register(on) {
on('prompt.submit', async ($, e, next) => {
const entered = await next(e);
if (e.text === 'nothing') return undefined;
if (e.text === 'spread') return {...e, ...entered};
return {text: 'x'.repeat(300000)};
});
}`,
`export function register(on) {
on('prompt.submit', ($, e, next) => next({...e, text: e.text + '!'}));
}`,
]);
const texts = ['nothing', 'spread', 'large'];
const entered = [];
for (const text of texts)
entered.push(await runtime.submitPrompt({ ...prompt, text }, signal()));
expect(entered).toEqual(texts.map((text) => ({ text: text + '!' })));
expect(logs).toEqual([]);
});
it('skips a hook that fails, drops after its next or changes what is pinned', async () => {
const runtime = await createTurns([
`export function register(on) {
on('prompt.submit', async ($, e, next) => {
if (e.text === 'late') { await next(e); return {drop: 'too late'}; }
if (e.text === 'origin') return next({...e, origin: {kind: 'sdk'}});
if (e.text === 'wait') return next({...e, wait: true});
if (e.text === 'context') return next({...e, context: 'one'});
if (e.text === 'answer') return {text: 'x', origin: {kind: 'sdk'}};
throw new Error('broken');
});
}`,
]);
const texts = ['late', 'origin', 'wait', 'context', 'answer', 'other'];
const entered = [];
for (const text of texts)
entered.push(await runtime.submitPrompt({ ...prompt, text }, signal()));
expect(entered).toEqual(texts.map((text) => ({ text })));
expect(logs.map((log) => log.text)).toEqual([
expect.stringContaining('a drop after its next(e) was answered'),
...Array.from({ length: 3 }, () =>
expect.stringContaining('origin, wait and turnId are pinned'),
),
expect.stringContaining('Unsupported prompt.submit result fields'),
expect.stringContaining('broken'),
]);
});
it('lets a .catch drop a prompt its failed guard never passed on', async () => {
const runtime = await createTurns([
`export function register(on) {
on('prompt.submit', () => { throw new Error('broken'); })
.catch(($, e, next) => next.called ? next(e) : {drop: 'no'});
}`,
]);
expect(await runtime.submitPrompt(prompt, signal())).toEqual({
drop: 'no',
byHook: true,
});
});
it('bounds logs and applies a whole session.end timeout', async () => {
const noisy = await create(
fixture(
+40
View File
@@ -41,6 +41,8 @@ import {
type ModReply,
type ModRequest,
type ModSessionFacts,
type ModPromptSubmit,
type ModPromptSubmitted,
type ModTurnComplete,
type ModTurnStart,
type ModWorkerReply,
@@ -85,6 +87,7 @@ export class ModRuntime {
start: false,
complete: [],
};
private prompts: string[] = [];
private renders: string[] = [];
// Asked for during the load, before the host knows which plugins draw.
private early?: Set<string> = new Set();
@@ -198,6 +201,8 @@ export class ModRuntime {
facts?: () => ModSessionFacts,
abortTurn?: (turnId: string) => void,
render?: ModRenderHost,
// Whether this host raises `prompt.submit` for the prompts submitted.
prompts = false,
): Promise<ModRuntime> {
if (
plugins.length > 32 ||
@@ -236,6 +241,7 @@ export class ModRuntime {
surface,
version,
sites: render?.sites ?? [],
prompts,
},
signal,
60_000,
@@ -245,10 +251,12 @@ export class ModRuntime {
value.every((name) => plugins.some((plugin) => plugin.name === name));
const turnComplete = record(result) ? result['turnComplete'] : undefined;
const renders = record(result) ? result['render'] : undefined;
const promptSubmit = record(result) ? result['promptSubmit'] : undefined;
if (
!record(result) ||
typeof result['tools'] !== 'boolean' ||
typeof result['turnStart'] !== 'boolean' ||
!names(promptSubmit) ||
!names(turnComplete) ||
!names(renders)
)
@@ -275,6 +283,7 @@ export class ModRuntime {
start: result['turnStart'],
complete: turnComplete,
};
runtime.prompts = promptSubmit;
runtime.renders = renders;
const early = runtime.early!;
runtime.early = undefined;
@@ -305,6 +314,11 @@ export class ModRuntime {
return this.turns;
}
/** The plugins whose hooks hear `prompt.submit`. */
get promptHooks(): readonly string[] {
return this.prompts;
}
/** The plugins whose `ui.render` hooks may draw where this host draws. */
get renderHooks(): readonly string[] {
return this.renders;
@@ -554,6 +568,32 @@ export class ModRuntime {
);
}
/**
* Raises `prompt.submit`: the prompt that arrived beneath the hooks, or why
* it did not enter.
*/
async submitPrompt(
input: ModPromptSubmit,
signal: AbortSignal,
): Promise<ModPromptSubmitted> {
const result = await this.request({ type: 'prompt-submit', input }, signal);
if (
record(result) &&
(typeof result['drop'] === 'string'
? Object.keys(result).length === 2 &&
typeof result['byHook'] === 'boolean'
: typeof result['text'] === 'string' &&
Object.keys(result).every(
(key) => key === 'text' || key === 'context',
) &&
(result['context'] === undefined ||
(Array.isArray(result['context']) &&
result['context'].every((item) => typeof item === 'string'))))
)
return result as ModPromptSubmitted;
throw new Error('Invalid Mod prompt.submit result.');
}
async completeTurn(
input: ModTurnComplete,
signal: AbortSignal,
+21
View File
@@ -105,6 +105,24 @@ export interface ModTurnStart {
isTextTruncated?: true;
}
/** Where a submitted prompt came from, in Claude Code's `PromptOrigin`. */
export type ModPromptOrigin = { kind: 'composer' } | { kind: 'sdk' };
export interface ModPromptSubmit {
text: string;
wait: boolean;
origin: ModPromptOrigin;
}
/**
* How a submitted prompt fared: what arrived beneath the hooks, or why it
* did not enter. `byHook` tells a hook's own drop from one that answered
* without passing the prompt on.
*/
export type ModPromptSubmitted =
| { text: string; context?: string[] }
| { drop: string; byHook: boolean };
export interface ModTurnComplete {
answer: string;
durationMs: number;
@@ -125,6 +143,8 @@ export type ModRequest =
version: string;
/** The render sites this host draws; `ui.render` runs only where one is offered. */
sites?: readonly ModRenderSite[];
/** Whether the host raises `prompt.submit`; its hooks are skipped otherwise. */
prompts?: boolean;
}
| {
type: 'command';
@@ -144,6 +164,7 @@ export type ModRequest =
}
| { type: 'end'; sessionId: string; reason: 'clear' | 'resume' | 'other' }
| { type: 'turn-start'; input: ModTurnStart }
| { type: 'prompt-submit'; input: ModPromptSubmit }
| { type: 'turn-complete'; input: ModTurnComplete }
| { type: 'render'; input: ModRenderInput }
| { type: 'turn-abort-result'; id: number; error?: string };
+1
View File
@@ -109,6 +109,7 @@ it('runs a relocated bundled worker with TypeScript and relative imports', async
tools: false,
commands: [{ name: 'hello', description: 'Bundled', plugin: 'fixture' }],
turnStart: false,
promptSubmit: [],
turnComplete: [],
render: [],
});