Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
25ac5c6
feat(core): add run_in_background support for Agent tool
tanzhenxin Apr 10, 2026
a60328d
feat(background): improve notification formatting and UI handling
tanzhenxin Apr 10, 2026
9f836e1
fix(background): reject run_in_background in non-interactive mode
tanzhenxin Apr 10, 2026
f5067bc
refactor(background): replace prefix/separator protocol with typed no…
tanzhenxin Apr 10, 2026
bcce78d
refactor(background): move abortAll to Config.shutdown
tanzhenxin Apr 10, 2026
83f23fd
fix(background): persist notification items for session resume
tanzhenxin Apr 10, 2026
3647889
fix(background): replace YOLO with deny-by-default for background agents
tanzhenxin Apr 10, 2026
8587577
fix(background): add missing getBackgroundTaskRegistry mock in useGem…
tanzhenxin Apr 11, 2026
d032d4e
Merge remote-tracking branch 'origin/main' into feat/background-subagent
tanzhenxin Apr 11, 2026
37ce6e9
refactor(core): move fork subagent params from execute() to construct…
tanzhenxin Apr 14, 2026
bf0ff7c
refactor(core): move agent tool files into dedicated tools/agent/ dir…
tanzhenxin Apr 15, 2026
ab9ab01
refactor(core): remove dead temp and top_p fields from ModelConfig
tanzhenxin Apr 15, 2026
e1e6885
refactor(core): read parent generation config directly instead of get…
tanzhenxin Apr 15, 2026
268fc11
fix(core): prevent duplicate tool declarations when toolConfig has on…
tanzhenxin Apr 16, 2026
84c653b
Merge remote-tracking branch 'origin/main' into refactor/fork-subagen…
tanzhenxin Apr 16, 2026
aba07b8
Merge remote-tracking branch 'origin/main' into feat/background-subagent
tanzhenxin Apr 16, 2026
e02b56a
Merge remote-tracking branch 'origin/refactor/fork-subagent-construct…
tanzhenxin Apr 16, 2026
7c7d7c7
feat(core): support agent-level `background: true` in frontmatter
tanzhenxin Apr 16, 2026
37d1924
fix(core): address background subagent lifecycle gaps
tanzhenxin Apr 17, 2026
0da1182
feat(cli): headless support and SDK task events for background agents…
tanzhenxin Apr 17, 2026
722c342
Merge remote-tracking branch 'origin/main' into feat/background-subagent
tanzhenxin Apr 17, 2026
d3b60d6
fix: address codex review comments for background subagents
tanzhenxin Apr 17, 2026
de5b5ea
test(cli): sync JsonOutputAdapter text-mode assertions with trailing …
tanzhenxin Apr 17, 2026
5d88ba9
refactor: simplify background subagent plumbing
tanzhenxin Apr 17, 2026
610d3aa
fix: address codex review comments for background subagents
tanzhenxin Apr 17, 2026
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
40 changes: 34 additions & 6 deletions .qwen/skills/structured-debugging/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: structured-debugging
description:
description: >
Hypothesis-driven debugging methodology for hard bugs. Use this skill whenever
you're investigating non-trivial bugs, unexpected behavior, flaky tests, or
tracing issues through complex systems. Activate proactively when debugging
Expand Down Expand Up @@ -32,11 +32,9 @@ Good: "The leader hangs because `hasActiveTeammates()` returns true after all ag
have reported completed, likely because terminal status isn't being set on the agent
object after the backend process exits."

Create a side note file for the investigation:

```
~/.qwen/investigations/<project>-<issue>.md
```
For bugs you expect to take more than one round, create a side note file
for the investigation in whichever location the project uses for such
notes.

Write your hypothesis there. This file persists across conversation turns and even
across sessions — it's your investigation journal.
Expand All @@ -49,6 +47,12 @@ confirm or reject your hypothesis. Think about what data you need to see.
Don't scatter `console.log` everywhere. Identify the 2-3 places where your
hypothesis makes a testable prediction, and instrument those.

