Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .qwen/skills/triage/references/pr-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ gh pr review "$PR_NUMBER" --repo "$REPO" --request-changes --body "Needs some re
```

Genuinely unsure, or `GUARD` blocked approval — **don't approve or reject**, but **never defer silently**. Post an explicit defer comment that:

1. States you are escalating to the maintainer.
2. Names the specific reason(s) for uncertainty — what you cannot resolve from the diff, tests, and PR description.
3. @mentions the maintainer (use `$QWEN_MAINTAINER_HANDLE` if set, or the most recent human reviewer).
Expand Down
29 changes: 29 additions & 0 deletions docs/users/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ Hooks fire at specific points during a Qwen Code session. Different events suppo
| `UserPromptSubmit` | After user submits prompt | None (always fires) |
| `SessionStart` | When session starts or resumes | Source (`startup`, `resume`, `clear`, `compact`) |
| `SessionEnd` | When session ends | Reason (`clear`, `logout`, `prompt_input_exit`, etc.) |
| `MessageDisplay` | Repeatedly, as the reply streams | None (always fires) |
| `Stop` | When Claude prepares to conclude response | None (always fires) |
| `SubagentStart` | When subagent starts | Agent type (`Bash`, `Explorer`, `Plan`, etc.) |
| `SubagentStop` | When subagent stops | Agent type |
Expand All @@ -268,6 +269,7 @@ Hooks fire at specific points during a Qwen Code session. Different events suppo
| Todo Events | `TodoCreated`, `TodoCompleted` | ❌ No | N/A |
| Prompt Events | `UserPromptSubmit` | ❌ No | N/A |
| Stop Events | `Stop` | ❌ No | N/A |
| Message Display | `MessageDisplay` | ❌ No | N/A |

**Matcher Syntax:**

Expand Down Expand Up @@ -565,6 +567,33 @@ The `permissionDecision` value controls whether the tool runs:

- Standard hook output fields (typically not used for blocking)

#### MessageDisplay

**Purpose**: Fires repeatedly as the assistant's reply streams — before `Stop`, which fires once at the end of the turn. Useful for live narration, incremental logging, or any consumer that wants to react to the reply as it's written rather than after the fact. This is a **fire-and-forget** event - hook output and exit codes are ignored.

**Event-specific fields**:

```json
{
"message_id": "stable id for the whole streamed message",
"displayed_text": "the CUMULATIVE text streamed so far for this message (not a delta)",
"is_final": "true on the last firing for this message, false otherwise"
}
```

`displayed_text` is cumulative rather than a delta so hook scripts never need to reassemble chunks themselves — each firing carries the full text so far. Firing is debounced (at most every ~200ms) except for the final firing (`is_final: true`), which always fires once the message ends, so the reply's tail is never dropped waiting on the debounce window.

**Delivery semantics** — what a hook script can rely on:

- **Slow hooks see fewer, newer payloads.** At most one mid-stream hook execution per message is in flight at a time; while one runs, newer debounced payloads _replace_ the queued one rather than piling up behind it. A hook slower than the debounce window therefore skips intermediate snapshots — lossless, since each payload carries the full cumulative text.
- **`is_final` is never queued behind a stale delivery.** The final payload is dispatched the moment the message ends — alongside a still-running mid-stream execution if there is one (the one exception to the one-at-a-time rule, justified the same way: the final cumulative text strictly supersedes whatever that execution is processing). Your hook always receives the `is_final` payload, and receives it before the `Stop` hook fires. One consequence for stateful hooks: when the final execution overlaps a superseded mid-stream one, their _completion_ order is unspecified — the stale execution may finish after the final one (even after `Stop`). Treat `is_final` as terminal per `message_id` and let the cumulative text win, rather than assuming the last execution to finish carries the newest state.
- **The turn waits for `is_final` delivery to complete — but not forever.** The turn's end (and the `Stop` hook, when it fires) waits up to 5 seconds for the final delivery to finish. A hook that completes within that budget keeps the strongest guarantee: a headless run (`qwen -p ...`) exits only after the hook finished, and the `is_final` execution completes before `Stop` starts. A slower hook still receives `is_final` first — only the wait for its completion is bounded: in the terminal UI or an ACP session the execution simply finishes in the background, while a headless run exits without waiting. The hook process is not killed on exit; it is left to finish on its own, so a script chaining `qwen -p … && next-step` can observe `next-step` starting while a slow hook is still running. Hitting this timeout prints a warning on stderr.
- **Cancellation behaviour depends on timing.** A turn cancelled _before `is_final` dispatches_ fires no `is_final` — the message is treated as abandoned, and a consumer that buffers until `is_final` should treat cancellation-silence as its flush/discard signal (e.g. a timeout fallback). The criterion is the abort signal's state at the moment the turn ends, not whether every chunk had already streamed — an abort landing in the brief gap before that check can still suppress `is_final` for a message whose text had, in practice, finished arriving. Cancelling _after `is_final` has dispatched_ (during the drain wait) is different: the still-running hook execution may be terminated mid-flight (SIGTERM), but the payload itself has already been delivered.
- **`displayed_text` is provisional until `is_final`.** It reflects what has streamed so far; treat intermediate payloads as display state, not as authoritative final content.
- **A tool-using turn produces multiple messages.** Each model call gets its own `message_id` with its own `is_final: true` firing: the text before a tool call is one message, the continuation after the tool result is another. Model calls that produce no displayed text (tool-call-only) fire nothing.

**Note**: Fires in the terminal UI, headless (`-p`), and ACP (IDE/editor/`qwen serve`) sessions, with the same payload contract on every surface.

#### Stop

**Purpose**: Executed before Qwen concludes its response to provide final feedback or summaries.
Expand Down
3 changes: 3 additions & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,9 @@ export const IDLE_HOOK_EVENTS: Record<HookEventName, ServeHookEventMeta> = {
description: 'When a new session is started',
matcherKind: 'sessionTrigger',
},
MessageDisplay: {
description: 'Repeatedly, as the assistant reply streams',
},
Stop: { description: 'Right before Qwen Code concludes its response' },
SubagentStart: {
description: 'When a subagent is started',
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
StopFailure: 'StopFailure',
TodoCreated: 'TodoCreated',
TodoCompleted: 'TodoCompleted',
MessageDisplay: 'MessageDisplay',
InstructionsLoaded: 'InstructionsLoaded',
},
buildInstallPlan: vi.fn((provider, inputs) => {
const authType = inputs.protocol ?? provider.protocol;
Expand Down
Loading
Loading