mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(mods): run the prompts a Mod submits in the Ink terminal (#16373)
* feat(mods): run the prompts a Mod submits in the Ink terminal * test(mods): run a Mod's prompt in a real Ink terminal * fix(mods): stop a Mod's prompts when the person stops a turn in Ink - Esc or declining every tool a turn asks to run drops the Mods' queued prompts and refuses new ones until the person sends a prompt again. The stopped turn's turn.complete hooks run after the stop, and a call a hook does not await reaches the host after the hook returns, so dropping the queue alone let such a hook start the next turn. $.turn.abort still keeps the queue. - A Monitor update waiting out its minimum interval no longer holds a Mod's prompt, which a Monitor printing within every interval held for good. - A prompt put back after losing the idle moment runs once the winning submission settles, since that one may start no turn. - The dual-output sidecar gets the Mod's text and a mod_prompt system message, as a stream-json client does, instead of the frame. - The guide's capability table lists Ink for a Mod's turn events. * fix(mods): drain Mod prompts after any settle and strip a stopped one A Mod prompt queued while a submission that starts no turn held the session (a cron's Mod command, a notification dropped before sending) stayed queued with the session idle: the drain re-ran on settle only for a prompt it had put back. Re-run it whenever a Mod prompt waits. When the person stops a Mod prompt's turn before it answers, the framed prompt stayed at the end of the model history, so the next prompt reached the model behind it. The cancel info now says the turn ran a Mod's prompt, and the handler strips it as it does a Goal continuation's. * fix(mods): run a waiting Mod prompt once a Goal lets go of the turns A Mod prompt queued while a Goal owned the session's turns waited for the next submission to settle or another prompt to be queued. The Goal often releases its turns after the session is already idle (its verifier accepts, its last turn is released), so the prompt could sit until some unrelated event. The drain now also re-runs on the Goal runtime's broadcasts while a Mod prompt waits. The test no longer queues a second prompt to wake the drain; it changes the Goal snapshot and broadcasts, and fails without the fix.
This commit is contained in:
1 parent
4e75789d8e
commit
08f1843c22
18 files changed
+1500
-143
No files matched your search
@@ -108,12 +108,13 @@ The OpenTUI terminal raises the event at two sites. The live turn (`useOpenTuiLi
|
||||
- **The call.** It is an API event, like the other `$` methods: the worker raises `prompt.submit` with the input `{ text, wait: false, origin: { kind: 'plugin', name, asUser? } }`, `name` being the caller's. The hooks it runs through are every hook but the caller and those the caller runs beneath, which the existing frames of a `$` call leave out; the caller's plugin's other hooks hear it. A call of another shape rejects, and `attachments` reject with a line of their own. A call whose text is empty or only whitespace, or longer than half a Mod payload (128 KiB as JSON), resolves to a drop without raising the event; an input too large for any Mod call rejects.
|
||||
- **The bottom of the chain.** Once every hook has answered, what last arrived beneath them goes to the host as `{ plugin, text, context?, asUser? }`, so a hook that calls `next` twice queues one prompt. The host queues it or says why not, and the call resolves to `{ text, context?, origin }` or to `{ drop }`. A hook's drop, a hook that answers without `next`, or a hook that passes on an empty text or one past half a payload queues nothing. Held to half a payload, what the call resolves to always fits a Mod payload, so a queued prompt is never reported as failed.
|
||||
- **When it resolves.** At the end of that chain, inside the dispatch of the hook that called. The worker runs one dispatch at a time and waits for a dispatch's calls before it ends, so a call that waited for its turn to start would hold up the very dispatch that keeps the session from being idle. The chain's hooks do not run beneath the calling hook, so a call the hook does not await still queues after the hook has returned. For the same reason the call does not pause the caller's budget as other calls do: a hook that went on running while the call was pending would otherwise run unbudgeted. A hook that awaits it is charged for the time the other Mods' `prompt.submit` hooks take.
|
||||
- **Where hosts take it.** A host that runs such prompts passes a queue at load, and the load request says so. `$.session.version()` lists the method only then; elsewhere a call resolves to a drop without raising the event. This step passes one in headless runs only.
|
||||
- **Where hosts take it.** A host that runs such prompts passes a queue at load, and the load request says so. `$.session.version()` lists the method only then; elsewhere a call resolves to a drop without raising the event. Step 4 passes one in headless runs. In step 5 the Ink terminal passes one too: `InteractiveModSession.initialize` takes `{ runsPrompts: true }` beside `{ prompts: true }`, in the call that loads the Mods, and the session tells the terminal each time a prompt is queued.
|
||||
- **The turn.** When the run is idle, the prompt runs as a turn of its own after the queue's earlier items: a `UserQuery` with the Mod's text as the submitted prompt and the context the hooks attached, so UserPromptSubmit hears it and it counts toward the session turn limit. `@` references are not read. The turn raises `turn.start` and `turn.complete`. A slash command that answers with a message, as a Mod command does, starts no turn of its own: when it leaves a Mod's prompt queued, the run shows its reply and goes on to run the prompt, and ends with one result. Text output, which prints only the run's last answer, writes the reply as it comes. A worktree notice the run would have given the command goes with the first request the run sends.
|
||||
- **The Ink terminal.** Once the session is idle, the terminal takes the next prompt and submits it as a `UserQuery` with the framed text, the Mod's text as the submitted prompt and the hooks' context, as a headless run does. It waits for the prompts the person queued, for the background notifications that can run now, and while a Goal owns the session's turns, since the prompt is neither the person's words nor a turn the Goal asked for. A Monitor update waiting out the minimum interval between its turns does not hold it: a Monitor that prints within every interval would hold it for as long as it runs. The prompt does not pass the Mods' `prompt.submit` hooks again, is not written to the input history, reads no `@` reference and runs no command, slash form or shell mode alike. Its row is a notification line, `Mod <name>: <text>`, sanitized for the terminal, with `asUser` too, since the person did not type it; it is not a turn to rewind to, and a cancel does not hand it to the input box. A submission that wins the same idle moment leaves the prompt queued, uncounted, and the drain runs again once that submission settles, since it may start no turn. A dual-output sidecar receives the Mod's text as a user message and then a `mod_prompt` system message, as a stream-json client does. A resumed session shows the record's display text in the same row.
|
||||
- **What the model reads.** Unless the Mod passed `asUser`, the text goes inside a `<qwen:mod-prompt mod="<name>">` block, followed by a line saying the Mod sent it and it waited until the session was idle, that the person did not type it, and that it is a request on the person's behalf within what they asked for and allowed. Every `<` and `>` inside the text is escaped, as for the hooks' context, so no spelling of a closing marker can end the block. With `asUser` the model reads the text as it is.
|
||||
- **The record.** The transcript keeps the prompt as a user message with the provenance `mod_prompt`, the Mod's name and its text as the display text. Goal evidence and the session-agent view read only the person's words, so neither takes it for theirs. It keeps its turn's place among the texts a session title is drawn from but offers the title nothing, and it does not by itself make the title read only those texts, so a session whose prompts recorded no display text still draws its title from the conversation, with the Mod's message, as the model read it, left out; one sent with `asUser` is not left out, since its bare text reads as the person's and would match a prompt the person typed alike. It does not count as a prompt in the commit attribution. Memory recall searches for the Mod's text, not the frame. A stream-json client, which did not send the prompt, receives its text as a user message and then a `mod_prompt` system message naming the Mod, before the turn's output.
|
||||
- **The turn limit.** The Mods' prompts start at most 8 turns, queued ones counted, before the person sends a prompt. Past that, a call resolves to a drop and the line `$.prompt.submit refused: …` appears where transcript logs do. In a headless run the prompt the run was given is the person's, and in a stream-json session each message the client sends is, a slash command too. A switch to another session, by `/clear` or `/resume`, starts the new one with nothing queued and the count at 0, and a prompt submitted while the old one ends is refused as `the session is ending`.
|
||||
- **What does not run.** A prompt queued for a session that `/clear` or `/resume` replaced, one queued once the session is closing, and one still queued when a run stops early on an interrupt, a structured result, a detected loop or a slash command that fails.
|
||||
- **The turn limit.** The Mods' prompts start at most 8 turns, queued ones counted, before the person sends a prompt. Past that, a call resolves to a drop and the line `$.prompt.submit refused: …` appears where transcript logs do. In a headless run the prompt the run was given is the person's, and in a stream-json session each message the client sends is, a slash command too. In the Ink terminal each prompt or slash command the person sends is. A switch to another session, by `/clear` or `/resume`, starts the new one with nothing queued and the count at 0, and a prompt submitted while the old one ends is refused as `the session is ending`.
|
||||
- **What does not run.** A prompt queued for a session that `/clear` or `/resume` replaced, one queued once the session is closing, and one still queued when a run stops early on an interrupt, a structured result, a detected loop or a slash command that fails. In the Ink terminal, one still queued when the person stops a turn, with Esc or by declining every tool it asks to run. Until the person sends a prompt again, a call then resolves to a drop, `the person stopped a turn and has not sent a prompt since`, so a `turn.complete` hook of the stopped turn, which runs after the stop, starts no turn. A turn a Mod ends with `$.turn.abort` keeps them, so a Mod can stop a turn and redirect it.
|
||||
|
||||
## Design decisions
|
||||
|
||||
@@ -130,6 +131,8 @@ The OpenTUI terminal raises the event at two sites. The live turn (`useOpenTuiLi
|
||||
- **The model is told who sent a Mod's prompt.** A prompt the person did not type should not read as theirs: the model weighs a person's words above a tool's, and a Mod can run code the person never looked at. The block names the Mod, and the line after it gives the prompt the standing of a request made for the person. `asUser` is the Mod's choice to send the text as the person's words, as the API allows.
|
||||
- **A Mod's prompt has a provenance of its own.** The transcript marks the person's prompts as theirs, and a Goal's verifier reads those as what the person asked for. A prompt a Mod wrote under the person's provenance would let a Mod speak for them there; a provenance of its own keeps every reader that looks for the person's words from finding it. The session title and the commit attribution's prompt count read the person's prompts in other ways, so each skips a Mod's prompt on its own.
|
||||
- **A limit on the Mods' turns.** A hook that submits at every `turn.complete` would keep a session busy forever, and in a headless run would never let it exit. Eight turns covers a Mod that follows up a few times; the person's next prompt resets the count, so an interactive session keeps going while the person does.
|
||||
- **Stopping a turn stops the Mods' prompts until the person speaks again.** A person who presses Esc, or declines the tools a turn asks for, wants the session's work to stop; a Mod's prompt starting as soon as the session is idle would undo that, and a Mod that submits at every turn could only be stopped by its limit. Dropping the queue is not enough, since the stopped turn's `turn.complete` hooks run after the stop. Refusing only until those hooks settle is not enough either: the prompt a hook submits without awaiting reaches the host after the other Mods' `prompt.submit` hooks, which can be after the hook has returned. The person's next prompt is the point at which they ask for work again, and it already resets the turn count. The Mod's own `$.turn.abort` keeps the queue, since ending a turn to submit another is what a Mod may mean by it.
|
||||
- **The Ink terminal shows a Mod's prompt as a notification line.** A user row would read as something the person typed, and would offer the prompt to rewind and to the input box. The line names the Mod, the same way live and on resume.
|
||||
- **UserPromptSubmit hears a Mod's prompt.** It is a prompt entering the session, and a settings hook that guards prompts should not be bypassed because a Mod wrote one.
|
||||
- **The hooks hear the typed text, not the model-only notices.** The person wrote the typed text; the notices belong to the terminal and change from session to session. A rewrite replaces only what the person typed, so a guard cannot drop the terminal's notices by accident.
|
||||
|
||||
@@ -162,7 +165,7 @@ These go into the user guide:
|
||||
- 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.
|
||||
- `$.prompt.submit`:
|
||||
- real QuickJS worker tests: the event through every hook but the caller's and those it runs beneath, the origin, what is queued and what the call resolves to; a hook's drop, a hook that answers without `next`, an empty text from the caller or a hook, and a host's refusal queuing nothing; a call the hook does not await, under a slow hook, queued once as it last arrived though a hook called `next` twice; a host that runs no such prompts; calls of the wrong shape;
|
||||
- Mod session tests: the queue in order, the turn limit counting queued and started prompts, its line, the person's prompt resetting it, a replaced or closing session, a run discarding what has not started, and no queue in a terminal;
|
||||
- Mod session tests: a stop dropping the queue, refusing calls and puts-back until the person's prompt or a new session; the queue in order, the turn limit counting queued and started prompts, its line, the person's prompt resetting it, a replaced or closing session, a run discarding what has not started, a queue for a terminal only once it runs the prompts and its notice of each, and a prompt put back uncounted, once, and only into its own session;
|
||||
- the record: provenance `mod_prompt` and the Mod's name; the title projection keeping its place with nothing in it, live and on resume, and a title drawn from the conversation leaving the Mod's message out; no attribution count;
|
||||
- the frame: the Mod named, a closing marker in any spelling escaped, `asUser` read as it is;
|
||||
- a prompt past half a payload, from the call or a hook, dropped before it is queued, and an input too large for the bridge rejecting;
|
||||
@@ -171,10 +174,13 @@ These go into the user guide:
|
||||
- a Mod command's reply in text output, and the worktree notice reaching the prompt it hands on;
|
||||
- a stream-json run that stops early not carrying a Mod's prompt into the next one, and memory recall reading the Mod's text;
|
||||
- headless tests on both loops: a prompt queued while the person's turn ended running after it, framed, with its context and submitted prompt, an `asUser` prompt read bare, `turn.start` for each turn, and a Mod command's prompt running after its reply with no turn for the command;
|
||||
- Ink stream tests: a queued prompt running once idle as a `UserQuery` with the frame, its submitted prompt, the Mod's name, its recall query and context, `turn.start` hearing its text, under a notification row and with no user row, no `prompt.submit` hooks, no input history and no `@` read; an `asUser` prompt sent bare with no command run from it; queued prompts one turn after another; waiting for the person's queued prompts and for a Goal that owns the turns; the person's prompt, not a Mod's, resetting the count; Esc and declining every tool stopping the queue, and `$.turn.abort` keeping it; a Monitor update in its interval not holding a prompt, one that can run now going first; a prompt put back when its turn cannot start, and run once the winning submission settles; the dual-output sidecar receiving the Mod's text and name; the owner's opt-in and its queue's notice;
|
||||
- resume: a `mod_prompt` record restored as the Mod's notification line, `asUser` too;
|
||||
- the built CLI in a real Ink terminal against a fixture server (`integration-tests/interactive/mods-prompt-submit.test.ts`): a `turn.complete` hook's prompt running after the person's turn under the Mod's line, framed in the second request and recorded as the Mod's, and a hook that submits at every turn stopped at the limit;
|
||||
- the built CLI against a fixture server (`integration-tests/cli/mods-prompt-submit.test.ts`): a `turn.complete` hook that submits, the second request holding the frame and the hooks' context, the transcript record as the Mod's, an `asUser` prompt read bare, and a hook that submits at every turn stopped at the limit.
|
||||
|
||||
## 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.
|
||||
- A resumed session shows a Mod's prompt as the text it submitted; how the terminals show such a prompt and its transcript record comes with the Ink step.
|
||||
- A Mod's prompt waits while a Goal owns the session's turns, and runs once the Goal stops, or not at all if the person stops a turn first.
|
||||
@@ -108,12 +108,13 @@ OpenTUI 终端在两处触发该事件。实时回合(`useOpenTuiLiveTurn`)
|
||||
- **调用。** 它和其他 `$` 方法一样是 API 事件:worker 以输入 `{ text, wait: false, origin: { kind: 'plugin', name, asUser? } }` 触发 `prompt.submit`,`name` 是调用者的名字。它经过的 hook 是除调用者自身以及调用者所处的外层 hook 之外的全部 hook,这些由 `$` 调用已有的 frames 排除;调用者所在插件的其他 hook 会听到它。形状不对的调用会被拒绝,`attachments` 有单独的拒绝提示。文本为空或只有空白、或超过 Mod 负载一半(JSON 128 KiB)的调用直接返回 drop,不触发事件;对任何 Mod 调用都过大的输入会被拒绝(reject)。
|
||||
- **链底。** 所有 hook 答复之后,最后到达链底的内容以 `{ plugin, text, context?, asUser? }` 交给宿主,因此调用两次 `next` 的 hook 只排队一条提示。宿主将其排队,或说明不排队的原因,调用随之返回 `{ text, context?, origin }` 或 `{ drop }`。hook 的 drop、不调用 `next` 就答复的 hook,或传下空文本、超过负载一半的文本的 hook,都不会排队任何内容。限制在负载一半以内,调用的返回值总能放进 Mod 负载,所以已排队的提示不会被报告为失败。
|
||||
- **何时返回。** 在这条链结束时返回,仍处于发起调用的 hook 所在的分发之内。worker 一次只运行一个分发,并在分发结束前等待其中的调用;如果调用要等到回合开始才返回,它就会拖住正让会话无法空闲的那个分发。这条链上的 hook 不在发起调用的 hook 之下运行,所以 hook 没有 await 的调用在 hook 返回之后仍会排队。出于同样的原因,这个调用不像其他调用那样暂停调用者的预算:否则调用未完成期间仍在运行的 hook 就不受预算约束。await 它的 hook 会为其他 Mod 的 `prompt.submit` hook 所花的时间计费。
|
||||
- **宿主从哪里接手。** 能运行这类提示的宿主在加载时传入一个队列,加载请求会注明这一点。只有这时 `$.session.version()` 才列出该方法;其他宿主上调用直接返回 drop,不触发事件。这一步只在 headless 中传入队列。
|
||||
- **宿主从哪里接手。** 能运行这类提示的宿主在加载时传入一个队列,加载请求会注明这一点。只有这时 `$.session.version()` 才列出该方法;其他宿主上调用直接返回 drop,不触发事件。第 4 步在 headless 中传入队列。第 5 步 Ink 终端也传入:`InteractiveModSession.initialize` 在加载 Mod 的那次调用里,与 `{ prompts: true }` 一起接收 `{ runsPrompts: true }`;每有一条提示排队,会话都会通知终端。
|
||||
- **回合。** 运行空闲时,该提示排在队列中先到的项目之后,作为独立的回合运行:类型为 `UserQuery`,以 Mod 的文本作为提交的提示,并带上 hook 附加的上下文,因此 UserPromptSubmit 会听到它,它也计入会话回合上限。文本中的 `@` 引用不会被读取。该回合会触发 `turn.start` 和 `turn.complete`。以消息作答的斜杠命令(Mod 命令就是这样)自身不开始回合:如果它留下了排队的 Mod 提示,运行会先显示它的回复,再接着运行该提示,最后只给出一个结果。文本输出只打印运行的最后一个回答,因此回复在出现时直接写出。原本要交给该命令的 worktree 提示,随运行发出的第一个请求一起发送。
|
||||
- **Ink 终端。** 会话空闲后,终端取出下一条提示,像 headless 一样作为 `UserQuery` 提交:发送框文,以 Mod 的文本作为提交的提示,并带上 hook 附加的上下文。它会等用户排队的提示和此刻能运行的后台通知先运行,并在 Goal 掌管会话回合期间等待,因为这条提示既不是用户的话,也不是 Goal 要求的回合。正在等待 Monitor 两次回合之间最短间隔的更新不会拦住它:每个间隔内都有输出的 Monitor 否则会在运行期间一直拦住它。这条提示不会再经过 Mod 的 `prompt.submit` hook,不写入输入历史,不读取 `@` 引用,也不运行任何命令,斜杠形式和 shell 模式都一样。它显示为一条通知行 `Mod <name>: <text>`,已针对终端做过清理,`asUser` 时也一样,因为用户并没有输入它;它不是可回退到的回合,取消时也不会被放回输入框。如果同一个空闲时刻被另一条提交抢先,这条提示留在队列里、不计数,等那条提交结束后再次尝试,因为那条提交不一定会开始回合。dual output 旁路像 stream-json 客户端一样,先收到 Mod 的文本(user 消息),再收到一条 `mod_prompt` 系统消息。恢复的会话用记录里的显示文本显示同样的行。
|
||||
- **模型读到什么。** 除非 Mod 传了 `asUser`,文本会放进 `<qwen:mod-prompt mod="<name>">` 块中,块后有一行说明:这条消息是该 Mod 发来的、一直等到会话空闲,用户没有输入它,应把它当作代表用户、在用户要求和允许范围内的请求。文本里的每个 `<` 和 `>` 都会被转义(与 hook 附加的上下文相同),因此闭合标记无论怎么写都无法提前结束这个块。带 `asUser` 时模型读到的是原文。
|
||||
- **记录。** transcript 把这条提示存为用户消息,来源为 `mod_prompt`,带上 Mod 的名字,并以它的文本作为显示文本。Goal 证据和会话代理视图只读取用户本人的话,因此都不会把它当成用户说的。在生成会话标题所用的文本里,它保留自己回合的位置,但不提供任何内容,也不会单凭自己让标题只读这些文本,所以提示都没有记录显示文本的会话仍从对话生成标题,并剔除模型读到的那条 Mod 消息;用 `asUser` 发送的不剔除,因为它的原文读起来就是用户的话,也会与用户输入的同样文字相匹配。它不计入提交署名的提示数。记忆召回检索的是 Mod 的文本,而不是框文。stream-json 客户端没有发送这条提示,因此会在该回合的输出之前收到它的文本(user 消息),随后是一条注明 Mod 的 `mod_prompt` 系统消息。
|
||||
- **回合上限。** 在用户再次发送提示之前,Mod 的提示最多启动 8 个回合,排队中的也计算在内。超过之后,调用返回 drop,并在 transcript 日志出现的位置显示 `$.prompt.submit refused: …`。headless 运行时,运行收到的提示算作用户的;stream-json 会话中,客户端发送的每条消息都算,斜杠命令也算。通过 `/clear` 或 `/resume` 切换到另一个会话时,新会话没有排队的提示,计数从 0 开始;旧会话结束期间提交的提示会以 `the session is ending` 被拒绝。
|
||||
- **不会运行的情况。** 为已被 `/clear` 或 `/resume` 替换的会话排队的提示、会话关闭过程中排队的提示,以及运行因中断、结构化结果、检测到循环或斜杠命令失败而提前结束时仍在队列里的提示。
|
||||
- **回合上限。** 在用户再次发送提示之前,Mod 的提示最多启动 8 个回合,排队中的也计算在内。超过之后,调用返回 drop,并在 transcript 日志出现的位置显示 `$.prompt.submit refused: …`。headless 运行时,运行收到的提示算作用户的;stream-json 会话中,客户端发送的每条消息都算,斜杠命令也算。Ink 终端中,用户发送的每条提示或斜杠命令都算。通过 `/clear` 或 `/resume` 切换到另一个会话时,新会话没有排队的提示,计数从 0 开始;旧会话结束期间提交的提示会以 `the session is ending` 被拒绝。
|
||||
- **不会运行的情况。** 为已被 `/clear` 或 `/resume` 替换的会话排队的提示、会话关闭过程中排队的提示,以及运行因中断、结构化结果、检测到循环或斜杠命令失败而提前结束时仍在队列里的提示。在 Ink 终端中,还有用户停下回合(按 Esc,或拒绝回合要运行的全部工具)时仍在队列里的提示。此后直到用户再次发送提示,调用都返回 drop(`the person stopped a turn and has not sent a prompt since`),因此被停下回合的 `turn.complete` hook(在停下之后才运行)不会开始新回合。Mod 用 `$.turn.abort` 结束的回合会保留它们,这样 Mod 可以停下一个回合并改换方向。
|
||||
|
||||
## 设计决策
|
||||
|
||||
@@ -130,6 +131,8 @@ OpenTUI 终端在两处触发该事件。实时回合(`useOpenTuiLiveTurn`)
|
||||
- **模型会被告知 Mod 的提示是谁发的。** 不是用户输入的提示不应读起来像用户说的:模型对用户的话比对工具的话更看重,而 Mod 运行的代码用户可能从未看过。这个块写明 Mod 的名字,块后的说明让这条提示具有代表用户提出的请求的地位。`asUser` 是 Mod 按 API 的允许、把文本作为用户的话发出的选择。
|
||||
- **Mod 的提示有自己的来源。** transcript 把用户的提示标为用户本人的,Goal 的验证器把这些当作用户提出的要求来读。如果 Mod 写的提示也挂在用户的来源下,Mod 就能在那里替用户说话;单独的来源能让所有寻找用户本人话语的读取方都找不到它。会话标题和提交署名的提示数用别的方式读取用户的提示,所以各自单独跳过 Mod 的提示。
|
||||
- **限制 Mod 启动的回合数。** 每次 `turn.complete` 都提交提示的 hook 会让会话永远忙碌,在 headless 中则永远无法退出。8 个回合足以覆盖 Mod 跟进几次的情况;用户的下一条提示会重新计数,所以交互会话在用户继续使用时可以一直进行。
|
||||
- **停下回合后,Mod 的提示一直停到用户再次发话。** 按 Esc 或拒绝回合所要工具的用户,是想让会话停下手头的工作;如果会话一空闲 Mod 的提示就开始运行,等于抵消了这次操作,而每个回合都提交提示的 Mod 就只能靠上限才停得下来。只清空队列不够,因为被停下回合的 `turn.complete` hook 在停下之后才运行。只在这些 hook 结束前拒绝也不够:hook 没有 await 的提交要先经过其他 Mod 的 `prompt.submit` hook 才到达宿主,可能晚于 hook 返回。用户的下一条提示才是他们重新要求工作的时刻,它本来也会重置回合计数。Mod 自己的 `$.turn.abort` 会保留队列,因为结束一个回合再提交另一个,正是 Mod 这样做可能的用意。
|
||||
- **Ink 终端把 Mod 的提示显示为通知行。** 用户消息行读起来像用户自己输入的,还会让这条提示可被回退、可被放回输入框。通知行写明 Mod 的名字,实时显示和恢复会话时都一样。
|
||||
- **UserPromptSubmit 会听到 Mod 的提示。** 它是进入会话的提示,守护提示的设置 hook 不应因为提示是 Mod 写的就被绕过。
|
||||
- **hook 听到用户输入的文本,而不是只给模型的提醒。** 用户输入的文本是用户写的;那些提醒属于终端,随会话变化。改写只替换用户输入的部分,守卫不会误删终端的提醒。
|
||||
|
||||
@@ -162,7 +165,7 @@ OpenTUI 终端在两处触发该事件。实时回合(`useOpenTuiLiveTurn`)
|
||||
- 构建后的 CLI 对接 fixture 服务器,在 headless JSON、文本和 stream-json 下验证:模型请求和 transcript 都是改写后的文本;上下文在隐藏块里;drop;守卫失败时由 `.catch` 丢弃。
|
||||
- `$.prompt.submit`:
|
||||
- 真实 QuickJS worker 测试:事件经过除调用者及其外层 hook 之外的全部 hook,origin,排队的内容和调用的返回值;hook 的 drop、不调用 `next` 就答复的 hook、调用者或 hook 给出的空文本以及宿主的拒绝都不排队;hook 没有 await 的调用在慢 hook 之下仍会排队,且 hook 调用两次 `next` 时只按最后到达的内容排队一次;不运行这类提示的宿主;形状不对的调用;
|
||||
- Mod session 测试:按顺序排队,回合上限同时计算排队中和已启动的提示,上限的提示行,用户的提示重新计数,被替换或正在关闭的会话,运行丢弃尚未开始的提示,以及终端没有队列;
|
||||
- Mod session 测试:停下会清空队列,并在用户的提示或新会话之前拒绝调用和放回;按顺序排队,回合上限同时计算排队中和已启动的提示,上限的提示行,用户的提示重新计数,被替换或正在关闭的会话,运行丢弃尚未开始的提示,终端只有声明运行这类提示后才有队列并收到每次排队的通知,以及被放回的提示不计数、只放回一次、且只放回它自己的会话;
|
||||
- 记录:来源为 `mod_prompt`,带 Mod 的名字;标题投影保留它的位置但内容为空,实时和恢复会话时都如此,从对话生成的标题也剔除这条 Mod 消息;不计入署名提示数;
|
||||
- 框文:写明 Mod 名字,任何写法的闭合标记都被转义,`asUser` 时原样读取;
|
||||
- 超过负载一半的提示(来自调用或 hook)在排队之前被丢弃,对 bridge 过大的输入被拒绝;
|
||||
@@ -171,10 +174,13 @@ OpenTUI 终端在两处触发该事件。实时回合(`useOpenTuiLiveTurn`)
|
||||
- 文本输出下 Mod 命令的回复,以及 worktree 提示到达它交出的提示;
|
||||
- 提前结束的 stream-json 运行不会把 Mod 的提示带进下一次运行,记忆召回读取 Mod 的文本;
|
||||
- 两条循环下的 headless 测试:用户的回合结束时排队的提示在其后运行,带框文、上下文和提交的提示,`asUser` 的提示原样读取,每个回合都有 `turn.start`,Mod 命令的提示在其回复之后运行、命令本身不占回合;
|
||||
- Ink 流测试:排队的提示在空闲后作为 `UserQuery` 运行,带框文、提交的提示、Mod 名字、召回查询和上下文,`turn.start` 听到它的文本,显示为通知行而没有用户行,不经过 `prompt.submit` hook,不写入输入历史,不读取 `@`;`asUser` 的提示原样发送,不运行其中的命令;排队的提示一个回合接一个回合运行;等待用户排队的提示和掌管回合的 Goal;用户的提示而不是 Mod 的提示重新计数;Esc 和拒绝全部工具会停下队列,`$.turn.abort` 保留队列;处于间隔内的 Monitor 更新不拦住提示,此刻能运行的通知先运行;回合无法开始时提示被放回,并在抢先的提交结束后运行;dual output 旁路收到 Mod 的文本和名字;所有者的声明及队列的通知;
|
||||
- 恢复:`mod_prompt` 记录恢复为 Mod 的通知行,`asUser` 也一样;
|
||||
- 构建后的 CLI 在真实 Ink 终端中对接 fixture 服务器(`integration-tests/interactive/mods-prompt-submit.test.ts`):`turn.complete` hook 提交的提示在用户的回合之后运行,显示在注明 Mod 的行下,第二个请求带框文,transcript 把它记为 Mod 的提示;每回合都提交的 hook 在上限处停止;
|
||||
- 构建后的 CLI 对接 fixture 服务器(`integration-tests/cli/mods-prompt-submit.test.ts`):`turn.complete` hook 提交提示,第二个请求带框文和 hook 附加的上下文,transcript 把它记为 Mod 的提示,`asUser` 的提示原样读取,每回合都提交的 hook 在上限处停止。
|
||||
|
||||
## 风险与待定问题
|
||||
|
||||
- 慢 hook 会让提示延迟,最多为它的预算,每个 hook 10 秒。
|
||||
- 目前遮蔽斜杠命令的参数要靠 `command.run`;带 `command` 的 `prompt.submit` 留到以后。
|
||||
- 恢复的会话把 Mod 的提示显示为它提交的文本;终端如何显示这类提示及其 transcript 记录,留到 Ink 那一步。
|
||||
- Goal 掌管会话回合期间,Mod 的提示会一直等待,Goal 停止后才运行;如果用户先停下了某个回合,它就不会运行。
|
||||
@@ -136,8 +136,8 @@ this recovery exception.
|
||||
| Middleware | Patterns and recursive matchers; explicit, repeatable `next(e)`; `.catch` and per-plugin failure recovery; frozen input and host identity |
|
||||
| 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 and, in headless runs, those a Mod submits, described in [Turn events](#turn-events) |
|
||||
| Prompts | `prompt.submit` in headless runs and the Ink and OpenTUI terminals, and `$.prompt.submit` in headless runs, described in [Submitted prompts](#submitted-prompts) |
|
||||
| Turns | `turn.start`, `turn.complete` and `$.turn.abort` for the prompts a person submits and, in headless and Ink sessions, those a Mod submits, described in [Turn events](#turn-events) |
|
||||
| Prompts | `prompt.submit` in headless runs and the Ink and OpenTUI terminals, and `$.prompt.submit` in headless runs and the Ink terminal, 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) |
|
||||
@@ -466,7 +466,7 @@ supports:
|
||||
}
|
||||
```
|
||||
|
||||
This is a headless run. In the Ink and OpenTUI terminals, `events` also lists `ui.render`, and `methods` does not list `prompt.submit` yet.
|
||||
This is a headless run. In the Ink and OpenTUI terminals, `events` also lists `ui.render`, and in OpenTUI `methods` does not list `prompt.submit` yet.
|
||||
`claudeCode` is the Claude Code release whose event names are recognized,
|
||||
`events` lists the events hooks can receive and `methods` the `$` methods a Mod can
|
||||
call. A Mod can use it to choose a fallback, such as a command reply where a
|
||||
@@ -729,9 +729,9 @@ where transcript logs do.
|
||||
|
||||
### A Mod's own prompt
|
||||
|
||||
In headless runs, `$.prompt.submit({ text })` submits a prompt of the Mod's own.
|
||||
It starts a turn once the run is idle, after the turn that is running, and
|
||||
never joins a running turn:
|
||||
In headless runs and the Ink terminal, `$.prompt.submit({ text })` submits a
|
||||
prompt of the Mod's own. It starts a turn once the session is idle, after the
|
||||
turn that is running, and never joins a running turn:
|
||||
|
||||
```javascript
|
||||
export function register(on) {
|
||||
@@ -766,22 +766,35 @@ export function register(on) {
|
||||
`turn.complete` like any other. It counts toward the session turn limit.
|
||||
- `@` references in the text are not read.
|
||||
- A Mod command that submits a prompt hands work on to the model: its reply is
|
||||
shown, and the prompt's turn follows in the same run. The command itself
|
||||
starts no turn.
|
||||
shown, and the prompt's turn follows, in a headless run in the same run. The
|
||||
command itself starts no turn.
|
||||
- In the Ink terminal the prompt's turn starts after the prompts the person
|
||||
queued and the background notifications, and waits while a Goal is running.
|
||||
A Monitor update that waits out the minimum interval between its turns does
|
||||
not hold it.
|
||||
The transcript shows it as the line `Mod <name>: <text>`, live and when the
|
||||
session is resumed, with `asUser` too, and it does not enter the input
|
||||
history.
|
||||
- The Mods' prompts start at most 8 turns before the person sends a prompt
|
||||
again. Past that a call resolves to a drop, and the line
|
||||
`$.prompt.submit refused: …` appears where transcript logs do. In a headless
|
||||
run, the prompt the run was given is the person's, and in a stream-json
|
||||
session each message the client sends is, a slash command too.
|
||||
session each message the client sends is, a slash command too. In the Ink
|
||||
terminal, each prompt or slash command the person sends is.
|
||||
- `/clear` and `/resume` start the new session with no prompts queued and the
|
||||
count at 0. A prompt submitted while the old session ends, from a
|
||||
`session.end` hook for instance, resolves to
|
||||
`{ drop: 'the session is ending' }`.
|
||||
- A prompt still queued when the run stops early, on an interrupt, a structured
|
||||
result, a detected loop or a slash command that fails, does not run.
|
||||
- In the Ink and OpenTUI terminals the method is not listed in `methods` yet,
|
||||
and a call resolves to
|
||||
`{ drop: 'this host does not run prompts a Mod submits' }`.
|
||||
result, a detected loop or a slash command that fails, does not run. In the
|
||||
Ink terminal, neither does one still queued when the person stops a turn,
|
||||
with Esc or by declining every tool it asks to run. Until the person sends a
|
||||
prompt again, a call then resolves to
|
||||
`{ drop: 'the person stopped a turn and has not sent a prompt since' }`, so a
|
||||
`turn.complete` hook of the stopped turn starts no turn. A turn a Mod ends
|
||||
with `$.turn.abort` keeps them.
|
||||
- In the OpenTUI terminal the method is not listed in `methods` yet, and a
|
||||
call resolves to `{ drop: 'this host does not run prompts a Mod submits' }`.
|
||||
|
||||
These differ from Claude Code:
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* A Mod's `$.prompt.submit` in the Ink terminal, on the built CLI in a real
|
||||
* terminal: the prompt a hook submits once a turn ends runs as a turn of its
|
||||
* own once the session is idle, under a line naming the Mod, and the Mods'
|
||||
* prompts stop at their limit.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { InteractiveSession } from './interactive-session.js';
|
||||
import {
|
||||
startFakeOpenAIServer,
|
||||
type FakeOpenAIServer,
|
||||
} from '../fake-openai-server.js';
|
||||
import { E2E_MEMORY_SETTINGS_DEFAULTS } from '../e2e-memory-defaults.js';
|
||||
import { pickE2eRenderer, resolveE2eCliCommand } from '../renderer-matrix.js';
|
||||
import { removeScratchDir } from '../scratch-dir.js';
|
||||
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const bundle = join(repo, 'dist/cli.js');
|
||||
const renderer = pickE2eRenderer();
|
||||
const json = (file: string, value: unknown) =>
|
||||
writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
|
||||
|
||||
function files(root: string): string[] {
|
||||
if (!existsSync(root)) return [];
|
||||
return readdirSync(root, { withFileTypes: true }).flatMap((entry) =>
|
||||
entry.isDirectory()
|
||||
? files(join(root, entry.name))
|
||||
: [join(root, entry.name)],
|
||||
);
|
||||
}
|
||||
|
||||
type Message = { role: string; content?: unknown };
|
||||
const text = (content: unknown): string =>
|
||||
typeof content === 'string'
|
||||
? content
|
||||
: Array.isArray(content)
|
||||
? content.map((part) => (part as { text?: string }).text ?? '').join('\n')
|
||||
: '';
|
||||
|
||||
// OpenTUI does not run a Mod's own prompts yet.
|
||||
describe.skipIf(renderer !== 'ink')(
|
||||
'$.prompt.submit in the Ink terminal',
|
||||
() => {
|
||||
let server: FakeOpenAIServer | undefined;
|
||||
let session: InteractiveSession | undefined;
|
||||
let scratch: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await session?.close();
|
||||
session = undefined;
|
||||
await server?.close();
|
||||
server = undefined;
|
||||
if (scratch) await removeScratchDir(scratch);
|
||||
scratch = undefined;
|
||||
});
|
||||
|
||||
async function start(hooks: string) {
|
||||
scratch = mkdtempSync(join(tmpdir(), 'qwen-mods-prompt-submit-ink-'));
|
||||
const workspace = join(scratch, 'workspace');
|
||||
const qwenHome = join(scratch, 'home');
|
||||
const plugin = join(scratch, 'followup-mod');
|
||||
const env = {
|
||||
PATH: process.env['PATH'],
|
||||
TERM: 'xterm-256color',
|
||||
LANG: 'en_US.UTF-8',
|
||||
HOME: join(scratch, 'os-home'),
|
||||
QWEN_HOME: qwenHome,
|
||||
QWEN_RUNTIME_DIR: join(scratch, 'runtime'),
|
||||
NO_PROXY: 'localhost,127.0.0.1',
|
||||
no_proxy: 'localhost,127.0.0.1',
|
||||
QWEN_CODE_DISABLE_CRON: '1',
|
||||
QWEN_CODE_DISABLE_UPDATE_CHECK: '1',
|
||||
QWEN_SANDBOX: 'false',
|
||||
};
|
||||
for (const directory of [
|
||||
workspace,
|
||||
qwenHome,
|
||||
env.HOME,
|
||||
join(plugin, '.claude-plugin'),
|
||||
join(plugin, 'hooks'),
|
||||
])
|
||||
mkdirSync(directory, { recursive: true });
|
||||
json(join(qwenHome, 'settings.json'), {
|
||||
experimental: { mods: true },
|
||||
general: {
|
||||
enableAutoUpdate: false,
|
||||
chatRecording: true,
|
||||
showSessionRecap: false,
|
||||
},
|
||||
ui: { enableFollowupSuggestions: false },
|
||||
memory: E2E_MEMORY_SETTINGS_DEFAULTS,
|
||||
security: {
|
||||
folderTrust: { enabled: true },
|
||||
auth: { selectedType: 'openai' },
|
||||
},
|
||||
});
|
||||
json(join(qwenHome, 'trustedFolders.json'), {
|
||||
[workspace]: 'TRUST_FOLDER',
|
||||
});
|
||||
json(join(plugin, '.claude-plugin/plugin.json'), {
|
||||
name: 'followup-mod',
|
||||
version: '1.0.0',
|
||||
});
|
||||
json(join(plugin, 'hooks/hooks.json'), { modules: ['./register.js'] });
|
||||
writeFileSync(
|
||||
join(plugin, 'hooks/register.js'),
|
||||
`export function register(on) { ${hooks} }`,
|
||||
);
|
||||
|
||||
let answered = 0;
|
||||
server = await startFakeOpenAIServer(({ body }) =>
|
||||
body['stream'] === true
|
||||
? { content: `Answer ${++answered}` }
|
||||
: { content: '{}' },
|
||||
);
|
||||
const cli = resolveE2eCliCommand(renderer);
|
||||
execFileSync(
|
||||
cli,
|
||||
[bundle, 'extensions', 'install', plugin, '--consent'],
|
||||
{
|
||||
cwd: workspace,
|
||||
env,
|
||||
timeout: 60_000,
|
||||
stdio: 'pipe',
|
||||
},
|
||||
);
|
||||
session = await InteractiveSession.start({
|
||||
cols: 160,
|
||||
rows: 50,
|
||||
cwd: workspace,
|
||||
env,
|
||||
inheritEnv: false,
|
||||
command: {
|
||||
bin: cli,
|
||||
args: [
|
||||
bundle,
|
||||
'--auth-type',
|
||||
'openai',
|
||||
'--model',
|
||||
'fixture',
|
||||
'--openai-base-url',
|
||||
server.baseUrl,
|
||||
'--openai-api-key',
|
||||
'fixture',
|
||||
],
|
||||
},
|
||||
});
|
||||
await session.idle(1000, 20_000);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** The last user message of each main-conversation model request. */
|
||||
const prompts = () =>
|
||||
server!.requests
|
||||
.filter((request) => request.body['stream'] === true)
|
||||
.map((request) =>
|
||||
text(
|
||||
((request.body['messages'] ?? []) as Message[])
|
||||
.filter((message) => message.role === 'user')
|
||||
.at(-1)?.content,
|
||||
),
|
||||
);
|
||||
|
||||
const records = () =>
|
||||
files(scratch!)
|
||||
.filter((file) => file.endsWith('.jsonl'))
|
||||
.flatMap((file) =>
|
||||
readFileSync(file, 'utf8')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>),
|
||||
)
|
||||
.filter(
|
||||
(record) =>
|
||||
record['type'] === 'user' && record['subtype'] === undefined,
|
||||
);
|
||||
|
||||
it('runs the prompt a hook submits once idle, under a line naming the Mod', async () => {
|
||||
const nonce = randomUUID().slice(0, 8);
|
||||
const terminal = await start(`
|
||||
let asked = false;
|
||||
on('turn.complete', ($, e, next) => {
|
||||
if (!asked) {
|
||||
asked = true;
|
||||
void $.prompt.submit({ text: 'Follow up ${nonce}' });
|
||||
}
|
||||
return next(e);
|
||||
});`);
|
||||
|
||||
await terminal.send(`Look ${nonce}`);
|
||||
await terminal.waitForScreen(
|
||||
(screen) => screen.includes('Answer 2'),
|
||||
"the Mod prompt's answer",
|
||||
60_000,
|
||||
);
|
||||
|
||||
expect(await terminal.screen()).toContain(
|
||||
`Mod followup-mod: Follow up ${nonce}`,
|
||||
);
|
||||
expect(prompts()).toHaveLength(2);
|
||||
expect(prompts()[1]).toContain(
|
||||
`<qwen:mod-prompt mod="followup-mod">\nFollow up ${nonce}\n</qwen:mod-prompt>`,
|
||||
);
|
||||
expect(
|
||||
records().map((record) => [
|
||||
record['provenance'],
|
||||
(record['systemPayload'] as { mod?: string } | undefined)?.mod,
|
||||
]),
|
||||
).toEqual([
|
||||
['real_user', undefined],
|
||||
['mod_prompt', 'followup-mod'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('stops a hook that submits at every turn at the limit', async () => {
|
||||
const terminal = await start(`
|
||||
let n = 0;
|
||||
on('turn.complete', ($, e, next) => {
|
||||
void $.prompt.submit({ text: 'again ' + ++n });
|
||||
return next(e);
|
||||
});`);
|
||||
|
||||
await terminal.send('Start');
|
||||
await terminal.waitForScreen(
|
||||
(screen) => screen.includes('$.prompt.submit refused'),
|
||||
'the limit line',
|
||||
120_000,
|
||||
);
|
||||
await terminal.idle(2000, 30_000);
|
||||
|
||||
// The person's turn and eight of the Mod's.
|
||||
expect(prompts()).toHaveLength(9);
|
||||
expect(await terminal.screen()).toContain(
|
||||
'the Mods have started 8 turns since the last prompt from the person',
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
+1
-1
@@ -99,7 +99,7 @@
|
||||
"test:integration:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
|
||||
"test:integration:hosted:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --config ./vitest.hosted.config.ts",
|
||||
"test:integration:no-ak:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 ./fake-openai-server.test.ts ./test-helper.test.ts ./chat-transcript-contract.test.ts ./skill-hooks-invocation-parity.test.ts ./skill-hooks-resume.test.ts ./qwen-live-m4-acp-call.test.ts ./qwen-live-m4-acp-permission.test.ts ./qwen-live-m4-acp-steering.test.ts ./qwen-live-m4-acp-multibackend.test.ts ./qwen-live-m1-call.test.ts ./qwen-live-m2-inject.test.ts ./qwen-live-m2-permission.test.ts ./qwen-live-m2-steering.test.ts ./cli/_prompt-latency-policy.test.ts ./cli/agent-loop-parity.test.ts ./cli/acp-agent-loop-parity.test.ts ./cli/hosted-harness-process.test.ts ./cli/daemon-invocation-context.test.ts ./cli/headless-workflow-skill.test.ts ./cli/workflow-ancestor-discovery.test.ts ./cli/daemon-workflow-ancestor-trust.test.ts ./cli/workflow-intent.test.ts ./cli/workflow-mode.test.ts ./cli/acp-workflow-mode.test.ts ./cli/daemon-workflow-intent.test.ts ./cli/daemon-workflow-mode.test.ts ./cli/list_directory.test.ts ./cli/tool-hook-context.test.ts ./cli/tool-hook-updated-input.test.ts ./cli/mods-prompt-submit.test.ts ./cli/qwen-serve-conversations-root.test.ts ./cli/qwen-serve-routes.test.ts ./cli/qwen-serve-session-cd.test.ts ./cli/qwen-serve-streaming.test.ts ./cli/qwen-serve-standalone-concurrency.test.ts ./helpers/hosted-sse-observer.test.ts ./interactive/workflow-completion.test.ts ./interactive/workflow-intent.test.ts ./interactive/workflow-mode.test.ts ./interactive/mods-tool-middleware.test.ts ./interactive/statusline-parity.test.ts ./interactive/tui-editor-parity.test.ts ./interactive/tui-input-ownership.test.ts ./interactive/ink-agent-loop-parity.test.ts ./sdk-typescript/abort-and-lifecycle.test.ts ./sdk-typescript/permission-control.test.ts ./sdk-typescript/sdk-mcp-server.test.ts ./sdk-typescript/subagents.test.ts ./sdk-typescript/system-control.test.ts ./sdk-typescript/tool-control.test.ts ./sdk-typescript/workflow-intent.test.ts ./sdk-typescript/workflow-mode.test.ts",
|
||||
"test:integration:no-ak:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 ./fake-openai-server.test.ts ./test-helper.test.ts ./chat-transcript-contract.test.ts ./skill-hooks-invocation-parity.test.ts ./skill-hooks-resume.test.ts ./qwen-live-m4-acp-call.test.ts ./qwen-live-m4-acp-permission.test.ts ./qwen-live-m4-acp-steering.test.ts ./qwen-live-m4-acp-multibackend.test.ts ./qwen-live-m1-call.test.ts ./qwen-live-m2-inject.test.ts ./qwen-live-m2-permission.test.ts ./qwen-live-m2-steering.test.ts ./cli/_prompt-latency-policy.test.ts ./cli/agent-loop-parity.test.ts ./cli/acp-agent-loop-parity.test.ts ./cli/hosted-harness-process.test.ts ./cli/daemon-invocation-context.test.ts ./cli/headless-workflow-skill.test.ts ./cli/workflow-ancestor-discovery.test.ts ./cli/daemon-workflow-ancestor-trust.test.ts ./cli/workflow-intent.test.ts ./cli/workflow-mode.test.ts ./cli/acp-workflow-mode.test.ts ./cli/daemon-workflow-intent.test.ts ./cli/daemon-workflow-mode.test.ts ./cli/list_directory.test.ts ./cli/tool-hook-context.test.ts ./cli/tool-hook-updated-input.test.ts ./cli/mods-prompt-submit.test.ts ./cli/qwen-serve-conversations-root.test.ts ./cli/qwen-serve-routes.test.ts ./cli/qwen-serve-session-cd.test.ts ./cli/qwen-serve-streaming.test.ts ./cli/qwen-serve-standalone-concurrency.test.ts ./helpers/hosted-sse-observer.test.ts ./interactive/workflow-completion.test.ts ./interactive/workflow-intent.test.ts ./interactive/workflow-mode.test.ts ./interactive/mods-tool-middleware.test.ts ./interactive/mods-prompt-submit.test.ts ./interactive/statusline-parity.test.ts ./interactive/tui-editor-parity.test.ts ./interactive/tui-input-ownership.test.ts ./interactive/ink-agent-loop-parity.test.ts ./sdk-typescript/abort-and-lifecycle.test.ts ./sdk-typescript/permission-control.test.ts ./sdk-typescript/sdk-mcp-server.test.ts ./sdk-typescript/subagents.test.ts ./sdk-typescript/system-control.test.ts ./sdk-typescript/tool-control.test.ts ./sdk-typescript/workflow-intent.test.ts ./sdk-typescript/workflow-mode.test.ts",
|
||||
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 sdk-typescript",
|
||||
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 sdk-typescript",
|
||||
"test:sdk:python": "python3 -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q",
|
||||
|
||||
@@ -280,6 +280,48 @@ describe('interactive Mod owner', () => {
|
||||
expect(runtime.submitPrompt).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('runs the Mods’ prompts only when its first initialize says so, until it closes', async () => {
|
||||
const { owner, host, runtime } = setup();
|
||||
const queued = { plugin: 'lint', text: 'Fix it' };
|
||||
const queue = {
|
||||
hasSubmittedPrompts: true,
|
||||
takeSubmittedPrompt: vi.fn(() => queued),
|
||||
returnSubmittedPrompt: vi.fn(),
|
||||
notePersonPrompt: vi.fn(),
|
||||
stopSubmittedPrompts: vi.fn(),
|
||||
};
|
||||
Object.assign(runtime, queue);
|
||||
expect(host.runsPrompts!()).toBe(false);
|
||||
await owner.initialize(['AbovePrompt'], {
|
||||
prompts: true,
|
||||
runsPrompts: true,
|
||||
});
|
||||
await owner.initialize(['AbovePrompt']);
|
||||
expect(host.runsPrompts!()).toBe(true);
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = owner.subscribePrompts(listener);
|
||||
host.promptQueued!();
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
unsubscribe();
|
||||
host.promptQueued!();
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(owner.hasSubmittedPrompts).toBe(true);
|
||||
expect(owner.takeSubmittedPrompt()).toBe(queued);
|
||||
owner.returnSubmittedPrompt(queued);
|
||||
expect(queue.returnSubmittedPrompt).toHaveBeenCalledWith(queued);
|
||||
owner.notePersonPrompt();
|
||||
owner.stopSubmittedPrompts();
|
||||
expect(queue.notePersonPrompt).toHaveBeenCalledOnce();
|
||||
expect(queue.stopSubmittedPrompts).toHaveBeenCalledOnce();
|
||||
owner.subscribePrompts(listener);
|
||||
await owner.close();
|
||||
// A closed owner runs none, and its listeners hear no more.
|
||||
expect(owner.hasSubmittedPrompts).toBe(false);
|
||||
expect(owner.takeSubmittedPrompt()).toBeUndefined();
|
||||
host.promptQueued!();
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('closing before startup releases pending command loaders and removes the owner', async () => {
|
||||
const { config, owner, runtime } = setup();
|
||||
const commands = owner.loadCommands(new AbortController().signal);
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
|
||||
import type { Config } from '@qwen-code/qwen-code-core/config/config.js';
|
||||
import type { ModToolCall } from '@qwen-code/qwen-code-core/mods/tool-events.js';
|
||||
import type { ModCommandPresentation } from '@qwen-code/qwen-code-core/mods/types.js';
|
||||
import type {
|
||||
ModCommandPresentation,
|
||||
ModQueuedPrompt,
|
||||
} from '@qwen-code/qwen-code-core/mods/types.js';
|
||||
import type {
|
||||
ModRenderElement,
|
||||
ModRenderInput,
|
||||
@@ -82,6 +85,8 @@ export class InteractiveModSession implements ICommandLoader {
|
||||
};
|
||||
private sites: readonly ModRenderSite[] = [];
|
||||
private prompts = false;
|
||||
private runsPrompts = false;
|
||||
private readonly promptListeners = new Set<() => void>();
|
||||
// The band's last tree, kept until a new answer lands; none draws nothing.
|
||||
private band?: ModBand;
|
||||
private revision = 0;
|
||||
@@ -98,6 +103,10 @@ export class InteractiveModSession implements ICommandLoader {
|
||||
presentation: () => this.presentation,
|
||||
sites: () => this.sites,
|
||||
prompts: () => this.prompts,
|
||||
runsPrompts: () => this.runsPrompts,
|
||||
promptQueued: () => {
|
||||
for (const listener of this.promptListeners) listener();
|
||||
},
|
||||
draw: (tree, owner) => {
|
||||
const band =
|
||||
tree.type === 'engine'
|
||||
@@ -120,16 +129,21 @@ export class InteractiveModSession implements ICommandLoader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the Mods once; `sites` names the render sites the host draws, and
|
||||
* `prompts` says whether it raises `prompt.submit`.
|
||||
* Loads the Mods once; `sites` names the render sites the host draws,
|
||||
* `prompts` says whether it raises `prompt.submit`, and `runsPrompts`
|
||||
* whether it runs the prompts the Mods submit with `$.prompt.submit`.
|
||||
*/
|
||||
initialize(
|
||||
sites: readonly ModRenderSite[] = [],
|
||||
{ prompts = false }: { prompts?: boolean } = {},
|
||||
{
|
||||
prompts = false,
|
||||
runsPrompts = false,
|
||||
}: { prompts?: boolean; runsPrompts?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (!this.initialization) {
|
||||
this.sites = sites;
|
||||
this.prompts = prompts;
|
||||
this.runsPrompts = runsPrompts;
|
||||
}
|
||||
this.initialization ??= this.session.initialize(this.lifetime.signal).then(
|
||||
() => {
|
||||
@@ -255,6 +269,40 @@ export class InteractiveModSession implements ICommandLoader {
|
||||
: this.session.submitPrompt(text, signal);
|
||||
}
|
||||
|
||||
/** Hears each prompt a Mod queues with `$.prompt.submit`. */
|
||||
readonly subscribePrompts = (listener: () => void): (() => void) => {
|
||||
this.promptListeners.add(listener);
|
||||
return () => this.promptListeners.delete(listener);
|
||||
};
|
||||
|
||||
/** Whether a prompt a Mod submitted waits for the session to be idle. */
|
||||
get hasSubmittedPrompts(): boolean {
|
||||
return !this.closing && this.session.hasSubmittedPrompts;
|
||||
}
|
||||
|
||||
/** The next prompt a Mod submitted, to run now as a turn of its own. */
|
||||
takeSubmittedPrompt(): ModQueuedPrompt | undefined {
|
||||
return this.closing ? undefined : this.session.takeSubmittedPrompt();
|
||||
}
|
||||
|
||||
/** Puts back a prompt whose turn could not start. */
|
||||
returnSubmittedPrompt(prompt: ModQueuedPrompt): void {
|
||||
if (!this.closing) this.session.returnSubmittedPrompt(prompt);
|
||||
}
|
||||
|
||||
/** The person sent a prompt, so the Mods' prompts may start turns again. */
|
||||
notePersonPrompt(): void {
|
||||
this.session.notePersonPrompt();
|
||||
}
|
||||
|
||||
/**
|
||||
* The person stopped the running turn: drops the Mods' queued prompts and
|
||||
* refuses new ones until the person sends a prompt again.
|
||||
*/
|
||||
stopSubmittedPrompts(): void {
|
||||
this.session.stopSubmittedPrompts();
|
||||
}
|
||||
|
||||
startTurn(
|
||||
text: string,
|
||||
signal: AbortSignal,
|
||||
@@ -278,6 +326,7 @@ export class InteractiveModSession implements ICommandLoader {
|
||||
this.logs.length = 0;
|
||||
this.band = undefined;
|
||||
for (const listener of this.bandListeners) listener();
|
||||
this.promptListeners.clear();
|
||||
if (sessions.get(this.config) === this) sessions.delete(this.config);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { frameModPrompt } from './mod-prompt-frame.js';
|
||||
import { frameModPrompt, modPromptRow } from './mod-prompt-frame.js';
|
||||
|
||||
describe('frameModPrompt', () => {
|
||||
it('names the Mod and says the person did not type the prompt', () => {
|
||||
@@ -38,3 +38,16 @@ describe('frameModPrompt', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modPromptRow', () => {
|
||||
it('names the Mod on one line and keeps the text safe for the terminal', () => {
|
||||
expect(modPromptRow('lint', 'Run it\nthen report')).toBe(
|
||||
'Mod lint: Run it\nthen report',
|
||||
);
|
||||
const row = modPromptRow('li\nnt\u202e', 'a\u001b]52;c;eA==\u0007b');
|
||||
expect(row.startsWith('Mod li nt: ')).toBe(true);
|
||||
expect(row).not.toContain('\u001b');
|
||||
expect(row).not.toContain('\u0007');
|
||||
expect(row).not.toContain('\u202e');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,10 @@
|
||||
*/
|
||||
|
||||
import type { ModQueuedPrompt } from '@qwen-code/qwen-code-core/mods/types.js';
|
||||
import {
|
||||
sanitizeTerminalLine,
|
||||
sanitizeTerminalText,
|
||||
} from '../ui/utils/textUtils.js';
|
||||
|
||||
const CLOSE = '</qwen:mod-prompt>';
|
||||
|
||||
@@ -26,3 +30,12 @@ export function frameModPrompt(prompt: ModQueuedPrompt): string {
|
||||
`The ${name} Mod, an extension the user installed, sent the message above, which waited until the session was idle; the user did not type it. Act on it as a request made on the user's behalf, within what the user has asked for and allowed. Only the block's own closing marker ends it.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The transcript row a terminal shows for a prompt a Mod submitted, live and
|
||||
* on resume: a notice naming the Mod, not a prompt the person typed, `asUser`
|
||||
* or not.
|
||||
*/
|
||||
export function modPromptRow(plugin: string, text: string): string {
|
||||
return `Mod ${sanitizeTerminalLine(plugin)}: ${sanitizeTerminalText(text)}`;
|
||||
}
|
||||
@@ -822,18 +822,92 @@ describe('Mod session ownership', () => {
|
||||
expect(session.takeSubmittedPrompt()?.text).toBe('after');
|
||||
});
|
||||
|
||||
it('offers no queue to a terminal yet', async () => {
|
||||
const { session } = setup(
|
||||
it('refuses the Mods’ prompts after the person stops a turn, until they send one', async () => {
|
||||
const { session, config } = setup();
|
||||
await session.initialize(new AbortController().signal);
|
||||
const queue = queueOf()!;
|
||||
expect(queue(prompt('queued'))).toBeUndefined();
|
||||
const taken = session.takeSubmittedPrompt()!;
|
||||
expect(queue(prompt('waiting'))).toBeUndefined();
|
||||
session.stopSubmittedPrompts();
|
||||
expect(session.hasSubmittedPrompts).toBe(false);
|
||||
// What a stopped turn's hooks submit, awaited or not, does not run.
|
||||
expect(queue(prompt('from turn.complete'))).toBe(
|
||||
'the person stopped a turn and has not sent a prompt since',
|
||||
);
|
||||
session.returnSubmittedPrompt(taken);
|
||||
expect(session.takeSubmittedPrompt()).toBeUndefined();
|
||||
session.notePersonPrompt();
|
||||
expect(queue(prompt('after'))).toBeUndefined();
|
||||
expect(session.takeSubmittedPrompt()?.text).toBe('after');
|
||||
// A new session starts unstopped.
|
||||
session.stopSubmittedPrompts();
|
||||
config.getSessionId.mockReturnValue('session-2');
|
||||
await session.syncSession('clear');
|
||||
expect(queue(prompt('fresh'))).toBeUndefined();
|
||||
});
|
||||
|
||||
const terminal = (runsPrompts?: boolean, promptQueued = vi.fn()) =>
|
||||
setup(
|
||||
{ isInteractive: () => true },
|
||||
{
|
||||
surface: 'terminal',
|
||||
log: vi.fn(),
|
||||
presentation: () => ({ isFullscreen: false, columns: 80 }),
|
||||
prompts: () => true,
|
||||
...(runsPrompts === undefined
|
||||
? {}
|
||||
: { runsPrompts: () => runsPrompts }),
|
||||
promptQueued,
|
||||
},
|
||||
);
|
||||
|
||||
it('offers a terminal a queue only once it runs the prompts', async () => {
|
||||
for (const runs of [undefined, false]) {
|
||||
mocks.create.mockClear();
|
||||
const { session } = terminal(runs);
|
||||
await session.initialize(new AbortController().signal);
|
||||
expect(queueOf()).toBeUndefined();
|
||||
}
|
||||
mocks.create.mockClear();
|
||||
const promptQueued = vi.fn();
|
||||
const { session } = terminal(true, promptQueued);
|
||||
await session.initialize(new AbortController().signal);
|
||||
expect(queueOf()).toBeUndefined();
|
||||
expect(queueOf()!(prompt('one'))).toBeUndefined();
|
||||
// The terminal hears each prompt queued, to run it once idle.
|
||||
expect(promptQueued).toHaveBeenCalledTimes(1);
|
||||
expect(session.takeSubmittedPrompt()).toEqual(prompt('one'));
|
||||
});
|
||||
|
||||
it('puts back a prompt whose turn could not start, uncounted', async () => {
|
||||
const { session, config } = setup();
|
||||
await session.initialize(new AbortController().signal);
|
||||
const queue = queueOf()!;
|
||||
for (let index = 0; index < MOD_PROMPT_TURN_LIMIT - 1; index++)
|
||||
queue(prompt(`p${index}`));
|
||||
const first = session.takeSubmittedPrompt()!;
|
||||
queue(prompt('last'));
|
||||
expect(queue(prompt('over'))).toBeDefined();
|
||||
session.returnSubmittedPrompt(first);
|
||||
// A prompt is put back once.
|
||||
session.returnSubmittedPrompt(first);
|
||||
expect(queue(prompt('over'))).toBeDefined();
|
||||
expect(session.takeSubmittedPrompt()).toEqual(prompt('p0'));
|
||||
expect(session.takeSubmittedPrompt()).toEqual(prompt('p1'));
|
||||
// A session that ended since takes nothing back.
|
||||
const taken = session.takeSubmittedPrompt()!;
|
||||
config.getSessionId.mockReturnValue('session-2');
|
||||
session.returnSubmittedPrompt(taken);
|
||||
config.getSessionId.mockReturnValue('session-1');
|
||||
expect(session.takeSubmittedPrompt()).toEqual(prompt('p3'));
|
||||
// Put back, it counts as queued, not as started too.
|
||||
session.discardSubmittedPrompts();
|
||||
session.notePersonPrompt();
|
||||
queue(prompt('again'));
|
||||
session.returnSubmittedPrompt(session.takeSubmittedPrompt()!);
|
||||
session.discardSubmittedPrompts();
|
||||
for (let index = 0; index < MOD_PROMPT_TURN_LIMIT; index++)
|
||||
expect(queue(prompt(`n${index}`))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -100,6 +100,13 @@ export interface ModTerminalHost {
|
||||
sites?(): readonly ModRenderSite[];
|
||||
/** Whether the terminal raises `prompt.submit`, read once at load. */
|
||||
prompts?(): boolean;
|
||||
/**
|
||||
* Whether the terminal runs the prompts the Mods submit with
|
||||
* `$.prompt.submit`, read once at load.
|
||||
*/
|
||||
runsPrompts?(): boolean;
|
||||
/** A Mod's prompt was queued, for the terminal to run once idle. */
|
||||
promptQueued?(): void;
|
||||
/** Draws the tree the band's hooks settled on, drawn by `owner`. */
|
||||
draw?(tree: ModRenderElement, owner: string): void;
|
||||
}
|
||||
@@ -177,6 +184,11 @@ export class ModSession implements ICommandLoader {
|
||||
[];
|
||||
// Turns the Mods' prompts started since the person last sent one.
|
||||
private modTurns = 0;
|
||||
// The person stopped a turn and has not sent a prompt since: what the Mods
|
||||
// submit does not run, also a call a hook did not await before it ended.
|
||||
private stopped = false;
|
||||
// The session each prompt taken to run was queued for.
|
||||
private readonly taken = new WeakMap<ModQueuedPrompt, string>();
|
||||
// Also runs on every API response, so a session that no hook saw still has
|
||||
// its figures when it ends. A subagent's response emits from inside its
|
||||
// agent frame, whose model would otherwise answer for the main session.
|
||||
@@ -418,8 +430,10 @@ export class ModSession implements ICommandLoader {
|
||||
? { sites, invalidate: () => this.requestRender() }
|
||||
: undefined,
|
||||
this.terminal ? this.terminal.prompts?.() === true : true,
|
||||
// The terminals do not run a Mod's own prompts yet.
|
||||
this.terminal ? undefined : (prompt) => this.queuePrompt(prompt),
|
||||
// A terminal runs a Mod's own prompts only once it says so.
|
||||
this.terminal && this.terminal.runsPrompts?.() !== true
|
||||
? undefined
|
||||
: (prompt) => this.queuePrompt(prompt),
|
||||
);
|
||||
this.mods.push(
|
||||
...ordered.map(({ extension }) => ({
|
||||
@@ -543,6 +557,7 @@ export class ModSession implements ICommandLoader {
|
||||
// The Mods' prompts and the turns they started belong to the old one.
|
||||
this.submitted.length = 0;
|
||||
this.modTurns = 0;
|
||||
this.stopped = false;
|
||||
this.adoptedAt = Date.now();
|
||||
this.startedAt = undefined;
|
||||
this.recorder = undefined;
|
||||
@@ -724,8 +739,12 @@ export class ModSession implements ICommandLoader {
|
||||
// the hooks that hear it end cannot queue a prompt for it.
|
||||
if (this.closing || this.sessionId !== this.config.getSessionId())
|
||||
return 'the session is ending';
|
||||
if (this.modTurns + this.submitted.length >= MOD_PROMPT_TURN_LIMIT) {
|
||||
const refused = `the Mods have started ${MOD_PROMPT_TURN_LIMIT} turns since the last prompt from the person`;
|
||||
const refused = this.stopped
|
||||
? 'the person stopped a turn and has not sent a prompt since'
|
||||
: this.modTurns + this.submitted.length >= MOD_PROMPT_TURN_LIMIT
|
||||
? `the Mods have started ${MOD_PROMPT_TURN_LIMIT} turns since the last prompt from the person`
|
||||
: undefined;
|
||||
if (refused) {
|
||||
this.pendingLogs.push({
|
||||
text: `$.prompt.submit refused: ${refused}`,
|
||||
to: 'transcript',
|
||||
@@ -736,6 +755,7 @@ export class ModSession implements ICommandLoader {
|
||||
return refused;
|
||||
}
|
||||
this.submitted.push({ ...prompt, sessionId: this.sessionId });
|
||||
this.terminal?.promptQueued?.();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -750,10 +770,31 @@ export class ModSession implements ICommandLoader {
|
||||
while (next && next.sessionId !== sessionId) next = this.submitted.shift();
|
||||
if (!next || this.closing) return undefined;
|
||||
this.modTurns++;
|
||||
const { sessionId: _session, ...prompt } = next;
|
||||
const { sessionId: queuedFor, ...prompt } = next;
|
||||
this.taken.set(prompt, queuedFor);
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts back a prompt {@link takeSubmittedPrompt} took, at the front of the
|
||||
* queue, when the turn it was taken for could not start. It no longer counts
|
||||
* toward {@link MOD_PROMPT_TURN_LIMIT}.
|
||||
*/
|
||||
returnSubmittedPrompt(prompt: ModQueuedPrompt): void {
|
||||
const sessionId = this.taken.get(prompt);
|
||||
this.taken.delete(prompt);
|
||||
// A session that ended since takes nothing back.
|
||||
if (
|
||||
sessionId === undefined ||
|
||||
this.closing ||
|
||||
this.stopped ||
|
||||
sessionId !== this.config.getSessionId()
|
||||
)
|
||||
return;
|
||||
this.modTurns = Math.max(0, this.modTurns - 1);
|
||||
this.submitted.unshift({ ...prompt, sessionId });
|
||||
}
|
||||
|
||||
/** Whether a prompt a Mod submitted for the current session waits to run. */
|
||||
get hasSubmittedPrompts(): boolean {
|
||||
const sessionId = this.config.getSessionId();
|
||||
@@ -766,16 +807,28 @@ export class ModSession implements ICommandLoader {
|
||||
/** The person sent a prompt, so the Mods' prompts may start turns again. */
|
||||
notePersonPrompt(): void {
|
||||
this.modTurns = 0;
|
||||
this.stopped = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the prompts the Mods submitted that have not started: a run that
|
||||
* stopped early does not run them, and neither does the next one.
|
||||
* Drops the prompts the Mods submitted that have not started: a headless
|
||||
* run that stopped early does not run them, and neither does the next one.
|
||||
*/
|
||||
discardSubmittedPrompts(): void {
|
||||
this.submitted.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The person stopped the running turn: drops the prompts the Mods submitted
|
||||
* that have not started, and refuses those they submit until the person
|
||||
* sends a prompt again. A `turn.complete` hook of the stopped turn that
|
||||
* submits, awaited or not, starts no turn.
|
||||
*/
|
||||
stopSubmittedPrompts(): void {
|
||||
this.discardSubmittedPrompts();
|
||||
this.stopped = true;
|
||||
}
|
||||
|
||||
/** Whether a loaded Mod hooks `prompt.submit`. */
|
||||
get hearsPrompts(): boolean {
|
||||
return !this.closing && (this.runtime?.promptHooks.length ?? 0) > 0;
|
||||
|
||||
@@ -932,6 +932,7 @@ describe('AppContainer State Management', () => {
|
||||
await vi.waitFor(() =>
|
||||
expect(initialize).toHaveBeenCalledWith(['AbovePrompt'], {
|
||||
prompts: true,
|
||||
runsPrompts: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5037,6 +5038,7 @@ describe('AppContainer State Management', () => {
|
||||
canUndoLastLoggedUserMessage: boolean;
|
||||
turnProducedMeaningfulContent: boolean;
|
||||
wasGoalTurn?: boolean;
|
||||
wasModPromptTurn?: boolean;
|
||||
}) => void;
|
||||
let capturedOnCancelSubmit: CapturedCancelSubmit | null = null;
|
||||
|
||||
@@ -5535,79 +5537,85 @@ describe('AppContainer State Management', () => {
|
||||
expect(mockRemoveLastUserMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('strips the orphaned continuation prompt when a Goal turn is cancelled', async () => {
|
||||
const mockStripOrphans = vi.fn();
|
||||
const mockTruncateToItem = vi.fn();
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: vi.fn(),
|
||||
});
|
||||
mockedUseHistory.mockReturnValue({
|
||||
history: [{ id: 1, type: 'info', text: 'Request cancelled.' }],
|
||||
addItem: vi.fn(),
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
truncateToItem: mockTruncateToItem,
|
||||
});
|
||||
mockedUseLogger.mockReturnValue({
|
||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||
removeLastUserMessage: vi.fn().mockResolvedValue(true),
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getLlmClient').mockReturnValue({
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
setTools: vi.fn().mockResolvedValue(undefined),
|
||||
isInitialized: vi.fn().mockReturnValue(false),
|
||||
stripOrphanedUserEntriesFromHistory: mockStripOrphans,
|
||||
} as unknown as LlmClient);
|
||||
installCancelCapture({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
retryLastPrompt: vi.fn(),
|
||||
});
|
||||
mockedUseMessageQueue.mockReturnValue({
|
||||
removeGoalTurns: vi.fn().mockReturnValue([]),
|
||||
messageQueue: [],
|
||||
addMessage: vi.fn(),
|
||||
clearQueue: vi.fn(),
|
||||
getQueuedMessagesText: vi.fn().mockReturnValue(''),
|
||||
popAllMessages: vi.fn().mockReturnValue(null),
|
||||
drainQueue: vi.fn().mockReturnValue([]),
|
||||
popNextTurn: vi.fn().mockReturnValue(null),
|
||||
});
|
||||
it.each([
|
||||
['a Goal continuation turn', { wasGoalTurn: true }],
|
||||
["a Mod's own prompt turn", { wasModPromptTurn: true }],
|
||||
] as const)(
|
||||
'strips the orphaned prompt when %s is cancelled',
|
||||
async (_turn, flag) => {
|
||||
const mockStripOrphans = vi.fn();
|
||||
const mockTruncateToItem = vi.fn();
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: vi.fn(),
|
||||
});
|
||||
mockedUseHistory.mockReturnValue({
|
||||
history: [{ id: 1, type: 'info', text: 'Request cancelled.' }],
|
||||
addItem: vi.fn(),
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
truncateToItem: mockTruncateToItem,
|
||||
});
|
||||
mockedUseLogger.mockReturnValue({
|
||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||
removeLastUserMessage: vi.fn().mockResolvedValue(true),
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getLlmClient').mockReturnValue({
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
setTools: vi.fn().mockResolvedValue(undefined),
|
||||
isInitialized: vi.fn().mockReturnValue(false),
|
||||
stripOrphanedUserEntriesFromHistory: mockStripOrphans,
|
||||
} as unknown as LlmClient);
|
||||
installCancelCapture({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
retryLastPrompt: vi.fn(),
|
||||
});
|
||||
mockedUseMessageQueue.mockReturnValue({
|
||||
removeGoalTurns: vi.fn().mockReturnValue([]),
|
||||
messageQueue: [],
|
||||
addMessage: vi.fn(),
|
||||
clearQueue: vi.fn(),
|
||||
getQueuedMessagesText: vi.fn().mockReturnValue(''),
|
||||
popAllMessages: vi.fn().mockReturnValue(null),
|
||||
drainQueue: vi.fn().mockReturnValue([]),
|
||||
popNextTurn: vi.fn().mockReturnValue(null),
|
||||
});
|
||||
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// A Goal continuation turn adds no UI user item, so lastTurnUserItem is
|
||||
// null and the auto-restore branch (with its own orphan strip) bails.
|
||||
// wasGoalTurn must trigger the strip independently so the synthetic
|
||||
// "no new real user input" prompt can't merge into the next message.
|
||||
triggerCancel({
|
||||
pendingItem: null,
|
||||
lastTurnUserItem: null,
|
||||
canUndoLastLoggedUserMessage: false,
|
||||
turnProducedMeaningfulContent: false,
|
||||
wasGoalTurn: true,
|
||||
});
|
||||
// A Goal continuation turn and a Mod's own prompt add no UI user item,
|
||||
// so lastTurnUserItem is null and the auto-restore branch (with its own
|
||||
// orphan strip) bails. The flag must trigger the strip independently
|
||||
// so the stopped prompt can't reach the model with the next message.
|
||||
triggerCancel({
|
||||
pendingItem: null,
|
||||
lastTurnUserItem: null,
|
||||
canUndoLastLoggedUserMessage: false,
|
||||
turnProducedMeaningfulContent: false,
|
||||
...flag,
|
||||
});
|
||||
|
||||
expect(mockStripOrphans).toHaveBeenCalledWith(true);
|
||||
// Auto-restore itself bailed: there was no user item to rewind.
|
||||
expect(mockTruncateToItem).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(mockStripOrphans).toHaveBeenCalledWith(true);
|
||||
// Auto-restore itself bailed: there was no user item to rewind.
|
||||
expect(mockTruncateToItem).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['queue', false, true, false, false],
|
||||
|
||||
@@ -1270,7 +1270,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Restore the transcript before Mod diagnostics, without making input
|
||||
// or Mod startup wait for asynchronous session recovery.
|
||||
void getInteractiveModSession(config)
|
||||
?.initialize(['AbovePrompt'], { prompts: true })
|
||||
?.initialize(['AbovePrompt'], { prompts: true, runsPrompts: true })
|
||||
.catch(() => {});
|
||||
|
||||
if (resumedSessionData) {
|
||||
@@ -3790,10 +3790,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// so the auto-restore branch below bails out before its orphan strip
|
||||
// runs. Strip the orphaned prompt here; otherwise appendCuratedContent
|
||||
// merges the user's NEXT real message into the "no new real user input"
|
||||
// preamble. Safe even if the turn already produced a model response:
|
||||
// the strip only pops trailing user entries, and a responded prompt is
|
||||
// not trailing.
|
||||
if (info?.wasGoalTurn) {
|
||||
// preamble. A Mod's own prompt is stripped the same way: the person
|
||||
// stopped it, so the next prompt must not reach the model behind it.
|
||||
// Safe even if the turn already produced a model response: the strip
|
||||
// only pops trailing user entries, and a responded prompt is not
|
||||
// trailing.
|
||||
if (info?.wasGoalTurn || info?.wasModPromptTurn) {
|
||||
llmClient?.stripOrphanedUserEntriesFromHistory?.(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,11 @@ import {
|
||||
import type { DirectUserAdmission, QueuedGoalTurn } from './useMessageQueue.js';
|
||||
import { useShellCommandProcessor } from './shellCommandProcessor.js';
|
||||
import * as interactiveMods from '../../services/interactive-mod-session.js';
|
||||
import {
|
||||
frameModPrompt,
|
||||
modPromptRow,
|
||||
} from '../../services/mod-prompt-frame.js';
|
||||
import type { ModQueuedPrompt } from '@qwen-code/qwen-code-core/mods/types.js';
|
||||
import {
|
||||
framePasteExpansion,
|
||||
REWRITE_CHANGED_PASTE,
|
||||
@@ -168,6 +173,7 @@ const mockDualOutput = vi.hoisted(() => ({
|
||||
finalizeAssistantMessage: vi.fn(),
|
||||
emitToolResult: vi.fn(),
|
||||
emitUserMessage: vi.fn(),
|
||||
emitSystemMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/review-worktree-lease.js', () => ({
|
||||
@@ -202,6 +208,15 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
});
|
||||
|
||||
const mockUseReactToolScheduler = useReactToolScheduler as Mock;
|
||||
// What a Mod session offers for the prompts the Mods submit, with none queued.
|
||||
const modQueue = () => ({
|
||||
subscribePrompts: () => () => {},
|
||||
hasSubmittedPrompts: false,
|
||||
takeSubmittedPrompt: vi.fn(),
|
||||
returnSubmittedPrompt: vi.fn(),
|
||||
notePersonPrompt: vi.fn(),
|
||||
stopSubmittedPrompts: vi.fn(),
|
||||
});
|
||||
const mockRunModelRound = vi.hoisted(() => vi.fn());
|
||||
vi.mock(
|
||||
'@qwen-code/qwen-code-core/core/agent-loop.js',
|
||||
@@ -645,7 +660,10 @@ describe('useLlmStream', () => {
|
||||
void ready.catch(() => {});
|
||||
const owner = vi
|
||||
.spyOn(interactiveMods, 'getInteractiveModSession')
|
||||
.mockReturnValue({ ready } as interactiveMods.InteractiveModSession);
|
||||
.mockReturnValue({
|
||||
ready,
|
||||
...modQueue(),
|
||||
} as unknown as interactiveMods.InteractiveModSession);
|
||||
try {
|
||||
const { result } = renderTestHook();
|
||||
await act(async () => {
|
||||
@@ -677,7 +695,10 @@ describe('useLlmStream', () => {
|
||||
void ready.catch(() => {});
|
||||
const owner = vi
|
||||
.spyOn(interactiveMods, 'getInteractiveModSession')
|
||||
.mockReturnValue({ ready } as interactiveMods.InteractiveModSession);
|
||||
.mockReturnValue({
|
||||
ready,
|
||||
...modQueue(),
|
||||
} as unknown as interactiveMods.InteractiveModSession);
|
||||
mockHandleSlashCommand.mockResolvedValue({ type: 'handled' });
|
||||
try {
|
||||
const { result } = renderTestHook();
|
||||
@@ -715,6 +736,7 @@ describe('useLlmStream', () => {
|
||||
.mockReturnValue({
|
||||
ready,
|
||||
startTurn: vi.fn(),
|
||||
...modQueue(),
|
||||
} as unknown as interactiveMods.InteractiveModSession);
|
||||
mockHandleSlashCommand.mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
@@ -9652,6 +9674,7 @@ describe('useLlmStream', () => {
|
||||
ready: Promise.resolve(),
|
||||
invalidateTools: vi.fn(),
|
||||
submitPrompt: vi.fn(async (text: string) => ({ text })),
|
||||
...modQueue(),
|
||||
syncSession: vi.fn(() =>
|
||||
transition === 'session before chat'
|
||||
? sessionSync
|
||||
@@ -14357,6 +14380,7 @@ describe('useLlmStream', () => {
|
||||
ready: Promise.resolve(),
|
||||
startTurn,
|
||||
submitPrompt,
|
||||
...modQueue(),
|
||||
} as unknown as ReturnType<
|
||||
typeof interactiveMods.getInteractiveModSession
|
||||
>);
|
||||
@@ -14419,6 +14443,558 @@ describe('useLlmStream', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("a Mod's own prompt", () => {
|
||||
const finished = async function* () {
|
||||
yield {
|
||||
type: ServerLlmEventType.Finished,
|
||||
value: { reason: 'STOP', usageMetadata: undefined },
|
||||
};
|
||||
};
|
||||
const installQueue = (...queued: ModQueuedPrompt[]) => {
|
||||
const listeners = new Set<() => void>();
|
||||
const turn = { id: 'turn-1', saw: vi.fn(), complete: vi.fn() };
|
||||
const mods = {
|
||||
ready: Promise.resolve() as Promise<void>,
|
||||
startTurn: vi.fn(
|
||||
(
|
||||
_text: string,
|
||||
_signal: AbortSignal,
|
||||
_abort: () => boolean,
|
||||
_promptId?: string,
|
||||
) => turn,
|
||||
),
|
||||
submitPrompt: vi.fn(async (text: string) => ({ text })),
|
||||
subscribePrompts: vi.fn((listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}),
|
||||
get hasSubmittedPrompts() {
|
||||
return queued.length > 0;
|
||||
},
|
||||
takeSubmittedPrompt: vi.fn(() => queued.shift()),
|
||||
returnSubmittedPrompt: vi.fn((prompt: ModQueuedPrompt) => {
|
||||
queued.unshift(prompt);
|
||||
}),
|
||||
notePersonPrompt: vi.fn(),
|
||||
stopSubmittedPrompts: vi.fn(() => {
|
||||
queued.length = 0;
|
||||
}),
|
||||
};
|
||||
vi.spyOn(interactiveMods, 'getInteractiveModSession').mockReturnValue(
|
||||
mods as unknown as ReturnType<
|
||||
typeof interactiveMods.getInteractiveModSession
|
||||
>,
|
||||
);
|
||||
// A hook queues a prompt, or the session says one waits.
|
||||
const queue = (...prompts: ModQueuedPrompt[]) =>
|
||||
act(() => {
|
||||
queued.push(...prompts);
|
||||
for (const listener of listeners) listener();
|
||||
});
|
||||
return { mods, turn, queued, queue };
|
||||
};
|
||||
const options = (call = 0) =>
|
||||
mockSendMessageStream.mock.calls[call][3] as Record<string, unknown>;
|
||||
const rows = () => mockAddItem.mock.calls.map(([item]) => item);
|
||||
|
||||
it('runs as a turn of its own once idle, framed, under a row naming the Mod', async () => {
|
||||
const { mods, turn, queue } = installQueue();
|
||||
admitted(finished);
|
||||
const logMessage = vi.fn().mockResolvedValue(undefined);
|
||||
renderTestHook([], undefined, undefined, undefined, {
|
||||
logMessage,
|
||||
} as unknown as NonNullable<Parameters<typeof useLlmStream>[20]>);
|
||||
const prompt = {
|
||||
plugin: 'lint',
|
||||
text: 'List the TODOs @a.txt',
|
||||
context: ['From a Mod'],
|
||||
};
|
||||
|
||||
await queue(prompt);
|
||||
|
||||
await waitFor(() => expect(turn.complete).toHaveBeenCalledOnce());
|
||||
expect(mockSendMessageStream).toHaveBeenCalledOnce();
|
||||
expect(mockSendMessageStream.mock.calls[0][0]).toBe(
|
||||
frameModPrompt(prompt),
|
||||
);
|
||||
expect(options()).toEqual(
|
||||
expect.objectContaining({
|
||||
type: SendMessageType.UserQuery,
|
||||
submittedPrompt: 'List the TODOs @a.txt',
|
||||
submittedByMod: 'lint',
|
||||
memoryRecallQuery: 'List the TODOs @a.txt',
|
||||
promptContext: ['From a Mod'],
|
||||
}),
|
||||
);
|
||||
expect(options()).not.toHaveProperty('submittedAsUser');
|
||||
expect(options()).not.toHaveProperty('workflowModeEligible');
|
||||
expect(options()).not.toHaveProperty('humanInput');
|
||||
expect(mods.startTurn.mock.calls[0][0]).toBe('List the TODOs @a.txt');
|
||||
// The row is the Mod's; nothing reads it as the person's prompt.
|
||||
expect(rows()).toContainEqual({
|
||||
type: 'notification',
|
||||
text: modPromptRow('lint', 'List the TODOs @a.txt'),
|
||||
});
|
||||
expect(rows().some((item) => item.type === MessageType.USER)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(mods.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(logMessage).not.toHaveBeenCalled();
|
||||
expect(handleAtCommandSpy).not.toHaveBeenCalled();
|
||||
expect(mods.notePersonPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a prompt submitted as the person’s words bare, and runs no command from it', async () => {
|
||||
const { queue } = installQueue();
|
||||
admitted(finished);
|
||||
renderTestHook();
|
||||
|
||||
await queue({ plugin: 'lint', text: '/clear', asUser: true });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockSendMessageStream).toHaveBeenCalledOnce(),
|
||||
);
|
||||
expect(mockSendMessageStream.mock.calls[0][0]).toBe('/clear');
|
||||
expect(options()).toEqual(
|
||||
expect.objectContaining({
|
||||
submittedByMod: 'lint',
|
||||
submittedAsUser: true,
|
||||
}),
|
||||
);
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
expect(rows()).toContainEqual({
|
||||
type: 'notification',
|
||||
text: modPromptRow('lint', '/clear'),
|
||||
});
|
||||
});
|
||||
|
||||
it('runs those queued one turn after another', async () => {
|
||||
const { mods } = installQueue(
|
||||
{ plugin: 'lint', text: 'one' },
|
||||
{ plugin: 'lint', text: 'two' },
|
||||
);
|
||||
admitted(finished);
|
||||
renderTestHook();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
expect(options(0)['submittedPrompt']).toBe('one');
|
||||
expect(options(1)['submittedPrompt']).toBe('two');
|
||||
expect(mods.returnSubmittedPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the person’s queued prompts and for a Goal that owns the turns', async () => {
|
||||
const { queue } = installQueue();
|
||||
admitted(finished);
|
||||
let queuedUserMessages = true;
|
||||
const goalQueueRef = {
|
||||
current: {
|
||||
hasQueuedUserMessages: vi.fn(() => queuedUserMessages),
|
||||
getPendingSubmissionCount: vi.fn(() => 0),
|
||||
},
|
||||
};
|
||||
let snapshot: unknown = { goal: null, activity: 'idle' };
|
||||
const goalListeners = new Set<() => void>();
|
||||
mockConfig.getGoalRuntime = vi.fn(() => ({
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener: () => void) => {
|
||||
goalListeners.add(listener);
|
||||
return () => goalListeners.delete(listener);
|
||||
},
|
||||
})) as unknown as Config['getGoalRuntime'];
|
||||
// The Goal changes with nothing queued since, as when its verifier
|
||||
// accepts after the session went idle.
|
||||
const broadcast = () =>
|
||||
act(() => {
|
||||
for (const listener of goalListeners) listener();
|
||||
});
|
||||
renderTestHook(
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
goalQueueRef as never,
|
||||
);
|
||||
|
||||
const settle = () =>
|
||||
act(async () => {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
await queue({ plugin: 'lint', text: 'later' });
|
||||
await settle();
|
||||
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
||||
queuedUserMessages = false;
|
||||
snapshot = { goal: { status: 'active' }, activity: 'idle' };
|
||||
broadcast();
|
||||
await settle();
|
||||
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
||||
snapshot = { goal: null, activity: 'idle' };
|
||||
broadcast();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockSendMessageStream).toHaveBeenCalledOnce(),
|
||||
);
|
||||
expect(options()['submittedPrompt']).toBe('later');
|
||||
});
|
||||
|
||||
it('lets the person’s prompt, not a Mod’s, reset the Mods’ turn count', async () => {
|
||||
const { mods } = installQueue({ plugin: 'lint', text: 'one' });
|
||||
admitted(finished);
|
||||
const { result } = renderTestHook();
|
||||
await waitFor(() =>
|
||||
expect(mockSendMessageStream).toHaveBeenCalledOnce(),
|
||||
);
|
||||
expect(mods.notePersonPrompt).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/help');
|
||||
});
|
||||
|
||||
expect(mods.notePersonPrompt).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('drops the queued prompts when the person cancels, not when a Mod ends the turn', async () => {
|
||||
const { mods, queue, queued } = installQueue();
|
||||
let release!: () => void;
|
||||
admitted(async function* () {
|
||||
yield { type: ServerLlmEventType.Content, value: 'Working' };
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
});
|
||||
const { result } = renderTestHook();
|
||||
let running!: Promise<void>;
|
||||
await act(async () => {
|
||||
running = result.current.submitQuery('Fix it');
|
||||
});
|
||||
await waitFor(() => expect(mods.startTurn).toHaveBeenCalledOnce());
|
||||
await queue({ plugin: 'lint', text: 'next' });
|
||||
|
||||
// `$.turn.abort` keeps them.
|
||||
act(() => expect(mods.startTurn.mock.calls[0][2]()).toBe(true));
|
||||
expect(mods.stopSubmittedPrompts).not.toHaveBeenCalled();
|
||||
release();
|
||||
await act(async () => {
|
||||
await running;
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
await waitFor(() => expect(mods.startTurn).toHaveBeenCalledTimes(2));
|
||||
|
||||
await queue({ plugin: 'lint', text: 'after' });
|
||||
act(() => {
|
||||
result.current.cancelOngoingRequest();
|
||||
});
|
||||
expect(mods.stopSubmittedPrompts).toHaveBeenCalledOnce();
|
||||
expect(queued).toEqual([]);
|
||||
release();
|
||||
});
|
||||
|
||||
it('drops the queued prompts when the person declines every tool the turn asked for', async () => {
|
||||
const { mods, queue, queued } = installQueue();
|
||||
admitted(async function* () {
|
||||
yield {
|
||||
type: ServerLlmEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'declined',
|
||||
name: 'testTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-declined',
|
||||
},
|
||||
};
|
||||
});
|
||||
const { result, rerenderWithToolCalls, completeToolRound } =
|
||||
renderTestHook();
|
||||
await act(async () => {
|
||||
await result.current.submitQuery(
|
||||
'run the tool',
|
||||
SendMessageType.UserQuery,
|
||||
'prompt-declined',
|
||||
);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(mockScheduleToolCalls).toHaveBeenCalledOnce(),
|
||||
);
|
||||
const request = {
|
||||
callId: 'declined',
|
||||
name: 'testTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-declined',
|
||||
};
|
||||
const invocation = {
|
||||
getDescription: () => 'Mock description',
|
||||
} as unknown as AnyToolInvocation;
|
||||
rerenderWithToolCalls([
|
||||
{
|
||||
request,
|
||||
status: 'awaiting_approval',
|
||||
responseSubmittedToLlm: false,
|
||||
confirmationDetails: {
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
message: 'Run it?',
|
||||
},
|
||||
tool: { name: 'testTool', displayName: 'testTool' },
|
||||
invocation,
|
||||
} as unknown as TrackedWaitingToolCall,
|
||||
]);
|
||||
await queue({ plugin: 'lint', text: 'next' });
|
||||
expect(mods.takeSubmittedPrompt).not.toHaveBeenCalled();
|
||||
|
||||
// The dialog takes the Esc, so no cancel runs: the declined batch
|
||||
// ends the turn.
|
||||
await completeToolRound([
|
||||
{
|
||||
request,
|
||||
status: 'cancelled',
|
||||
response: {
|
||||
callId: 'declined',
|
||||
responseParts: [{ text: 'cancelled' }],
|
||||
errorType: undefined,
|
||||
},
|
||||
responseSubmittedToLlm: false,
|
||||
tool: { displayName: 'testTool' },
|
||||
invocation,
|
||||
} as TrackedCancelledToolCall,
|
||||
]);
|
||||
|
||||
expect(mods.stopSubmittedPrompts).toHaveBeenCalledOnce();
|
||||
expect(queued).toEqual([]);
|
||||
rerenderWithToolCalls([]);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
expect(mockSendMessageStream).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('runs while a monitor update waits out its interval', async () => {
|
||||
const { queue } = installQueue();
|
||||
admitted(finished);
|
||||
vi.useFakeTimers();
|
||||
renderTestHook();
|
||||
const pulse = mockMonitorRegistry.setNotificationCallback.mock
|
||||
.calls[0][0] as (
|
||||
displayText: string,
|
||||
modelText: string,
|
||||
meta: { monitorId: string; status: string },
|
||||
) => void;
|
||||
const settle = () =>
|
||||
act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
await act(async () => {
|
||||
pulse('Monitor event #1', '<n>pulse-1</n>', {
|
||||
monitorId: 'mon_1',
|
||||
status: 'running',
|
||||
});
|
||||
});
|
||||
await settle();
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
pulse('Monitor event #2', '<n>pulse-2</n>', {
|
||||
monitorId: 'mon_1',
|
||||
status: 'running',
|
||||
});
|
||||
});
|
||||
await settle();
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The held pulse does not hold the Mod's prompt: a monitor that
|
||||
// prints within every interval would hold it for good.
|
||||
await queue({ plugin: 'lint', text: 'later' });
|
||||
await settle();
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(options(1)['submittedByMod']).toBe('lint');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
INTERIM_MONITOR_MIN_TURN_INTERVAL_MS,
|
||||
);
|
||||
});
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
JSON.stringify(mockSendMessageStream.mock.calls[2][0]),
|
||||
).toContain('pulse-2');
|
||||
});
|
||||
|
||||
it('waits for a notification that can run now', async () => {
|
||||
const { mods, queue } = installQueue();
|
||||
let release!: () => void;
|
||||
admitted(async function* () {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
yield* finished();
|
||||
});
|
||||
const { result } = renderTestHook();
|
||||
let running!: Promise<void>;
|
||||
await act(async () => {
|
||||
running = result.current.submitQuery('Fix it');
|
||||
});
|
||||
const pulse = mockMonitorRegistry.setNotificationCallback.mock
|
||||
.calls[0][0] as (
|
||||
displayText: string,
|
||||
modelText: string,
|
||||
meta: { monitorId: string; status: string },
|
||||
) => void;
|
||||
await act(async () => {
|
||||
pulse('Monitor done', '<n>done</n>', {
|
||||
monitorId: 'mon_1',
|
||||
status: 'completed',
|
||||
});
|
||||
});
|
||||
await queue({ plugin: 'lint', text: 'later' });
|
||||
admitted(finished);
|
||||
release();
|
||||
await act(async () => {
|
||||
await running;
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(3),
|
||||
);
|
||||
expect(options(1)['type']).toBe(SendMessageType.Notification);
|
||||
expect(options(2)['submittedByMod']).toBe('lint');
|
||||
// It lost the idle moment to the notification and waited for it.
|
||||
expect(mods.returnSubmittedPrompt).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('runs a prompt queued while a submission that starts no turn held the session', async () => {
|
||||
const { queue } = installQueue();
|
||||
admitted(finished);
|
||||
let finishCommand!: (result: unknown) => void;
|
||||
mockHandleSlashCommand.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishCommand = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderTestHook();
|
||||
let running!: Promise<void>;
|
||||
await act(async () => {
|
||||
running = result.current.submitQuery(
|
||||
'/lint-mod',
|
||||
SendMessageType.Cron,
|
||||
);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalled(),
|
||||
);
|
||||
// A cron's Mod command submits; the session stays idle throughout.
|
||||
await queue({ plugin: 'lint', text: 'from the command' });
|
||||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||
await act(async () => {
|
||||
finishCommand({ type: 'handled' });
|
||||
await running;
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockSendMessageStream).toHaveBeenCalledOnce(),
|
||||
);
|
||||
expect(options()['submittedByMod']).toBe('lint');
|
||||
});
|
||||
|
||||
it('tells the cancel handler the person stopped a Mod’s prompt, not their own', async () => {
|
||||
const { mods, queue } = installQueue();
|
||||
// A stream that never ends until the person stops it.
|
||||
admitted(async function* () {
|
||||
await new Promise(() => {});
|
||||
yield* finished();
|
||||
});
|
||||
const onCancelSubmit = vi.fn();
|
||||
const { result } = renderTestHook(
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
onCancelSubmit,
|
||||
);
|
||||
await queue({ plugin: 'lint', text: 'next' });
|
||||
await waitFor(() => expect(mods.startTurn).toHaveBeenCalledOnce());
|
||||
|
||||
act(() => {
|
||||
result.current.cancelOngoingRequest();
|
||||
});
|
||||
|
||||
expect(onCancelSubmit).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
lastTurnUserItem: null,
|
||||
wasModPromptTurn: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mark the person’s own cancelled turn as a Mod’s', async () => {
|
||||
installQueue();
|
||||
// A stream that never ends until the person stops it.
|
||||
admitted(async function* () {
|
||||
await new Promise(() => {});
|
||||
yield* finished();
|
||||
});
|
||||
const onCancelSubmit = vi.fn();
|
||||
const { result } = renderTestHook(
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
onCancelSubmit,
|
||||
);
|
||||
await act(async () => {
|
||||
void result.current.submitQuery('Fix it');
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current.streamingState).toBe(
|
||||
StreamingState.Responding,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.cancelOngoingRequest();
|
||||
});
|
||||
|
||||
expect(onCancelSubmit).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ wasModPromptTurn: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the sidecar the Mod’s text and its name, not the frame', async () => {
|
||||
const { queue } = installQueue();
|
||||
admitted(finished);
|
||||
mockUseDualOutput.mockReturnValue(mockDualOutput);
|
||||
renderTestHook();
|
||||
|
||||
await queue({ plugin: 'lint', text: 'List the TODOs' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockSendMessageStream).toHaveBeenCalledOnce(),
|
||||
);
|
||||
expect(
|
||||
mockDualOutput.emitUserMessage,
|
||||
).toHaveBeenCalledExactlyOnceWith([{ text: 'List the TODOs' }]);
|
||||
expect(
|
||||
mockDualOutput.emitSystemMessage,
|
||||
).toHaveBeenCalledExactlyOnceWith('mod_prompt', { mod: 'lint' });
|
||||
});
|
||||
|
||||
it('puts the prompt back when its turn cannot start', async () => {
|
||||
const { mods } = installQueue({ plugin: 'lint', text: 'one' });
|
||||
mods.ready = Promise.reject(new Error('no Mods'));
|
||||
mods.ready.catch(() => {});
|
||||
renderTestHook();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mods.returnSubmittedPrompt).toHaveBeenCalledExactlyOnceWith({
|
||||
plugin: 'lint',
|
||||
text: 'one',
|
||||
}),
|
||||
);
|
||||
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt.submit', () => {
|
||||
const finished = async function* () {
|
||||
yield {
|
||||
|
||||
@@ -106,6 +106,11 @@ import {
|
||||
MODS_FAILED_TO_INITIALIZE,
|
||||
} from '../../services/interactive-mod-session.js';
|
||||
import type { ModTurn } from '../../services/mod-session.js';
|
||||
import type { ModQueuedPrompt } from '@qwen-code/qwen-code-core/mods/types.js';
|
||||
import {
|
||||
frameModPrompt,
|
||||
modPromptRow,
|
||||
} from '../../services/mod-prompt-frame.js';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryItemWithoutId,
|
||||
@@ -575,6 +580,27 @@ const LOADING_THOUGHT_DESCRIPTION_MAX_CHARS = 4_096;
|
||||
*/
|
||||
export const INTERIM_MONITOR_MIN_TURN_INTERVAL_MS = 10_000;
|
||||
|
||||
/**
|
||||
* How long the notification batch at the front of `queue` still waits out
|
||||
* {@link INTERIM_MONITOR_MIN_TURN_INTERVAL_MS}, or 0 when it can run now.
|
||||
*/
|
||||
function interimMonitorWaitMs(
|
||||
queue: readonly QueuedNotification[],
|
||||
lastInterimMonitorTurnAt: number,
|
||||
): number {
|
||||
const leading = queue[0];
|
||||
if (
|
||||
leading?.sendMessageType !== SendMessageType.Notification ||
|
||||
leading.monitor?.status !== 'running'
|
||||
)
|
||||
return 0;
|
||||
return Math.max(
|
||||
0,
|
||||
INTERIM_MONITOR_MIN_TURN_INTERVAL_MS -
|
||||
(Date.now() - lastInterimMonitorTurnAt),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An overflow summary taken from the tally and awaiting a turn to carry it.
|
||||
* `displayed` mirrors the per-notification flag so a re-queued summary is not
|
||||
@@ -709,6 +735,13 @@ export interface CancelSubmitInfo {
|
||||
* strip that prompt so it can't merge into the user's next real message.
|
||||
*/
|
||||
wasGoalTurn: boolean;
|
||||
/**
|
||||
* True when the cancelled turn ran a prompt a Mod submitted with
|
||||
* `$.prompt.submit`. Like a Goal continuation it adds no UI user item, so
|
||||
* the handler strips its framed prompt: the person stopped it, and the next
|
||||
* prompt must not reach the model behind it.
|
||||
*/
|
||||
wasModPromptTurn: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -907,8 +940,15 @@ export const useLlmStream = (
|
||||
);
|
||||
const flushBufferedStreamEventsRef = useRef<Set<() => void>>(new Set());
|
||||
const turnCancelledRef = useRef(false);
|
||||
// Whether the turn running now (or the last one) runs a Mod's own prompt.
|
||||
const modPromptTurnRef = useRef(false);
|
||||
const isSubmittingQueryRef = useRef(false);
|
||||
const submissionLeaseGenerationRef = useRef(0);
|
||||
// Re-runs the drain of the Mods' prompts (`$.prompt.submit`). One queued
|
||||
// or put back while another submission held the session waits for that one
|
||||
// to settle: it may start no turn (a cron's command, a notification dropped
|
||||
// before sending), so the session can stay idle throughout.
|
||||
const [modPromptTrigger, setModPromptTrigger] = useState(0);
|
||||
const setSubmissionInFlight = useCallback(
|
||||
(inFlight: boolean) => {
|
||||
const changed = isSubmittingQueryRef.current !== inFlight;
|
||||
@@ -917,9 +957,11 @@ export const useLlmStream = (
|
||||
if (sharedRef) sharedRef.current = inFlight;
|
||||
if (changed && !inFlight) {
|
||||
goalQueueRef?.current?.onSubmissionSettled?.();
|
||||
if (getInteractiveModSession(config)?.hasSubmittedPrompts)
|
||||
setModPromptTrigger((n) => n + 1);
|
||||
}
|
||||
},
|
||||
[goalQueueRef],
|
||||
[config, goalQueueRef],
|
||||
);
|
||||
// Teammate message queue. Declared here (above `handleCompletedTools`)
|
||||
// because the tool-round boundary drains it (#8172): in a multi-round
|
||||
@@ -1658,6 +1700,10 @@ export const useLlmStream = (
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// The person stopped the session's work: the Mods' prompts still queued
|
||||
// do not start once it is idle, nor do those the stopped turn's hooks
|
||||
// submit. A Mod that ends a turn keeps them.
|
||||
if (!quiet) getInteractiveModSession(config)?.stopSubmittedPrompts();
|
||||
if (modTurnRef.current) {
|
||||
modTurnRef.current.ending = 'aborted';
|
||||
modTurnRef.current.cancel.abort();
|
||||
@@ -1785,6 +1831,7 @@ export const useLlmStream = (
|
||||
canUndoLastLoggedUserMessage: canUndoLastLoggedUserMessageRef.current,
|
||||
turnProducedMeaningfulContent: turnSawContentEventRef.current,
|
||||
wasGoalTurn: activeGoalTurnRef.current !== null,
|
||||
wasModPromptTurn: modPromptTurnRef.current,
|
||||
});
|
||||
} finally {
|
||||
setIsResponding(false);
|
||||
@@ -1916,6 +1963,7 @@ export const useLlmStream = (
|
||||
pasteExpansions?: ReadonlyMap<string, string>,
|
||||
hasUnknownInput?: boolean,
|
||||
isCurrentSubmission?: () => boolean,
|
||||
modPrompt?: ModQueuedPrompt,
|
||||
): Promise<{
|
||||
queryToSend: PartListUnion | null;
|
||||
shouldProceed: boolean;
|
||||
@@ -1959,6 +2007,20 @@ export const useLlmStream = (
|
||||
return { queryToSend: trimmedQuery, shouldProceed: true };
|
||||
}
|
||||
|
||||
// A Mod's own prompt already passed the Mods' `prompt.submit` hooks,
|
||||
// and the person did not type it: it is not recalled, reads no `@`
|
||||
// reference and runs no command, and its row names the Mod.
|
||||
if (modPrompt) {
|
||||
addItem(
|
||||
{
|
||||
type: 'notification',
|
||||
text: modPromptRow(modPrompt.plugin, modPrompt.text),
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
return { queryToSend: trimmedQuery, shouldProceed: true };
|
||||
}
|
||||
|
||||
// Teammate envelopes are model-authored text already rendered
|
||||
// as a `● …` notification by the teammate drain. They must NOT
|
||||
// enter the slash/shell/@ preprocessing below: with `!` shell
|
||||
@@ -4190,10 +4252,17 @@ export const useLlmStream = (
|
||||
userAdmission?: DirectUserAdmission;
|
||||
goalBinding?: GoalTurnBinding;
|
||||
toolContinuationOwner?: ToolContinuationOwner;
|
||||
/** A prompt a Mod submitted, which `query` frames. */
|
||||
modPrompt?: ModQueuedPrompt;
|
||||
},
|
||||
) => {
|
||||
const mods = getInteractiveModSession(config);
|
||||
const modPrompt =
|
||||
submitType === SendMessageType.UserQuery
|
||||
? metadata?.modPrompt
|
||||
: undefined;
|
||||
const dispatchesSlashCommand =
|
||||
!modPrompt &&
|
||||
(submitType === SendMessageType.UserQuery ||
|
||||
submitType === SendMessageType.Cron) &&
|
||||
typeof query === 'string' &&
|
||||
@@ -4280,6 +4349,11 @@ export const useLlmStream = (
|
||||
return;
|
||||
}
|
||||
|
||||
// The person's own prompt, a slash command too, lets the Mods' prompts
|
||||
// start turns again.
|
||||
if (submitType === SendMessageType.UserQuery && !modPrompt)
|
||||
mods?.notePersonPrompt();
|
||||
|
||||
// Set the flag to indicate we're now executing
|
||||
acquireSubmissionLease();
|
||||
const submissionGeneration = submissionLeaseGenerationRef.current;
|
||||
@@ -4307,6 +4381,7 @@ export const useLlmStream = (
|
||||
// turn that already owns its own snapshot.
|
||||
if (!isTurnContinuation && !allowConcurrentBtwDuringResponse) {
|
||||
lastTurnUserItemRef.current = null;
|
||||
modPromptTurnRef.current = modPrompt !== undefined;
|
||||
canUndoLastLoggedUserMessageRef.current = false;
|
||||
turnSawContentEventRef.current = false;
|
||||
handledToolCallFingerprintsRef.current.clear();
|
||||
@@ -4509,6 +4584,7 @@ export const useLlmStream = (
|
||||
submissionGeneration ===
|
||||
submissionLeaseGenerationRef.current &&
|
||||
activeModelStreamsRef.current === 0,
|
||||
modPrompt,
|
||||
);
|
||||
} catch (error) {
|
||||
await releaseUndeliveredGoalTurn(metadata?.userAdmission?.turnKey);
|
||||
@@ -4522,7 +4598,9 @@ export const useLlmStream = (
|
||||
// here on: what the transcript shows, what `turn.start` hears and
|
||||
// what workflow keywords are read from.
|
||||
const promptRewrite = preparedQuery.promptSubmitted?.rewritten;
|
||||
const promptContext = preparedQuery.promptSubmitted?.context;
|
||||
const promptContext =
|
||||
preparedQuery.promptSubmitted?.context ??
|
||||
(modPrompt?.context?.length ? modPrompt.context : undefined);
|
||||
const sentPrompt = promptRewrite?.display ?? submittedPrompt;
|
||||
const typedInput =
|
||||
promptRewrite === undefined || !metadata?.humanInput
|
||||
@@ -4737,21 +4815,31 @@ export const useLlmStream = (
|
||||
submitType !== SendMessageType.ToolResult &&
|
||||
submitType !== SendMessageType.Goal
|
||||
) {
|
||||
const rawParts =
|
||||
typeof finalQueryToSend === 'string'
|
||||
? [finalQueryToSend]
|
||||
: Array.isArray(finalQueryToSend)
|
||||
? finalQueryToSend
|
||||
: [finalQueryToSend];
|
||||
const userParts: Part[] = rawParts.map((p) =>
|
||||
typeof p === 'string' ? { text: p } : p,
|
||||
);
|
||||
dualOutput.emitUserMessage(userParts);
|
||||
// A Mod's prompt reaches the sidecar as in a headless run: its
|
||||
// text, not the frame the model reads, then who submitted it.
|
||||
if (modPrompt) {
|
||||
dualOutput.emitUserMessage([{ text: modPrompt.text }]);
|
||||
dualOutput.emitSystemMessage('mod_prompt', {
|
||||
mod: modPrompt.plugin,
|
||||
});
|
||||
} else {
|
||||
const rawParts =
|
||||
typeof finalQueryToSend === 'string'
|
||||
? [finalQueryToSend]
|
||||
: Array.isArray(finalQueryToSend)
|
||||
? finalQueryToSend
|
||||
: [finalQueryToSend];
|
||||
const userParts: Part[] = rawParts.map((p) =>
|
||||
typeof p === 'string' ? { text: p } : p,
|
||||
);
|
||||
dualOutput.emitUserMessage(userParts);
|
||||
}
|
||||
}
|
||||
|
||||
const workflowSessionId = config.getSessionId();
|
||||
const workflowModeEligible =
|
||||
submitType === SendMessageType.UserQuery &&
|
||||
!modPrompt &&
|
||||
!allowConcurrentBtwDuringResponse &&
|
||||
!isDetachedToolContinuation &&
|
||||
!(metadata?.shellMode ?? shellModeActive) &&
|
||||
@@ -4807,6 +4895,14 @@ export const useLlmStream = (
|
||||
? { submittedPrompt: sentPrompt }
|
||||
: {}),
|
||||
...(promptContext ? { promptContext } : {}),
|
||||
...(modPrompt
|
||||
? {
|
||||
submittedByMod: modPrompt.plugin,
|
||||
...(modPrompt.asUser && { submittedAsUser: true as const }),
|
||||
// Recall looks for what the Mod asked, not the frame.
|
||||
memoryRecallQuery: modPrompt.text,
|
||||
}
|
||||
: {}),
|
||||
...(humanInput === undefined
|
||||
? {}
|
||||
: {
|
||||
@@ -6671,6 +6767,10 @@ export const useLlmStream = (
|
||||
(toolCall) => toolCall.request.callId,
|
||||
);
|
||||
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
|
||||
// The person declined every tool the turn asked for, which ends it as
|
||||
// Esc does; Esc and a Mod's `$.turn.abort` already set the cancel.
|
||||
if (!turnCancelledRef.current)
|
||||
getInteractiveModSession(config)?.stopSubmittedPrompts();
|
||||
if (toolGoalBinding) {
|
||||
// Every cancellation that reaches here originates in a user action:
|
||||
// either Esc through `cancelOngoingRequest`, or a declined tool
|
||||
@@ -7895,22 +7995,19 @@ export const useLlmStream = (
|
||||
// interim (status 'running') monitor-led batches; terminal notifications
|
||||
// and cron fires stay prompt. Checking queue[0] before the cancelled-
|
||||
// monitor prune inside is conservative in the right direction.
|
||||
const leading = notificationQueueRef.current[0]!;
|
||||
if (
|
||||
leading.sendMessageType === SendMessageType.Notification &&
|
||||
leading.monitor?.status === 'running'
|
||||
) {
|
||||
const elapsed = Date.now() - lastInterimMonitorTurnAtRef.current;
|
||||
if (elapsed < INTERIM_MONITOR_MIN_TURN_INTERVAL_MS) {
|
||||
// Re-fire this effect when the window elapses so queued pulses
|
||||
// still batch into a single catch-up turn even if the monitor
|
||||
// goes quiet in the meantime.
|
||||
const timer = setTimeout(
|
||||
() => setNotificationTrigger((n) => n + 1),
|
||||
INTERIM_MONITOR_MIN_TURN_INTERVAL_MS - elapsed,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
const waitMs = interimMonitorWaitMs(
|
||||
notificationQueueRef.current,
|
||||
lastInterimMonitorTurnAtRef.current,
|
||||
);
|
||||
if (waitMs > 0) {
|
||||
// Re-fire this effect when the window elapses so queued pulses
|
||||
// still batch into a single catch-up turn even if the monitor
|
||||
// goes quiet in the meantime.
|
||||
const timer = setTimeout(
|
||||
() => setNotificationTrigger((n) => n + 1),
|
||||
waitMs,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
// Consumer-side guard for #7156: this effect can run on a render pass
|
||||
@@ -8095,6 +8192,88 @@ export const useLlmStream = (
|
||||
goalQueuePendingCount,
|
||||
]);
|
||||
|
||||
// ─── A Mod's own prompts (`$.prompt.submit`) ───────────────
|
||||
// Each runs as a turn of its own once the session is idle, after the
|
||||
// person's queued prompts and the notifications that can run now, and not
|
||||
// while a Goal owns the session's turns: a Mod's prompt is neither the
|
||||
// person's words nor a turn the Goal asked for. A monitor update waiting
|
||||
// out its interval does not hold it, or a busy monitor would hold it for
|
||||
// as long as it runs.
|
||||
useEffect(
|
||||
() =>
|
||||
getInteractiveModSession(config)?.subscribePrompts(() =>
|
||||
setModPromptTrigger((n) => n + 1),
|
||||
),
|
||||
[config],
|
||||
);
|
||||
// A Goal lets go of the session's turns on its own schedule, after the
|
||||
// session is already idle (its verifier accepts, its last turn is
|
||||
// released), so the drain hears the Goal too.
|
||||
useEffect(() => {
|
||||
try {
|
||||
return config.getGoalRuntime().subscribe(() => {
|
||||
if (getInteractiveModSession(config)?.hasSubmittedPrompts)
|
||||
setModPromptTrigger((n) => n + 1);
|
||||
});
|
||||
} catch {
|
||||
// Goal persistence can be disabled for this session.
|
||||
return undefined;
|
||||
}
|
||||
}, [config, sessionStates.sessionId]);
|
||||
useEffect(() => {
|
||||
const mods = getInteractiveModSession(config);
|
||||
if (
|
||||
streamingState !== StreamingState.Idle ||
|
||||
isSubmittingQueryRef.current ||
|
||||
(notificationQueueRef.current.length > 0 &&
|
||||
interimMonitorWaitMs(
|
||||
notificationQueueRef.current,
|
||||
lastInterimMonitorTurnAtRef.current,
|
||||
) === 0) ||
|
||||
!mods?.hasSubmittedPrompts ||
|
||||
goalQueueRef?.current?.hasQueuedUserMessages?.() ||
|
||||
goalQueuePendingCount > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (goalRequiresExactPermit(config.getGoalRuntime().getSnapshot()))
|
||||
return;
|
||||
} catch {
|
||||
// Goal runtime is optional during early initialization.
|
||||
}
|
||||
runOutsideAgentContext(() => {
|
||||
const prompt = mods.takeSubmittedPrompt();
|
||||
if (!prompt) return;
|
||||
void submitQuery(
|
||||
frameModPrompt(prompt),
|
||||
SendMessageType.UserQuery,
|
||||
undefined,
|
||||
{
|
||||
modPrompt: prompt,
|
||||
submittedPrompt: prompt.text,
|
||||
shellMode: false,
|
||||
// Another submission won this idle moment; the prompt waits for
|
||||
// it to settle. When the turn could not start for a failure of
|
||||
// its own (the Mods failed to start, the prompt failed to
|
||||
// prepare) the prompt also waits for the next settle rather than
|
||||
// retrying at once, which would fail the same way in a loop.
|
||||
onAdmissionFailed: () => mods.returnSubmittedPrompt(prompt),
|
||||
},
|
||||
).catch((error) => {
|
||||
debugLogger.warn("Failed to start a Mod's prompt", error);
|
||||
});
|
||||
});
|
||||
}, [
|
||||
streamingState,
|
||||
submitQuery,
|
||||
modPromptTrigger,
|
||||
notificationTrigger,
|
||||
config,
|
||||
goalQueueRef,
|
||||
goalQueuePendingCount,
|
||||
]);
|
||||
|
||||
// ─── Teammate message integration ─────────────────────────
|
||||
// The queue state (`teammateQueueRef` / `teammateTrigger`) is declared
|
||||
// near the top of the hook so `handleCompletedTools` can drain it at
|
||||
|
||||
@@ -754,6 +754,51 @@ describe('resumeHistoryUtils', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('restores a Mod’s own prompt as the row naming the Mod, asUser or not', () => {
|
||||
const framed = (text: string) => ({
|
||||
parts: [
|
||||
{ text: `<qwen:mod-prompt mod="lint">\n${text}\n</qwen:mod-prompt>` },
|
||||
],
|
||||
});
|
||||
const conversation = {
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
provenance: 'mod_prompt',
|
||||
promptId: 'prompt-2',
|
||||
message: framed('List the TODOs'),
|
||||
systemPayload: {
|
||||
displayText: 'List the TODOs',
|
||||
hookContext: '',
|
||||
mod: 'lint',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
provenance: 'mod_prompt',
|
||||
message: { parts: [{ text: 'Say done' }] },
|
||||
systemPayload: {
|
||||
displayText: 'Say done',
|
||||
hookContext: '',
|
||||
mod: 'lint',
|
||||
asUser: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as ConversationRecord;
|
||||
|
||||
const items = buildResumedHistoryItems(
|
||||
{ conversation } as ResumedSessionData,
|
||||
makeConfig({}),
|
||||
30,
|
||||
);
|
||||
|
||||
expect(items).toEqual([
|
||||
{ id: 31, type: 'notification', text: 'Mod lint: List the TODOs' },
|
||||
{ id: 32, type: 'notification', text: 'Mod lint: Say done' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('restores ordinary user messages from clean display text', () => {
|
||||
const conversation = {
|
||||
messages: [
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
indexGapsByChild,
|
||||
} from './history-gap-notice.js';
|
||||
import { coalesceFindingsHistoryItems } from './findings-coalescing.js';
|
||||
import { modPromptRow } from '../../services/mod-prompt-frame.js';
|
||||
import { shouldDisplayGoalStateCause } from './goal-runtime.js';
|
||||
import {
|
||||
collectInlineImages,
|
||||
@@ -418,6 +419,24 @@ function convertToHistoryItems(
|
||||
}
|
||||
break;
|
||||
}
|
||||
// A Mod's own prompt restores as the row the terminal showed for it.
|
||||
if (record.provenance === 'mod_prompt') {
|
||||
const payload = record.systemPayload as { mod?: string } | undefined;
|
||||
const text =
|
||||
projectUserTranscriptForDisplay(record).displayText ||
|
||||
extractTextFromParts(record.message?.parts as Part[]);
|
||||
if (text) {
|
||||
if (currentToolGroup.length > 0) {
|
||||
items.push({ type: 'tool_group', tools: [...currentToolGroup] });
|
||||
currentToolGroup = [];
|
||||
}
|
||||
items.push({
|
||||
type: 'notification',
|
||||
text: modPromptRow(payload?.mod ?? 'unknown', text),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (record.subtype === 'mid_turn_user_message') {
|
||||
const payload = record.systemPayload as
|
||||
| { displayText?: string; attachmentReferences?: unknown[] }
|
||||
|
||||
@@ -240,6 +240,7 @@ describe('no-AK integration CI wiring', () => {
|
||||
'./interactive/workflow-intent.test.ts',
|
||||
'./interactive/workflow-mode.test.ts',
|
||||
'./interactive/mods-tool-middleware.test.ts',
|
||||
'./interactive/mods-prompt-submit.test.ts',
|
||||
'./interactive/statusline-parity.test.ts',
|
||||
'./interactive/tui-editor-parity.test.ts',
|
||||
'./interactive/tui-input-ownership.test.ts',
|
||||
|
||||
Reference in new issue
Block a user