Prefer logging _values_ (return codes, payload contents, stream types,
message bodies, env state) over _presence checks_ ("was this function
called?", "was this branch taken?"). Code-path traces tell you what ran;
data traces tell you what it ran on. Most non-trivial bugs are correct
code processing wrong data.

Ask yourself: "If my hypothesis is correct, what will I see at point X?
If it's wrong, what will I see instead?"

Expand Down Expand Up @@ -129,6 +133,23 @@ is still broken if the inbox contains stale messages from a previous run.
Always inspect the _content_ flowing through the code, not just whether the code
runs. Check payloads, message contents, file data, and database state.

### Reframing the user's report instead of investigating it

When the user reports a symptom your own run doesn't reproduce, the
contradiction _is_ the evidence — the two environments differ in some way
you haven't identified yet. The wrong move is to reframe their report
("they must be on a stale SHA", "they must be confused about what they
saw", "must be a flake") so that your run becomes the ground truth. Once
you do that, every later piece of evidence gets bent to defend the
reframing, and the actual bug stays hidden.

The right move: catalogue what differs between their environment and
yours (TTY vs pipe, terminal emulator, shell, locale, env vars, prior
state, build artifacts) before forming any hypothesis. For ambiguous
symptoms ("no output", "it's slow", "it's wrong") ask one disambiguating
question first — e.g., "does it hang or exit cleanly?" — that prunes the
hypothesis space cheaply before any test run.

### Losing context across attempts

After several debugging rounds, you start forgetting what you already tried and
Expand Down Expand Up @@ -164,3 +185,10 @@ Fix: [what you're changing and why it addresses the root cause]
```

Then apply the fix, remove instrumentation, and verify with a clean run.

## Worked examples

- [`examples/headless-bg-agent-empty-stdout.md`](examples/headless-bg-agent-empty-stdout.md)
— pipe-captured runs all passed; the user's TTY printed nothing. The
contradiction _was_ the bug. Illustrates _reproduction contradiction is
data_ and _instrument data, not code paths_.
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Worked example: headless run prints empty stdout in zsh TTY

A short qwen-code case to illustrate two failure modes from `SKILL.md`:
_reproduction contradiction is data_, and _instrument the data flow, not
just the code path_.

## The bug

User: `npm run dev -- -p "..."` in zsh prints nothing. Process exits clean,
`~/.qwen/logs` shows the model returned proper text. Stdout was empty.

Cause: `JsonOutputAdapter.emitResult` wrote `resultMessage.result` without
a trailing `\n`. zsh's `PROMPT_SP` (powerlevel10k, agnoster, …) detects
the missing newline and emits `\r\033[K` before drawing the next prompt,
erasing the line. Pipe-captured stdout has no `PROMPT_SP`, so the bug is
invisible there.

Fix: append `\n` to the write.

## What made the case instructive

Every reproduction attempt from a debugging environment that captures
stdout (Cursor's Shell tool, `out=$(...)`, `tee`, file redirect) **passed**.
14/14 success against the user's 0/N. Same SHA, same machine, same
command. The only variable was: pipe stdout vs TTY stdout.

That contradiction was the entire investigation. Once it was named, the
fix was one line.

## Lessons mapped to SKILL.md

- **Reproduction contradiction is data, not user error.** When your run
succeeds and the user's fails on identical state, the _difference
between the two environments_ is where the bug lives. Catalogue what
differs (TTY vs pipe, terminal emulator, shell, locale, env vars,
prior state) before forming any hypothesis. Reframing the user's
report ("they must be on stale code") burns rounds and credibility.

- **Ask the one disambiguating question first.** "Does it hang or exit
cleanly?" would have falsified the most tempting wrong hypothesis here
(the recently-fixed drain-loop hang) on turn one. For any "no output"
report, that question is free and prunes half the hypothesis space.

- **Instrument the data flow, not just the code path.** Tracing whether
`write` was called showed the happy path firing every time and resolved
nothing. The breakthrough was logging the _return value_ of
`process.stdout.write` together with `process.stdout.isTTY`. Code-path
traces tell you what ran; data traces tell you what it ran on.

- **Pipe ≠ TTY.** A passing pipe-captured run does not prove a TTY user
sees the same output. Shell prompts can post-process trailing-newline-
less writes; terminals can swallow control sequences; pipes do
neither. When debugging interactive-shell symptoms, get evidence from
the user's actual terminal at least once.

## Reference

Fix commit: qwen-code `feadf052f` —
`fix(cli): append newline to text-mode emitResult so zsh PROMPT_SP doesn't erase the line`
21 changes: 20 additions & 1 deletion packages/cli/src/acp-integration/session/HistoryReplayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type { ChatRecord, AgentResultDisplay } from '@qwen-code/qwen-code-core';
import type {
ChatRecord,
AgentResultDisplay,
NotificationRecordPayload,
} from '@qwen-code/qwen-code-core';
import type {
Content,
GenerateContentResponseUsageMetadata,
Expand Down Expand Up @@ -49,6 +53,21 @@ export class HistoryReplayer {
this.setActiveRecordId(record.uuid, record.timestamp);
switch (record.type) {
case 'user':
// Notification/cron records hold raw XML/prompt the user never
// typed; replay the friendly displayText so the assistant's reply
// has an antecedent in the ACP transcript.
if (record.subtype === 'notification' || record.subtype === 'cron') {
const displayText = (
record.systemPayload as NotificationRecordPayload | undefined
)?.displayText;
if (displayText) {
await this.messageEmitter.emitUserMessage(
displayText,
record.timestamp,
);
}
break;
}
if (record.message) {
await this.replayContent(record.message, 'user', record.timestamp);
}
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ describe('JsonOutputAdapter', () => {

expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
expect(output).toBe('Response text');
expect(output).toBe('Response text\n');
});

it('should emit error result to stderr in text mode', () => {
Expand All @@ -447,7 +447,7 @@ describe('JsonOutputAdapter', () => {

expect(stderrWriteSpy).toHaveBeenCalled();
const output = stderrWriteSpy.mock.calls[0][0] as string;
expect(output).toBe('Test error message');
expect(output).toBe('Test error message\n');

stderrWriteSpy.mockRestore();
});
Expand All @@ -465,7 +465,7 @@ describe('JsonOutputAdapter', () => {

expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
expect(output).toBe('Custom summary text');
expect(output).toBe('Custom summary text\n');
});

it('should handle empty error message in text mode', () => {
Expand All @@ -484,7 +484,7 @@ describe('JsonOutputAdapter', () => {
expect(stderrWriteSpy).toHaveBeenCalled();
const output = stderrWriteSpy.mock.calls[0][0] as string;
// When no errorMessage is provided, the default 'Unknown error' is used
expect(output).toBe('Unknown error');
expect(output).toBe('Unknown error\n');

stderrWriteSpy.mockRestore();
});
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ export class JsonOutputAdapter

if (this.config.getOutputFormat() === 'text') {
if (resultMessage.is_error) {
process.stderr.write(`${resultMessage.error?.message || ''}`);
process.stderr.write(`${resultMessage.error?.message || ''}\n`);
} else {
process.stdout.write(`${resultMessage.result}`);
process.stdout.write(`${resultMessage.result}\n`);
}
} else {
// Emit the entire messages array as JSON (includes all main agent + subagent messages)
Expand Down
27 changes: 16 additions & 11 deletions packages/cli/src/nonInteractiveCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ describe('runNonInteractive', () => {
isInteractive: vi.fn().mockReturnValue(false),
isCronEnabled: vi.fn().mockReturnValue(false),
getCronScheduler: vi.fn().mockReturnValue(null),
getBackgroundTaskRegistry: vi.fn().mockReturnValue({
setNotificationCallback: vi.fn(),
setRegisterCallback: vi.fn(),
getRunning: vi.fn().mockReturnValue([]),
}),
} as unknown as Config;

mockSettings = {
Expand Down Expand Up @@ -255,7 +260,7 @@ describe('runNonInteractive', () => {
'prompt-id-1',
{ type: SendMessageType.UserQuery },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Hello World');
expect(processStdoutSpy).toHaveBeenCalledWith('Hello World\n');
expect(mockShutdownTelemetry).toHaveBeenCalled();
});

Expand Down Expand Up @@ -319,7 +324,7 @@ describe('runNonInteractive', () => {
'prompt-id-2',
{ type: SendMessageType.ToolResult },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer\n');
});

it('should handle error during tool execution and should send error back to the model', async () => {
Expand Down Expand Up @@ -388,7 +393,7 @@ describe('runNonInteractive', () => {
'prompt-id-3',
{ type: SendMessageType.ToolResult },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.');
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.\n');
});

it('should exit with error if sendMessageStream throws initially', async () => {
Expand Down Expand Up @@ -450,7 +455,7 @@ describe('runNonInteractive', () => {
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
expect(processStdoutSpy).toHaveBeenCalledWith(
"Sorry, I can't find that tool.",
"Sorry, I can't find that tool.\n",
);
});

Expand Down Expand Up @@ -514,7 +519,7 @@ describe('runNonInteractive', () => {
);

// 6. Assert the final output is correct
expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.');
expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.\n');
});

it('should process input and write JSON output with stats', async () => {
Expand Down Expand Up @@ -887,7 +892,7 @@ describe('runNonInteractive', () => {
{ type: SendMessageType.UserQuery },
);

expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command\n');
});

it('should handle command that requires confirmation by returning early', async () => {
Expand All @@ -912,7 +917,7 @@ describe('runNonInteractive', () => {

// Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter)
expect(processStderrSpy).toHaveBeenCalledWith(
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.',
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.\n',
);
});

Expand Down Expand Up @@ -947,7 +952,7 @@ describe('runNonInteractive', () => {
{ type: SendMessageType.UserQuery },
);

expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown\n');
});

it('should handle known but unsupported slash commands like /help by returning early', async () => {
Expand All @@ -970,7 +975,7 @@ describe('runNonInteractive', () => {

// Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter)
expect(processStderrSpy).toHaveBeenCalledWith(
'The command "/help" is not supported in non-interactive mode.',
'The command "/help" is not supported in non-interactive mode.\n',
);
});

Expand All @@ -995,7 +1000,7 @@ describe('runNonInteractive', () => {

// Should write error message to stderr
expect(processStderrSpy).toHaveBeenCalledWith(
'Unknown command result type: unhandled',
'Unknown command result type: unhandled\n',
);
});

Expand Down Expand Up @@ -1033,7 +1038,7 @@ describe('runNonInteractive', () => {

expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2');

expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged');
expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged\n');
});

it('should emit stream-json envelopes when output format is stream-json', async () => {
Expand Down
Loading
Loading