fix(ci): adapt the /resolve replay to the publish-resolution job split

This commit is contained in:
qwen-code-dev-bot committed 2026-08-31 07:54:39 +00:00
commit cde150c9dd
159 files changed
+27484 -684

No files matched your search

+381 -22
View File
@@ -100,7 +100,8 @@ const followupWorkflowPath = join(
'qwen-issue-followup-bot.yml',
);
const followupDoc = parse(readFileSync(followupWorkflowPath, 'utf8'));
const followupStep = followupDoc.jobs['follow-up-issues'].steps.find(
const followupJob = followupDoc.jobs['follow-up-issues'];
const followupStep = followupJob.steps.find(
(s) => s.name === 'Run Qwen issue follow-up',
);
const ciWebShellJob = ciDoc.jobs.web_shell_e2e_smoke;
@@ -241,20 +242,62 @@ describe('qwen-triage: agent tool/permission settings', () => {
// The same settings_json → settings bug survived in two more workflows after
// the triage fix. An unknown action input is dropped without error, so the
// /resolve agent ran every time with no turn cap, no tool allowlist, and no
// sandbox — on a runner pool its runs-on comment chose specifically because
// `sandbox: true` needs docker — and the follow-up bot ran uncapped too.
// These blocks were therefore never validated by anything; parse them here.
// /resolve agent ran every time with no turn cap and no tool allowlist, and
// the follow-up bot ran uncapped too. These blocks were therefore never
// validated by anything; parse them here.
//
// The sandbox key is pinned to FALSE, not true. The fix that made the block
// take effect (#9252) also switched the container on, and /resolve went from
// 84% pushed to 0 of 81 in a row: the versioned ghcr image lags the npm
// release (#9898) so the agent died at startup, or it hung silently to the job
// timeout. No sandboxed /resolve run has ever pushed a resolution.
describe('qwen-code-pr-review.yml resolve-pr: agent settings', () => {
it('passes `settings:` (not the silently-dropped `settings_json:`)', () => {
assertSettingsContract(resolveConflictsStep, 'resolve_conflicts');
// The agent is a direct `qwen` invocation (no qwen-code-action), so the
// settings travel as a step env value that the run block writes to a
// per-run QWEN_HOME — nothing can be silently dropped by an input name.
const resolveSettings = (label) => {
assert.ok(resolveConflictsStep, `${label} must keep its agent step`);
assert.equal(
resolveConflictsStep.uses,
undefined,
`${label} must invoke qwen directly, not through an action (its $GITHUB_ENV/$GITHUB_PATH cannot be decoyed)`,
);
const raw = resolveConflictsStep.env?.QWEN_SETTINGS;
assert.ok(typeof raw === 'string', `${label} must carry QWEN_SETTINGS`);
assert.ok(
resolveConflictsStep.run.includes('> "$QWEN_HOME/settings.json"'),
`${label} must write QWEN_SETTINGS to the per-run QWEN_HOME`,
);
assert.ok(
resolveConflictsStep.run.includes('export QWEN_HOME'),
`${label} must export QWEN_HOME so the qwen process reads the per-run settings`,
);
// A fork PR can commit .qwen/settings.json (the workspace layer), which
// the CLI merges ABOVE the user layer written above — an attacker file
// would override tools.sandbox and maxSessionTurns. The run block must
// remove it; out-setting it from the user layer cannot work.
assert.ok(
resolveConflictsStep.run.includes('rm -f .qwen/settings.json'),
`${label} must remove a fork-PR-committed workspace settings file`,
);
const settings = JSON.parse(raw);
for (const key of ['coreTools', 'maxSessionTurns', 'sandbox']) {
assert.equal(
settings[key],
undefined,
`${label}: v1 top-level \`${key}\` is a legacy key — use the v2 shape`,
);
}
return settings;
};
it('writes its settings to a per-run QWEN_HOME instead of an action input', () => {
resolveSettings('resolve_conflicts');
assert.equal(resolveConflictsStep.with, undefined);
});
it('settings is valid JSON pinning the turn cap, allowlist, and sandbox', () => {
const settings = assertSettingsContract(
resolveConflictsStep,
'resolve_conflicts',
);
const settings = resolveSettings('resolve_conflicts');
assert.equal(
settings.model?.maxSessionTurns,
400,
@@ -275,23 +318,289 @@ describe('qwen-code-pr-review.yml resolve-pr: agent settings', () => {
]) {
assert.ok(core.includes(t), `tools.core must include ${t}`);
}
// The runs-on comment pins this job to hosted runners because the sandbox
// needs docker; dropping the key would pay that routing cost for nothing.
// Explicit false, not absent: absent means "whatever the CLI defaults to
// or QWEN_SANDBOX says", and the one time this flipped on it took the
// command down for 13 days (see the describe comment).
assert.equal(
settings.tools?.sandbox,
true,
'tools.sandbox must stay true — the runs-on routing depends on it',
false,
'tools.sandbox must stay false — every sandboxed /resolve run has failed; re-enable only with a dry-run that finishes inside the container',
);
});
it('keeps resolve-pr on hosted runners (sandbox: true needs docker)', () => {
// The routing half of the sandbox coupling: the ECS pool ships no
// container runtime, so an ECS-routed sandboxed agent dies at startup.
assert.equal(
resolvePrJob['runs-on'],
'ubuntu-latest',
'resolve-pr must stay on hosted runners — sandbox: true needs docker, absent on the ECS pool',
it('pins the CLI version instead of following `latest`', () => {
// `latest` ties the job to the npm release pipeline of the moment: on
// 2026-08-15 the dist-tag pointed at an unresolvable 0.21.12 and every
// /resolve died on `npm error notarget` before the agent started.
const install = resolvePrJob.steps.find(
(s) => s.name === 'Install Qwen CLI',
);
assert.ok(install, 'resolve-pr must install the CLI in its own step');
assert.match(
String(install.env?.QWEN_CLI_VERSION),
/^\d+\.\d+\.\d+$/,
'QWEN_CLI_VERSION must be an exact semver, not a dist-tag',
);
assert.ok(
install.run.includes('@qwen-code/qwen-code@${QWEN_CLI_VERSION}'),
'the install must use the pinned version',
);
});
it('decoys the runner command files for the agent invocation', () => {
// The agent has arbitrary shell; whatever it appends to $GITHUB_ENV or
// $GITHUB_PATH would apply to the credentialed push step of the same
// job. The invocation must see invocation-scoped decoys, inside a
// parsing-off window, with no token in its environment.
const run = resolveConflictsStep.run;
for (const file of [
'GITHUB_PATH',
'GITHUB_ENV',
'GITHUB_OUTPUT',
'GITHUB_STEP_SUMMARY',
]) {
assert.ok(
run.includes(`${file}="$decoy_dir/`),
`${file} must point at the decoy for the qwen invocation`,
);
}
// The decoys only mask the invocation's env: the REAL runner command
// files stay discoverable by the agent (the step's parent-shell
// /proc/<pid>/environ is same-uid readable, $RUNNER_TEMP is enumerable),
// and anything planted there applies to every later step — shell lookup
// included. The step must therefore truncate all four after the agent,
// on every exit path.
const statusIdx = run.indexOf('status=$?');
assert.ok(statusIdx > -1, 'the run block must capture the qwen exit status');
for (const file of [
'GITHUB_ENV',
'GITHUB_PATH',
'GITHUB_OUTPUT',
'GITHUB_STEP_SUMMARY',
]) {
const trunc = `: > "\${${file}:?}" || true`;
const idx = run.indexOf(trunc);
assert.ok(idx > -1, `${file} must be truncated after the qwen invocation`);
assert.ok(
idx > statusIdx,
`${file} truncation must run on the exit path, after status=$?`,
);
}
// A hung agent must end inside the script (GNU timeout), not by the
// runner killing the process tree — only then are the resume and the
// truncation above reached (mirrors 'Run review').
assert.ok(run.includes('timeout --kill-after=10s'));
// The workspace-layer settings file is removed BEFORE qwen starts: a
// fork-committed .qwen/settings.json would otherwise outrank the
// per-run home written above for the whole run.
const settingsRm = run.indexOf('rm -f .qwen/settings.json');
assert.ok(settingsRm > -1, 'the workspace settings file must be removed');
assert.ok(
settingsRm < run.indexOf('timeout --kill-after=10s'),
'the workspace settings removal must precede the qwen invocation',
);
assert.ok(run.includes('echo "::stop-commands::${stop_token}"'));
assert.ok(run.includes("printf '\\n::%s::\\n' \"$stop_token\""));
assert.ok(run.includes('--approval-mode yolo'));
for (const key of Object.keys(resolveConflictsStep.env)) {
assert.ok(
!/TOKEN|PAT/.test(key),
`agent env must carry no credential, found ${key}`,
);
}
// Dead keys: the run block reads none of them (PROMPT interpolates
// pr_number directly) and the CLI consumes no such env vars — they only
// clutter the env surface this step's containment comment says to audit.
for (const key of ['PR_NUMBER', 'BASE_REF', 'HEAD_REF']) {
assert.equal(
resolveConflictsStep.env[key],
undefined,
`agent env must not carry dead key ${key} (keep the copies on 'Report result' / 'Report skipped request')`,
);
}
});
it('keeps every credential out of the agent job once the agent has run', () => {
// Structural containment: the agent runs --yolo without a sandbox and
// can leave anything on its runner (config scopes, hooks, moved refs,
// PATH shims, the real $GITHUB_ENV, a detached process reading /proc).
// Rather than chase each entrance with a scrub, nothing credentialed
// runs on that runner after the agent: the push and the result comment
// live in `publish-resolution`, whose runner never executed the agent.
const steps = resolvePrJob.steps;
const agentIdx = steps.findIndex((s) => s.id === 'resolve_conflicts');
assert.ok(agentIdx > -1);
const secretRef = (v) =>
typeof v === 'string' && v.includes('${{ secrets.');
for (const s of steps.slice(agentIdx + 1)) {
if (s.name === 'Report skipped request') {
// The one exception: it runs only when the agent did NOT run.
assert.ok(
String(s.if).includes("steps.prepare.outputs.decision == 'skip'") &&
!String(s.if).includes("decision == 'run'"),
'Report skipped request must be gated on the agent not having run',
);
continue;
}
for (const [key, value] of Object.entries(s.env ?? {})) {
assert.ok(
!secretRef(value),
`'${s.name}' runs after the agent and must not carry a secret (${key})`,
);
}
for (const [key, value] of Object.entries(s.with ?? {})) {
assert.ok(
!secretRef(value),
`'${s.name}' runs after the agent and must not carry a secret (${key})`,
);
}
}
// What crosses the job boundary is a bundle and report files, verified
// on the other side; the verdict on the agent step crosses as an output.
const packageStep = steps.find((s) => s.name === 'Package resolution');
assert.ok(packageStep, 'resolve-pr must package the resolution');
assert.ok(
packageStep.run.includes(
'git bundle create "${WORKDIR}/resolution.bundle" "${HEAD_SHA}..refs/heads/qwen-resolve/pr-${PR_NUMBER}"',
),
);
assert.equal(
resolvePrJob.outputs.agent_outcome,
'${{ steps.resolve_conflicts.outcome }}',
);
assert.equal(
resolvePrJob.outputs.decision,
'${{ steps.prepare.outputs.decision }}',
);
});
it('publishes from a job that never ran the agent, from GitHub-fetched refs and a verified bundle', () => {
const publish = prReviewDoc.jobs['publish-resolution'];
assert.ok(publish, 'publish-resolution must exist');
assert.deepEqual(publish.needs, ['resolve-pr']);
assert.equal(
publish.if,
"${{ always() && needs.resolve-pr.outputs.decision == 'run' }}",
);
assert.equal(publish['runs-on'], 'ubuntu-latest');
assert.deepEqual(publish.permissions, { contents: 'read' });
// The publisher uses a PER-RUN concurrency group, NOT the shared
// head-write one: it is reached only after resolve-pr uploaded a
// completed resolution, and GitHub replaces the single pending job in a
// group when another is queued, so sharing the head-write group would let
// a second /resolve or an autofix writer silently cancel this pending
// publisher and drop a resolution that already succeeded. A per-run group
// can never be replaced; competing publishers are made safe by the
// force-with-lease push, not by the group.
assert.match(publish.concurrency.group, /\$\{\{\s*github\.run_id\s*\}\}/);
assert.notEqual(publish.concurrency.group, resolvePrJob.concurrency.group);
assert.match(resolvePrJob.concurrency.group, /^qwen-pr-head-write-/);
assert.equal(publish.concurrency['cancel-in-progress'], false);
// The atomic guard for two concurrent publishers is the lease, pinned to
// the head the agent resolved from.
const reportRun = publish.steps.find((s) => s.name === 'Report result').run;
assert.ok(
reportRun.includes(
'--force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}"',
),
'the publisher push must be force-with-lease pinned to HEAD_SHA',
);
// No agent here, and the token-bearing step is here and nowhere else.
assert.ok(publish.steps.every((s) => s.id !== 'resolve_conflicts'));
assert.ok(publish.steps.every((s) => !(s.run ?? '').includes('qwen ')));
const report = publish.steps.find((s) => s.name === 'Report result');
assert.equal(report.env.PUSH_TOKEN, '${{ secrets.CI_DEV_BOT_PAT }}');
assert.ok(report.run.includes('x-access-token:${PUSH_TOKEN}'));
assert.ok(
resolvePrJob.steps.every(
(s) => !(s.run ?? '').includes('x-access-token:'),
),
);
// Fresh checkout, refs from GitHub, compared against the head the agent
// resolved from; the resolution enters only as a verified bundle that
// must descend from that head.
const checkout = publish.steps.find((s) =>
s.uses?.startsWith('actions/checkout@'),
);
assert.equal(checkout.with['persist-credentials'], false);
const verify = publish.steps.find((s) => s.id === 'verify');
assert.equal(
verify.env.HEAD_SHA,
'${{ needs.resolve-pr.outputs.head_sha }}',
);
assert.equal(
verify.env.RESOLVE_OUTCOME,
'${{ needs.resolve-pr.outputs.agent_outcome }}',
);
for (const line of [
'git fetch origin "+refs/pull/${PR_NUMBER}/head:${HEAD_FETCH_REF}"',
'git update-ref "$HEAD_FETCH_REF" "$HEAD_SHA"',
'git bundle verify "$bundle"',
'git merge-base --is-ancestor "$HEAD_SHA" HEAD',
]) {
assert.ok(verify.run.includes(line), `verify must contain: ${line}`);
}
assert.ok(
verify.run.indexOf('git bundle verify') <
verify.run.indexOf('git fetch "$bundle"'),
'the bundle must verify before it is fetched',
);
assert.ok(
verify.run.indexOf('git merge-base --is-ancestor') <
verify.run.indexOf('git ls-files -u'),
'lineage must be checked before any content check',
);
// The artifact is the same one the agent job uploads. The name carries
// run_attempt on both sides: a re-run keeps the run_id, and without the
// suffix the re-run would republish attempt 1's stale bundle. The
// DOWNLOAD spells the attempt that RAN THE AGENT, not the publish job's
// own run_attempt: a partial "Re-run failed jobs" re-runs only the
// publish job, whose attempt number has no artifact. A missing artifact
// with a successful agent step is the lost artifact, never an infra
// failure of the agent run.
const download = publish.steps.find((s) =>
s.uses?.startsWith('actions/download-artifact@'),
);
assert.equal(download['continue-on-error'], true);
assert.equal(
download.with.name,
'qwen-resolve-pr-${{ needs.resolve-pr.outputs.pr_number }}-attempt-${{ needs.resolve-pr.outputs.agent_run_attempt }}',
);
assert.equal(
resolvePrJob.outputs.agent_run_attempt,
'${{ steps.resolve.outputs.run_attempt }}',
);
const resolveStep = resolvePrJob.steps.find((s) => s.id === 'resolve');
assert.ok(
resolveStep.run.includes(
'echo "run_attempt=${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"',
),
'the attempt that ran the agent must cross the job boundary',
);
const upload = resolvePrJob.steps.find((s) =>
s.uses?.startsWith('actions/upload-artifact@'),
);
assert.equal(
upload.with.name,
'qwen-resolve-pr-${{ steps.resolve.outputs.pr_number }}-attempt-${{ github.run_attempt }}',
);
assert.ok(verify.run.includes('failure_kind=infra'));
assert.ok(verify.run.includes('failure_kind=artifact_missing'));
assert.ok(verify.run.includes('its run artifact is missing'));
});
it('keeps both /resolve jobs on ephemeral hosted runners', () => {
// resolve-pr runs an unsandboxed agent; publish-resolution force-pushes
// to the PR head with a PAT. A fresh runner for each is the cheapest
// guarantee that nothing from an earlier attempt — or from the other
// job — is carried into either.
for (const name of ['resolve-pr', 'publish-resolution']) {
assert.equal(
prReviewDoc.jobs[name]['runs-on'],
'ubuntu-latest',
`${name} must stay on an ephemeral hosted runner`,
);
}
});
});
@@ -300,6 +609,56 @@ describe('qwen-issue-followup-bot.yml: agent settings', () => {
assertSettingsContract(followupStep, 'the follow-up step');
});
it('installs the pinned CLI job-locally so the pin binds BOTH lanes', () => {
// The action's 'Install Qwen Code' step skips when `qwen` is already on
// PATH (verified at the pinned action SHA) — always true on the ecs-qwen
// pool this job routes to by default, whose fleet CLI
// update-ecs-runner-qwen.yml maintains at `latest`. An input-only pin
// never bound that primary lane, so the bot there ran whatever the fleet
// update last installed — the exact 2026-08-15 notarget outage class the
// pin exists to prevent. The job-local install is prepended to PATH, so
// the pinned copy outranks the fleet CLI on ecs-qwen and is the only CLI
// on the ubuntu-latest fallback lane; without a contract test a future
// edit silently reverts the bot to the fleet dist-tag with nothing red
// at merge time.
const install = followupJob.steps.find((s) => s.id === 'install_qwen');
assert.ok(install, 'the pinned CLI install step must exist');
assert.match(
String(install.env?.QWEN_CLI_VERSION),
/^\d+\.\d+\.\d+$/,
'the install must pin an exact CLI version, not a dist-tag',
);
assert.ok(
install.run.includes('npm install --prefix') &&
install.run.includes('"@qwen-code/qwen-code@${QWEN_CLI_VERSION}"'),
'the install must be job-local (--prefix) and pinned to QWEN_CLI_VERSION',
);
assert.ok(
install.run.includes('--registry=https://registry.npmjs.org'),
'the install must pin the registry, not the runner npm mirror',
);
assert.ok(
install.run.includes(
'echo "${install_dir}/node_modules/.bin" >> "${GITHUB_PATH}"',
),
'the job-local bin dir must be prepended to PATH to outrank the fleet CLI',
);
// PATH prepends apply only to LATER steps, and the action's own install
// skips on any `qwen` already on PATH: if the install stops preceding
// the action step, the fleet CLI wins on ecs-qwen again.
assert.ok(
followupJob.steps.indexOf(install) <
followupJob.steps.indexOf(followupStep),
'the pinned install must run before the action step',
);
// Single source of truth: the action input is the installed version, so
// it can never drift from what actually runs.
assert.equal(
String(followupStep.with?.qwen_cli_version),
'${{ steps.install_qwen.outputs.version }}',
);
});
it('settings is valid JSON pinning the turn cap and gh allowlist', () => {
const settings = assertSettingsContract(followupStep, 'the follow-up step');
assert.equal(
+2 -2
View File
@@ -37,9 +37,9 @@
5942 qwen-autofix-fork-signal.yml
454547 qwen-autofix.yml
7061 qwen-ci-flaky-rerun.yml
184442 qwen-code-pr-review.yml
205562 qwen-code-pr-review.yml
79041 qwen-fleet-shepherd.yml
20525 qwen-issue-followup-bot.yml
22680 qwen-issue-followup-bot.yml
5760 qwen-pr-safety-precheck.yml
27648 qwen-triage-finalize.yml
350381 qwen-triage.yml
+3 -2
View File
@@ -216,8 +216,9 @@ jobs:
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
KEEP_OUTPUT: 'true'
VERBOSE: 'true'
# Mapped for integration-tests/vitest.config.ts, which exempts
# self-hosted runners from pressure-flake unhandled errors.
# Mapped for integration-tests/vitest.config.ts, which caps each
# shared-pool shard at one fork and exempts pressure-flake unhandled
# errors.
RUNNER_ENVIRONMENT: '${{ runner.environment }}'
run: |-
export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
+454 -117
View File
@@ -1462,13 +1462,20 @@ jobs:
# The agent runs no-sandbox with the full job environment, and
# the runner applies whatever it appends to $GITHUB_PATH /
# $GITHUB_ENV to every LATER step (prepending the path file,
# shell lookup included). Point both at invocation-scoped decoys
# so appends die with PROXY_BIN no matter how the step exits —
# SIGKILL, set -e abort, chmod 444 on the real files, or an
# EXIT-trap bypass all become irrelevant because the real
# command files are never in the agent's environment.
# shell lookup included). Point all four runner command files at
# invocation-scoped decoys so appends die with PROXY_BIN no matter
# how the step exits — SIGKILL, set -e abort, chmod 444 on the
# real files, or an EXIT-trap bypass all become irrelevant because
# the real command files are never in the agent's environment, and
# a decoyed $GITHUB_STEP_SUMMARY cannot plant attacker text into
# the run page. The prefix only reaches the qwen invocation, so
# the step's own later writes still hit the real files. This
# shield is copied inline in 'Resolve conflicts': keep the decoy
# set aligned, edit both copies together.
GITHUB_PATH="$PROXY_BIN/decoy.github-path" \
GITHUB_ENV="$PROXY_BIN/decoy.github-env" \
GITHUB_OUTPUT="$PROXY_BIN/decoy.github-output" \
GITHUB_STEP_SUMMARY="$PROXY_BIN/decoy.github-step-summary" \
timeout --kill-after=10s "${attempt_timeout}s" qwen \
--auth-type openai \
--approval-mode yolo \
@@ -2457,16 +2464,20 @@ jobs:
# `true`. The variable is daemon state, and autofix's preflight is what
# checks it up front, failing the job in seconds instead of at agent
# startup. Anything moving a sandboxed job onto the pool should copy that
# preflight — see #9556.
# preflight — see #9556. (Moot for now: the agent step below runs with
# tools.sandbox false, for the reasons recorded on its settings block.)
runs-on: 'ubuntu-latest'
timeout-minutes: 120
# Shared with qwen-autofix.yml's review-address job — see the rationale
# there. GitHub concurrency groups are repository-scoped, so the identical
# prefix is what serialises the two workflows against each other; both
# merge the base branch and push to this PR's head, and racing them costs a
# whole agent run on the loser (observed on #7355). The prefix must stay
# byte-identical in both files; a test pins that, because renaming one side
# alone re-opens the race with nothing failing.
# prefix is what serialises the two workflows against each other; both run
# an expensive conflict-resolution agent against this PR's head, and
# racing them costs a whole agent run on the loser (observed on #7355).
# The prefix must stay byte-identical in both files; a test pins that,
# because renaming one side alone re-opens the race with nothing failing.
# Only the AGENT phase lives here; the credentialed push runs in
# publish-resolution under a per-run group (see the note there), so a
# completed resolution waiting to publish can never be replaced.
concurrency:
group: 'qwen-pr-head-write-${{ github.event.issue.number || github.event.inputs.pr_number }}'
cancel-in-progress: false
@@ -2476,6 +2487,18 @@ jobs:
# conflict-resolution path can reach a writable ambient token.
permissions:
contents: 'read'
# Consumed by publish-resolution. All of these are written before the
# agent step runs (or by the runner about it), never by the agent.
outputs:
decision: '${{ steps.prepare.outputs.decision }}'
pr_number: '${{ steps.resolve.outputs.pr_number }}'
agent_outcome: '${{ steps.resolve_conflicts.outcome }}'
agent_run_attempt: '${{ steps.resolve.outputs.run_attempt }}'
head_ref: '${{ steps.prepare.outputs.head_ref }}'
head_sha: '${{ steps.prepare.outputs.head_sha }}'
head_repo: '${{ steps.prepare.outputs.head_repo }}'
head_fetch_ref: '${{ steps.prepare.outputs.head_fetch_ref }}'
base_ref: '${{ steps.prepare.outputs.base_ref }}'
env:
REPO: '${{ github.repository }}'
WORKDIR: '/tmp/qwen-resolve'
@@ -2522,6 +2545,10 @@ jobs:
pr_number="$ISSUE_NUMBER"
fi
echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT"
# The attempt that runs the agent — publish-resolution downloads
# the run artifact by THIS number, not its own github.run_attempt
# (a partial "Re-run failed jobs" re-runs only the publish job).
echo "run_attempt=${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
- name: 'Checkout base branch'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
@@ -2674,27 +2701,77 @@ jobs:
write_output decision run
decision_written=1
- name: 'Install Qwen CLI'
if: "steps.prepare.outputs.decision == 'run'"
env:
# Pinned, not `latest`. The CLI is installed from npm at run time,
# so `latest` makes every /resolve depend on the release pipeline
# of the moment: on 2026-08-15 the dist-tag moved to 0.21.12
# before the tarball was resolvable and 14 runs in a row died on
# `npm error notarget`. 0.21.10 is the last version with a
# measured record on this job (16 of 19 requests pushed on
# 2026-08-12). Bump it on purpose, after a dry-run /resolve on the
# new version.
QWEN_CLI_VERSION: '0.21.10'
run: |-
set -euo pipefail
npm install -g --registry=https://registry.npmjs.org "@qwen-code/qwen-code@${QWEN_CLI_VERSION}"
qwen --version
# The agent is invoked directly rather than through qwen-code-action so
# that its process environment can be controlled: the action runs the
# CLI with the runner's real $GITHUB_ENV / $GITHUB_PATH, and anything
# appended there is applied to every later step of this job. See the
# decoy note in the run block.
- name: 'Resolve conflicts'
if: "steps.prepare.outputs.decision == 'run'"
id: 'resolve_conflicts'
uses: 'QwenLM/qwen-code-action@05f81718976f3b7da657422e6e8e4d372b8621d7'
# A pushed resolution takes 6 minutes at the median, 41 at p95 and 70
# at p99 (294 runs, request to result comment); only two ever took
# longer than 75. The 120-minute job timeout has therefore never ended
# a healthy run, only hung ones — and a hang billed the full 120
# minutes 40 times in 2026-08. 75 keeps the p99 tail and cuts that
# bill by a third. Keep AGENT_TIMEOUT_MINUTES on 'Report result' in
# step with this value; a test pins the pair.
timeout-minutes: 75
env:
PR_NUMBER: '${{ steps.resolve.outputs.pr_number }}'
BASE_REF: '${{ steps.prepare.outputs.base_ref }}'
HEAD_REF: '${{ steps.prepare.outputs.head_ref }}'
with:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ vars.QWEN_PR_REVIEW_MODEL }}'
# The input name is `settings` — this action version has no
# `settings_json` input, and an unknown input is silently dropped.
# That is exactly what happened to this block: every /resolve run so
# far ignored it, and the agent ran without the turn cap, toolset
# allowlist, or the sandbox the runs-on comment above assumes.
# tools.core specifiers (e.g. `run_shell_command(git add)`) are advisory:
# the permission manager keys on the tool name and drops the parenthesised
# command. Real containment = sandbox + write authorization + no agent token.
settings: |-
# Written to a per-run QWEN_HOME by the run block below. Until
# #9252 this block was passed to the action under the wrong input
# name and silently dropped, so every /resolve ran without the turn
# cap or toolset allowlist; it now takes effect on every run.
# tools.core specifiers (e.g. `run_shell_command(git add)`) are
# advisory: the permission manager keys on the tool name and drops
# the parenthesised command, so the agent has arbitrary shell in
# this checkout. Containment therefore lives OUTSIDE the agent, and
# structurally: no token in this job after the agent starts — the
# push and the result comment run in `publish-resolution`, on a
# runner that never executed the agent, from a fresh checkout whose
# refs come from GitHub and whose only agent-supplied input is a
# verified git bundle. Whatever the agent leaves behind here — a
# config scope, a hook, a moved ref, a PATH shim, an append to the
# real $GITHUB_ENV, a detached process reading /proc — dies with
# this runner. (An in-job denylist was tried first and could not be
# closed: each entrance named produced the next one; see the review
# threads on #10428.) The remaining in-job measures — decoyed
# command files and the parsing-off window below — only keep the
# agent's OWN job's later steps (packaging, upload) honest.
#
# tools.sandbox is deliberately false. The day #9252 made this block
# take effect, `sandbox: true` took effect with it, and /resolve went
# from 84% pushed (273 agent runs, 2026-06-25..08-14) to 0 of 81
# (2026-08-15..08-27). Inside the container the agent either died at
# startup — the versioned `ghcr.io/qwenlm/qwen-code:<cli>` image lags
# the npm release (#9898: `manifest unknown` on 0.21.13, 0.22.0 and
# 0.22.1; 24 runs) — or produced no output at all until the job
# timeout (40 runs; `git status` afterwards showed the merge had not
# even started). No sandboxed /resolve run has ever pushed a
# resolution; every pushed one ran without the container. Turning the
# sandbox back on needs the same evidence the other way: a dry-run
# whose logs show the agent finishing inside it.
QWEN_SETTINGS: |-
{
"model": {
"maxSessionTurns": 400
@@ -2718,10 +2795,10 @@ jobs:
"run_shell_command(mkdir)",
"run_shell_command(pwd)"
],
"sandbox": true
"sandbox": false
}
}
prompt: |-
PROMPT: |-
## Role
You are resolving merge conflicts for PR #${{ steps.resolve.outputs.pr_number }} in this repository. The pull request branch is already checked out. You only need git to resolve the text conflicts; dependencies are not installed. Read ${{ env.WORKDIR }}/context.md first.
@@ -2750,10 +2827,225 @@ jobs:
End with the project's collapsed Chinese translation: `<details>` / `<summary>中文说明</summary>` / the translated body / `</details>`.
- If there is no longer a conflict, do not commit. Write `${{ env.WORKDIR }}/no-action.md` explaining what changed.
- If you cannot confidently resolve the conflict, do not commit. Write `${{ env.WORKDIR }}/failure.md` with the blocker and what you learned.
run: |-
set -euo pipefail
# Per-run agent home: the settings above and nothing inherited from
# the runner user's ~/.qwen.
QWEN_HOME="${RUNNER_TEMP:?}/qwen-resolve-home"
rm -rf "$QWEN_HOME"
mkdir -p "$QWEN_HOME"
printf '%s\n' "$QWEN_SETTINGS" > "$QWEN_HOME/settings.json"
export QWEN_HOME
# A fork PR can commit its own .qwen/settings.json (the workspace
# layer), which the CLI merges ABOVE the user layer written above —
# an attacker-authored tools.sandbox or maxSessionTurns would
# outrank these settings. Remove the file; it is git-ignored here,
# so it can only arrive via a forced add on a fork branch. Do not
# rm -rf .qwen/: commands/, skills/ and agents/ under it can be
# legitimate tracked content.
rm -f .qwen/settings.json
MODEL_ARGS=()
if [ -n "${OPENAI_MODEL:-}" ]; then
MODEL_ARGS=(--model "$OPENAI_MODEL")
fi
# The agent runs no-sandbox with arbitrary shell and, on a fork PR,
# reads attacker-authored files. The runner applies whatever a step
# appends to $GITHUB_PATH / $GITHUB_ENV to every LATER step — the
# credentialed push included — so every runner command file is
# pointed at an invocation-scoped decoy that dies with this step.
# This shield is copied inline in 'Run review' (run_review_once):
# keep the decoy set aligned, edit both copies together. Command
# parsing is switched off around the agent for the same reason: a
# `::set-output`-style line in its output must stay text. The token
# is random per attempt so no agent output can guess it and resume
# parsing early. Nothing credentialed follows in this job; the
# publish job starts from a clean runner (see the settings block).
decoy_dir="$(mktemp -d "${RUNNER_TEMP}/qwen-resolve-decoy.XXXXXX")"
stop_token="qwen-resolve-stop-$(date +%s%N)-${RANDOM}${RANDOM}"
echo "::stop-commands::${stop_token}"
set +e
# GNU timeout (mirroring 'Run review') so a hung agent ends inside
# THIS shell: the runner-side timeout-minutes kills the whole
# process tree before the resume below and the command-file
# truncation can run. 4440s sits just under the 75-minute step
# timeout, which stays the outer backstop.
GITHUB_PATH="$decoy_dir/github-path" \
GITHUB_ENV="$decoy_dir/github-env" \
GITHUB_OUTPUT="$decoy_dir/github-output" \
GITHUB_STEP_SUMMARY="$decoy_dir/github-step-summary" \
timeout --kill-after=10s 4440s qwen \
--auth-type openai \
--approval-mode yolo \
"${MODEL_ARGS[@]}" \
--prompt "$PROMPT" \
--output-format stream-json \
> "${WORKDIR}/agent.stream-json.log" 2> "${WORKDIR}/agent.stderr.log"
status=$?
# The decoys only mask THIS invocation's environment: the real
# runner command files stay discoverable by the agent (this step's
# parent-shell /proc/<pid>/environ is same-uid readable,
# $RUNNER_TEMP is enumerable), and anything planted there applies to
# every later step — shell lookup included. This step writes nothing
# to them and downstream reads only runner-tracked values, so
# truncate all four on every exit path before the step ends.
: > "${GITHUB_ENV:?}" || true
: > "${GITHUB_PATH:?}" || true
: > "${GITHUB_OUTPUT:?}" || true
: > "${GITHUB_STEP_SUMMARY:?}" || true
# Diagnostics stay inside the parsing-off window: agent output is
# untrusted text. The full logs travel with the run artifact.
echo "qwen exited with ${status}; last stderr lines:"
tail -n 40 "${WORKDIR}/agent.stderr.log" || true
printf '\n::%s::\n' "$stop_token"
set -e
rm -rf "$decoy_dir"
exit "$status"
# The agent job ends here. Everything the publish job needs travels
# through the run artifact: the resolution as a git bundle (objects and
# one named ref — no config, no hooks, no other refs) plus the agent's
# report files. The publish job fetches the base and head refs from
# GitHub itself and verifies the bundle against them before anything
# else happens, so this runner's state stays in this job.
- name: 'Package resolution'
if: "${{ always() && steps.prepare.outputs.decision == 'run' }}"
env:
HEAD_SHA: '${{ steps.prepare.outputs.head_sha }}'
PR_NUMBER: '${{ steps.resolve.outputs.pr_number }}'
run: |-
set -uo pipefail
if [ ! -d .git ]; then
echo "no .git directory after the agent step; nothing to package"
exit 0
fi
resolved="$(git rev-parse --verify HEAD 2>/dev/null || true)"
if [ -z "$resolved" ] || [ "$resolved" = "$HEAD_SHA" ]; then
echo "branch unchanged (${resolved:-unreadable}); no bundle to package"
exit 0
fi
git update-ref "refs/heads/qwen-resolve/pr-${PR_NUMBER}" "$resolved"
if git bundle create "${WORKDIR}/resolution.bundle" "${HEAD_SHA}..refs/heads/qwen-resolve/pr-${PR_NUMBER}"; then
printf '%s\n' "$resolved" > "${WORKDIR}/resolution.sha"
echo "packaged ${resolved} (on ${HEAD_SHA}) as resolution.bundle"
else
echo "::warning::could not package the resolution bundle; the publish job will report the run as failed."
fi
- name: 'Show run artifacts'
if: "${{ always() && steps.prepare.outputs.decision == 'run' }}"
env:
BASE_REF: '${{ steps.prepare.outputs.base_ref }}'
run: |-
# Diagnostics only, in the agent's own runner. Nothing printed or
# written here is trusted downstream: the publish job recomputes the
# diff and every verdict from the bundle and GitHub's refs, so a
# planted diff driver or config can only mislead this log.
git status --short || true
git diff "origin/${BASE_REF}...HEAD" > "${WORKDIR}/pr.diff" || true
for file in context.md address-summary.md no-action.md failure.md pr.diff; do
if [ -f "${WORKDIR}/${file}" ]; then
echo "=============== ${file} ==============="
cat "${WORKDIR}/${file}"
echo
fi
done
- name: 'Upload run artifacts'
if: "${{ always() && steps.prepare.outputs.decision == 'run' }}"
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
# The name carries run_attempt: a re-run keeps the run_id, and the
# publish job downloads by this exact name — without the suffix the
# re-run 409s on attempt 1's artifact and republishes the stale one.
name: 'qwen-resolve-pr-${{ steps.resolve.outputs.pr_number }}-attempt-${{ github.run_attempt }}'
path: '${{ env.WORKDIR }}/'
if-no-files-found: 'ignore'
- name: 'Report skipped request'
# always(): the prepare step's EXIT trap writes decision=failed when it
# crashes, but a bare if implicitly requires success() — so without
# always() this step is skipped on the very crash it must report.
if: "${{ always() && (steps.prepare.outputs.decision == 'skip' || steps.prepare.outputs.decision == 'unsupported' || steps.prepare.outputs.decision == 'failed') }}"
env:
GH_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
PR_NUMBER: '${{ steps.resolve.outputs.pr_number }}'
SKIP_REASON: '${{ steps.prepare.outputs.skip_reason }}'
run: |-
set -euo pipefail
{
echo "<!-- qwen-resolve-result -->"
echo "Qwen Code did not run conflict resolution for this request."
echo
echo "${SKIP_REASON}"
} > "${WORKDIR}/report.md"
# Best-effort, matching 'Report result': a transient API error here must
# not abort the step under set -e and swallow the only failure signal.
gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "${WORKDIR}/report.md" ||
echo "::warning::Resolve was skipped, but posting the skip-reason comment failed."
# Publishes what the agent job produced — verification, push and the result
# comment — on a runner that never executed the agent. It starts from a
# fresh checkout, fetches the base and head refs from GitHub itself, and
# takes from the agent job only the run artifact (a git bundle and the
# report files), each verified or sanitised before use. The push token lives
# only in this job.
publish-resolution:
needs: ['resolve-pr']
if: "${{ always() && needs.resolve-pr.outputs.decision == 'run' }}"
runs-on: 'ubuntu-latest'
timeout-minutes: 20
# A PER-RUN group, deliberately NOT the shared qwen-pr-head-write-<pr> one.
# This job is only reached after resolve-pr finished and uploaded a
# completed resolution; between that and this job starting it sits pending.
# GitHub keeps only one pending job per group and replaces it when another
# same-group job is queued (cancel-in-progress:false protects only a
# RUNNING job), so sharing the head-write group would let a second
# /resolve, a later publisher, or an autofix writer silently cancel this
# pending publisher — dropping a resolution that already succeeded, before
# it is ever pushed or reported. A per-run group has one member for life,
# so this job can never be replaced. Competing publishers stay safe not
# through the group but through the push itself: it is force-with-lease
# pinned to the head the agent resolved from, so of two concurrent pushes
# exactly one wins and the other reports "moved" cleanly (never a clobber,
# never a silent drop). The expensive, wasteful-to-race work — the agent —
# is what stays in the shared head-write group, on resolve-pr.
concurrency:
group: 'qwen-pr-publish-${{ github.run_id }}'
cancel-in-progress: false
permissions:
contents: 'read'
env:
REPO: '${{ github.repository }}'
WORKDIR: '/tmp/qwen-resolve'
DRY_RUN: '${{ github.event.inputs.dry_run || false }}'
PR_NUMBER: '${{ needs.resolve-pr.outputs.pr_number }}'
steps:
- name: 'Checkout base branch'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.repository.default_branch }}'
fetch-depth: 0
persist-credentials: false
- name: 'Download run artifacts'
# A cancelled or crashed agent job may have uploaded nothing; the
# import in 'Resolution check' reports that as an infrastructure
# failure instead of letting this step end the job without a comment.
# The name ends in the attempt that RAN THE AGENT, not this job's own
# github.run_attempt: a partial "Re-run failed jobs" re-runs only the
# publish job, and only the agent's attempt uploaded an artifact —
# downloading by this attempt's name would miss it and wedge the
# re-run on a contradictory failure comment.
continue-on-error: true
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
with:
name: 'qwen-resolve-pr-${{ needs.resolve-pr.outputs.pr_number }}-attempt-${{ needs.resolve-pr.outputs.agent_run_attempt }}'
path: '/tmp/qwen-resolve'
- name: 'Resolution check'
id: 'verify'
if: "${{ always() && steps.prepare.outputs.decision == 'run' }}"
if: '${{ always() }}'
# Refs are passed as env vars and only referenced as "$BASE_REF" /
# "$HEAD_FETCH_REF" inside the script. They must never be inlined as
# ${{ ... }}: branch names legally contain `$(...)`/backticks, so textual
@@ -2761,21 +3053,82 @@ jobs:
env:
# No PR code runs in this step — it only runs git checks — but the empty
# value keeps a writable GITHUB_TOKEN out of the workspace as defense in
# depth, mirroring the no-token contract of the agent step above.
# depth, mirroring the no-token contract of the agent step.
GITHUB_TOKEN: ''
BASE_REF: '${{ steps.prepare.outputs.base_ref }}'
HEAD_FETCH_REF: '${{ steps.prepare.outputs.head_fetch_ref }}'
RESOLVE_OUTCOME: '${{ steps.resolve_conflicts.outcome }}'
BASE_REF: '${{ needs.resolve-pr.outputs.base_ref }}'
HEAD_FETCH_REF: '${{ needs.resolve-pr.outputs.head_fetch_ref }}'
HEAD_SHA: '${{ needs.resolve-pr.outputs.head_sha }}'
RESOLVE_OUTCOME: '${{ needs.resolve-pr.outputs.agent_outcome }}'
run: |-
set -euo pipefail
# This checkout never ran the agent: the refs it checks against come
# from GitHub, and the resolution arrives only as a bundle (below).
git fetch origin "+refs/pull/${PR_NUMBER}/head:${HEAD_FETCH_REF}" "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
# Every check compares against the head the agent resolved FROM —
# recorded by the agent job before its agent ran — not against the
# PR's current head. A head that moved meanwhile is the push's
# business (the lease declines and the moved-head comment follows),
# not a scope-guard failure that would blame the contributor's new
# commits on the agent. If the PR head no longer points at it, the
# commit is fetched by SHA.
if ! git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then
git fetch origin "$HEAD_SHA" || true
fi
if ! git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then
echo "The PR head the agent resolved from (${HEAD_SHA}) is no longer reachable on GitHub; refusing."
echo "outcome=failed" >> "$GITHUB_OUTPUT"
exit 1
fi
git update-ref "$HEAD_FETCH_REF" "$HEAD_SHA"
# The agent step runs under always(); if it failed at the infrastructure
# level (API timeout, model quota, action crash) the branch is unmodified
# and the merge-tree check below would misreport "still has conflicts".
# Surface the real cause instead.
if [ "$RESOLVE_OUTCOME" != "success" ]; then
echo "The conflict-resolution agent step did not succeed (outcome=${RESOLVE_OUTCOME}): API/quota/infrastructure error or cancellation. Check its logs."
echo "The conflict-resolution agent step did not succeed (outcome=${RESOLVE_OUTCOME}): CLI install, model endpoint or infrastructure error, its step timeout, or cancellation. Check its logs."
echo "outcome=failed" >> "$GITHUB_OUTPUT"
# Distinguish "never reached a verdict" from "reached a wrong one":
# 'Report result' words the comment differently and does not invite
# a re-run, because a re-run repeats an infrastructure failure.
echo "failure_kind=infra" >> "$GITHUB_OUTPUT"
exit 1
fi
# Import the resolution. The artifact is attacker-influenced (the
# agent wrote it), so it is admitted as objects only: `git bundle
# verify` proves the bundle's prerequisites exist in THIS repository
# (the head fetched from GitHub above), and the imported commit must
# descend from the head the agent resolved from. No bundle means the
# agent changed nothing — a no-op or a written-up failure — and the
# checks below run against the unchanged head.
bundle="${WORKDIR}/resolution.bundle"
resolution_ref="refs/heads/qwen-resolve/pr-${PR_NUMBER}"
if [ -s "$bundle" ]; then
if ! git bundle verify "$bundle" > /dev/null; then
echo "The resolution bundle from the agent job does not verify against the PR head; refusing."
echo "outcome=failed" >> "$GITHUB_OUTPUT"
exit 1
fi
git fetch "$bundle" "+${resolution_ref}:${resolution_ref}"
git checkout -q -B "qwen-resolve/pr-${PR_NUMBER}" "$resolution_ref"
if ! git merge-base --is-ancestor "$HEAD_SHA" HEAD; then
echo "The resolution does not descend from the PR head the agent resolved from (${HEAD_SHA}); refusing."
echo "outcome=failed" >> "$GITHUB_OUTPUT"
exit 1
fi
elif [ -d "$WORKDIR" ]; then
git checkout -q -B "qwen-resolve/pr-${PR_NUMBER}" "$HEAD_FETCH_REF"
else
# Only reachable with RESOLVE_OUTCOME=success — any other outcome
# exits in the never-ran block above. The agent finished, but its
# artifact is gone (the upload step failed or it expired), so the
# result cannot be verified or published; classify the LOST
# ARTIFACT, never the agent run, and word the comment accordingly.
echo "The agent step succeeded, but its run artifact is missing (the upload failed or the artifact expired); nothing to verify."
echo "outcome=failed" >> "$GITHUB_OUTPUT"
echo "failure_kind=artifact_missing" >> "$GITHUB_OUTPUT"
exit 1
fi
@@ -2839,13 +3192,14 @@ jobs:
# itself changed (conflict resolutions live in those same files).
# Anything edited outside that set is out of scope — a prompt-injection
# symptom — so fail closed and list the offending files. Granularity is
# deliberately file-level, not per-hunk: real containment is sandbox +
# write authorization + no agent token; this is one defense-in-depth layer.
# deliberately file-level, not per-hunk: real containment is write
# authorization + no agent token + an ephemeral runner; this is one
# defense-in-depth layer.
merge_base="$(git merge-base "origin/${BASE_REF}" "$HEAD_FETCH_REF")"
# -z/sort -zu mirrors the conflict-marker check above so unusual
# filenames in the diff are handled consistently. ponytail: tr back to
# filenames in the diff are handled consistently, then tr back to
# newlines because bash vars can't hold NUL — a filename containing a
# literal newline still splits, but that's covered by the sandbox +
# literal newline still splits, but that's covered by the
# write-authorization + no-token containment this guard backstops.
base_changed="$(git diff --name-only -z "${merge_base}" "origin/${BASE_REF}" | sort -zu | tr '\0' '\n')"
agent_changed="$(git diff --name-only -z "$HEAD_FETCH_REF" HEAD | sort -zu | tr '\0' '\n')"
@@ -2863,71 +3217,36 @@ jobs:
# in scope, and committed, the resolution is publishable.
echo "outcome=fixed" >> "$GITHUB_OUTPUT"
- name: 'Show run artifacts'
if: "${{ always() && steps.prepare.outputs.decision == 'run' }}"
env:
BASE_REF: '${{ steps.prepare.outputs.base_ref }}'
run: |-
git status --short || true
git diff "origin/${BASE_REF}...HEAD" > "${WORKDIR}/pr.diff" || true
for file in context.md address-summary.md no-action.md failure.md pr.diff; do
if [ -f "${WORKDIR}/${file}" ]; then
echo "=============== ${file} ==============="
cat "${WORKDIR}/${file}"
echo
fi
done
- name: 'Upload run artifacts'
if: "${{ always() && steps.prepare.outputs.decision == 'run' }}"
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'qwen-resolve-pr-${{ steps.resolve.outputs.pr_number }}'
path: '${{ env.WORKDIR }}/'
if-no-files-found: 'ignore'
- name: 'Report skipped request'
# always(): the prepare step's EXIT trap writes decision=failed when it
# crashes, but a bare if implicitly requires success() — so without
# always() this step is skipped on the very crash it must report.
if: "${{ always() && (steps.prepare.outputs.decision == 'skip' || steps.prepare.outputs.decision == 'unsupported' || steps.prepare.outputs.decision == 'failed') }}"
env:
GH_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
PR_NUMBER: '${{ steps.resolve.outputs.pr_number }}'
SKIP_REASON: '${{ steps.prepare.outputs.skip_reason }}'
run: |-
set -euo pipefail
{
echo "<!-- qwen-resolve-result -->"
echo "Qwen Code did not run conflict resolution for this request."
echo
echo "${SKIP_REASON}"
} > "${WORKDIR}/report.md"
# Best-effort, matching 'Report result': a transient API error here must
# not abort the step under set -e and swallow the only failure signal.
gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "${WORKDIR}/report.md" ||
echo "::warning::Resolve was skipped, but posting the skip-reason comment failed."
- name: 'Report result'
if: "${{ always() && steps.prepare.outputs.decision == 'run' }}"
if: '${{ always() }}'
env:
GH_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
PUSH_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
PR_NUMBER: '${{ steps.resolve.outputs.pr_number }}'
HEAD_REF: '${{ steps.prepare.outputs.head_ref }}'
HEAD_SHA: '${{ steps.prepare.outputs.head_sha }}'
HEAD_REPO: '${{ steps.prepare.outputs.head_repo }}'
HEAD_REF: '${{ needs.resolve-pr.outputs.head_ref }}'
HEAD_SHA: '${{ needs.resolve-pr.outputs.head_sha }}'
HEAD_REPO: '${{ needs.resolve-pr.outputs.head_repo }}'
# The replay merges the base branch again; without this the step's
# `set -u` aborts on the first ${BASE_REF} — a test pins env coverage.
BASE_REF: '${{ steps.prepare.outputs.base_ref }}'
BASE_REF: '${{ needs.resolve-pr.outputs.base_ref }}'
# Test hook for the replay's fetch URL; empty in production, where
# the replay fetches https://github.com/<repo>.git.
RESOLVE_ORIGIN_URL: ''
OUTCOME: '${{ steps.verify.outputs.outcome }}'
FAILURE_KIND: '${{ steps.verify.outputs.failure_kind }}'
RESOLVE_OUTCOME: '${{ needs.resolve-pr.outputs.agent_outcome }}'
# The attempt that ran the agent; the artifact name cited below
# carries it (this job's own github.run_attempt is one ahead on a
# partial "Re-run failed jobs").
AGENT_RUN_ATTEMPT: '${{ needs.resolve-pr.outputs.agent_run_attempt }}'
# Mirrors timeout-minutes on 'Resolve conflicts'; a test pins the pair.
AGENT_TIMEOUT_MINUTES: '75'
DRY_RUN: '${{ env.DRY_RUN }}'
RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
run: |-
set -euo pipefail
export GIT_TERMINAL_PROMPT=0
mkdir -p "${WORKDIR}"
push_failed=false
# Budget for one agent-written section of the comment. The old 2000
@@ -2974,9 +3293,12 @@ jobs:
# (possibly fork) head branch and then runs in that repo's Actions. This
# is bounded by the no-token agent, the scope guard (only base-changed
# files), and write+ maintainer authorization, and it lands in the
# contributor's own CI context — not this repo's.
# contributor's own CI context — not this repo's. The push runs on a
# runner the agent never touched (this job), from a checkout whose
# refs came from GitHub and whose only agent-supplied input is the
# verified bundle, so nothing the agent could plant reaches it.
push_resolution() {
git push \
git push --no-verify \
--force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" \
"https://x-access-token:${PUSH_TOKEN}@github.com/${HEAD_REPO}.git" \
"HEAD:refs/heads/${HEAD_REF}" 2> "$push_log"
@@ -3040,9 +3362,8 @@ jobs:
local markers merge_base base_changed replay_changed out_of_scope
# The replay commits (`git commit -C` below reuses the agent's
# author and message; the committer comes from the environment).
# The identity 'Prepare pull request branch' wrote lives in the
# workspace .git/config, which the post-agent scrub removes before
# this step — so supply it here, never from config.
# The publish job's fresh checkout carries no committer identity —
# supply it here, never from config.
export GIT_COMMITTER_NAME='qwen-code-dev-bot'
export GIT_COMMITTER_EMAIL='[email protected]'
# Conflicted filenames are literal paths, not glob patterns: a file
@@ -3051,10 +3372,10 @@ jobs:
export GIT_LITERAL_PATHSPECS=1
resolved_commit="$(git rev-parse HEAD)"
new_ref="refs/remotes/origin/qwen-resolve/pr-${PR_NUMBER}/moved"
# Fetch by URL, not by remote name: the post-agent scrub removes
# the workspace .git/config (and with it the `origin` remote)
# before any credentialed step runs. RESOLVE_ORIGIN_URL exists so
# the fixture test can point this at a local bare repository.
# Fetch by URL, not by remote name: the replay must not depend on
# the checkout's remote configuration, and RESOLVE_ORIGIN_URL
# exists so the fixture test can point this at a local bare
# repository.
git fetch "${RESOLVE_ORIGIN_URL:-https://github.com/${REPO}.git}" "+refs/pull/${PR_NUMBER}/head:${new_ref}" || { replay_give_up "could not fetch the new head."; return 1; }
new_sha="$(git rev-parse "$new_ref")"
if [ "$new_sha" = "$HEAD_SHA" ]; then
@@ -3150,22 +3471,22 @@ jobs:
if [ "$push_failed" = "true" ]; then
case "$push_fail_reason" in
workflow_scope)
echo "Qwen Code resolved the merge conflicts, but could not push to \`${HEAD_REPO}\`: resolving merges the base branch in, which includes its \`.github/workflows/**\` changes, and GitHub blocks a token without the **\`workflow\`** scope from updating workflow files. A maintainer needs to grant that scope to the push bot (classic PAT: check \`workflow\`; fine-grained PAT: Workflows → Read and write), then re-run /resolve. The resolved diff is attached as the \`qwen-resolve-pr-${PR_NUMBER}\` artifact on the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
echo "Qwen Code resolved the merge conflicts, but could not push to \`${HEAD_REPO}\`: resolving merges the base branch in, which includes its \`.github/workflows/**\` changes, and GitHub blocks a token without the **\`workflow\`** scope from updating workflow files. A maintainer needs to grant that scope to the push bot (classic PAT: check \`workflow\`; fine-grained PAT: Workflows → Read and write), then re-run /resolve. The resolved diff is attached as the \`qwen-resolve-pr-${PR_NUMBER}-attempt-${AGENT_RUN_ATTEMPT}\` artifact on the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
;;
permission)
echo "Qwen Code resolved the merge conflicts, but could not push to \`${HEAD_REPO}\`. For a fork PR this needs **Allow edits by maintainers** enabled, and GitHub blocks maintainer edits on forks owned by an organization. The resolved diff is attached as the \`qwen-resolve-pr-${PR_NUMBER}\` artifact on the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
echo "Qwen Code resolved the merge conflicts, but could not push to \`${HEAD_REPO}\`. For a fork PR this needs **Allow edits by maintainers** enabled, and GitHub blocks maintainer edits on forks owned by an organization. The resolved diff is attached as the \`qwen-resolve-pr-${PR_NUMBER}-attempt-${AGENT_RUN_ATTEMPT}\` artifact on the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
;;
moved)
echo "Qwen Code resolved the merge conflicts, but the head branch changed while resolving, so the update was not pushed. Re-run /resolve. The resolved diff is attached as the \`qwen-resolve-pr-${PR_NUMBER}\` artifact on the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
echo "Qwen Code resolved the merge conflicts, but the head branch changed while resolving, so the update was not pushed. Re-run /resolve. The resolved diff is attached as the \`qwen-resolve-pr-${PR_NUMBER}-attempt-${AGENT_RUN_ATTEMPT}\` artifact on the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
;;
*)
echo "Qwen Code resolved the merge conflicts, but pushing to \`${HEAD_REPO}\` failed. The resolved diff is attached as the \`qwen-resolve-pr-${PR_NUMBER}\` artifact on the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
echo "Qwen Code resolved the merge conflicts, but pushing to \`${HEAD_REPO}\` failed. The resolved diff is attached as the \`qwen-resolve-pr-${PR_NUMBER}-attempt-${AGENT_RUN_ATTEMPT}\` artifact on the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
;;
esac
elif [ "$DRY_RUN" = "true" ]; then
echo "Qwen Code resolved the merge conflicts in dry-run mode. No branch update was pushed."
elif [ -n "$replayed_on" ]; then
echo "Qwen Code resolved the merge conflicts and pushed the branch update. The head branch moved to \`${replayed_on}\` while the conflicts were being resolved; the resolution was replayed on top of it and re-checked before the push — where a file still conflicted and the new commits had not touched it, the replay took the agent's version. The pushed branch is that replay: its diff against \`${BASE_REF}\` is attached as the \`qwen-resolve-pr-${PR_NUMBER}-pushed\` artifact on the [workflow run](${RUN_URL}); the \`qwen-resolve-pr-${PR_NUMBER}\` artifact describes the original resolution it was replayed from."
echo "Qwen Code resolved the merge conflicts and pushed the branch update. The head branch moved to \`${replayed_on}\` while the conflicts were being resolved; the resolution was replayed on top of it and re-checked before the push — where a file still conflicted and the new commits had not touched it, the replay took the agent's version. The pushed branch is that replay: its diff against \`${BASE_REF}\` is attached as the \`qwen-resolve-pr-${PR_NUMBER}-pushed\` artifact on the [workflow run](${RUN_URL}); the \`qwen-resolve-pr-${PR_NUMBER}-attempt-${AGENT_RUN_ATTEMPT}\` artifact describes the original resolution it was replayed from."
else
echo "Qwen Code resolved the merge conflicts and pushed the branch update."
fi
@@ -3178,16 +3499,32 @@ jobs:
append_safe_file "${WORKDIR}/no-action.md"
;;
*)
echo "Qwen Code attempted to resolve merge conflicts but the run did not complete successfully."
echo
for file in failure.md address-summary.md no-action.md; do
if [ -s "${WORKDIR}/${file}" ]; then
echo "### ${file}"
append_safe_file "${WORKDIR}/${file}"
echo
fi
done
echo "Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for full logs."
if [ "$FAILURE_KIND" = "artifact_missing" ]; then
# The agent SUCCEEDED: the infra wording below would blame
# a run that did not fail, and a partial re-run cannot
# bring the artifact back. Say the agent finished, and
# point at the one recovery that re-produces the artifact.
echo "Qwen Code's conflict-resolution agent finished successfully, but its run artifact never reached the publish job — the upload failed or the artifact expired — so the result could not be verified or published. This is not a verdict on the conflict. Use **Re-run all jobs** on the [workflow run](${RUN_URL}) to run the agent again and publish a fresh result; re-running only the failed jobs cannot recover the missing artifact."
elif [ "$FAILURE_KIND" = "infra" ]; then
# The agent never reached a verdict: outcome=failure (CLI
# install, model endpoint, crash) or outcome=cancelled (its
# step timeout). Say which, and do not invite a re-run: the
# generic text below reads as "the model gave up", and 13
# days of re-runs against one broken install (#9898) is what
# this branch exists to stop.
echo "Qwen Code could not run conflict resolution on this PR: the agent step ended with \`outcome=${RESOLVE_OUTCOME}\` before producing a result — a CLI install, model endpoint or infrastructure failure, or the step's ${AGENT_TIMEOUT_MINUTES}-minute timeout. This is not a verdict on the conflict. Requesting /resolve again will fail the same way until the cause shown in the [workflow run](${RUN_URL}) is fixed."
else
echo "Qwen Code attempted to resolve merge conflicts but the run did not complete successfully."
echo
for file in failure.md address-summary.md no-action.md; do
if [ -s "${WORKDIR}/${file}" ]; then
echo "### ${file}"
append_safe_file "${WORKDIR}/${file}"
echo
fi
done
echo "Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for full logs."
fi
;;
esac
} > "${WORKDIR}/report.md"
@@ -3205,13 +3542,13 @@ jobs:
fi
# Only populated when a moved head was replayed: the tree that was
# actually pushed, beside the `qwen-resolve-pr-N` artifact that holds
# the original resolution. Keeps the always()/decision gate of the
# first upload so it still runs when 'Report result' exits 1.
# actually pushed, beside the attempt-suffixed run artifact that holds
# the original resolution. always() so it still runs when 'Report
# result' exits 1; the job-level gate already carries the decision check.
- name: 'Upload pushed tree'
if: "${{ always() && steps.prepare.outputs.decision == 'run' }}"
if: '${{ always() }}'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'qwen-resolve-pr-${{ steps.resolve.outputs.pr_number }}-pushed'
name: 'qwen-resolve-pr-${{ needs.resolve-pr.outputs.pr_number }}-pushed'
path: '${{ env.WORKDIR }}/pushed/'
if-no-files-found: 'ignore'
@@ -283,6 +283,38 @@ jobs:
echo "scheduled_limit=${SCHEDULED_LIMIT_INPUT}" >> "${GITHUB_OUTPUT}"
echo "Issue follow-up state: event=${EVENT_NAME} dispatch_dry=${DISPATCH_DRY_RUN} schedule_dry=${SCHEDULE_DRY_RUN} resolved_dry_run=${dry_run} scheduled_limit=${SCHEDULED_LIMIT_INPUT}"
- name: 'Install pinned Qwen CLI'
id: 'install_qwen'
env:
# Pinned, not `latest`: the CLI is installed from npm at run
# time, and every consumer following the dist-tag died on
# `npm error notarget` when it moved to an unresolvable version
# on 2026-08-15. resolve-pr pins the same version for the same
# reason; bump the two together.
QWEN_CLI_VERSION: '0.21.10'
run: |-
set -euo pipefail
# Job-local, not `npm install -g`: this job's default lane is
# the ecs-qwen pool, whose system-wide CLI
# update-ecs-runner-qwen.yml maintains at `latest` for the
# agent fleet — a global install here would fight that
# contract. The pinned copy is prepended to PATH instead, so it
# outranks the fleet CLI on ecs-qwen and is the only CLI on the
# ubuntu-latest fallback lane. The action's 'Install Qwen Code'
# step skips when `qwen` is already on PATH (verified at the
# pinned action SHA), so the agent runs THIS install on both
# lanes: an input-only pin never bound ecs-qwen, the exact
# outage class this step exists to prevent.
install_dir="${RUNNER_TEMP}/qwen-cli"
npm install --prefix "${install_dir}" --loglevel=error --no-audit \
--registry=https://registry.npmjs.org \
"@qwen-code/qwen-code@${QWEN_CLI_VERSION}"
echo "${install_dir}/node_modules/.bin" >> "${GITHUB_PATH}"
qwen_bin="${install_dir}/node_modules/.bin/qwen"
installed_version="$("${qwen_bin}" --version)"
echo "version=${installed_version}" >> "${GITHUB_OUTPUT}"
echo "Installed qwen ${installed_version} at ${install_dir}"
- name: 'Run Qwen issue follow-up'
uses: 'QwenLM/qwen-code-action@05f81718976f3b7da657422e6e8e4d372b8621d7'
env:
@@ -297,6 +329,11 @@ jobs:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ vars.QWEN_PR_REVIEW_MODEL }}'
# The install step above already put the pinned CLI first on
# PATH on both lanes, and the action's own install skips when
# `qwen` is present — this declares the installed version to
# the action, so the input can never drift from what runs.
qwen_cli_version: '${{ steps.install_qwen.outputs.version }}'
# The input name is `settings` — this action version has no
# `settings_json` input, and an unknown input is silently dropped,
# which is what happened to this block until the rename.
+8
View File
@@ -446,10 +446,14 @@ jobs:
npm run bundle
- name: 'Run CLI Integration Tests'
env:
RUNNER_ENVIRONMENT: '${{ runner.environment }}'
run: |-
npm run test:integration:cli:sandbox:none
- name: 'Run Interactive Integration Tests'
env:
RUNNER_ENVIRONMENT: '${{ runner.environment }}'
run: |-
npm run test:integration:interactive:sandbox:none
@@ -528,12 +532,16 @@ jobs:
npm run build:sandbox -- -s
- name: 'Run CLI Docker Integration Tests'
env:
RUNNER_ENVIRONMENT: '${{ runner.environment }}'
run: |-
# The package.json docker test scripts each rebuild the sandbox image.
# Run vitest directly here so this job reuses the image built above.
QWEN_SANDBOX=docker npx vitest run --root ./integration-tests cli
- name: 'Run Interactive Docker Integration Tests'
env:
RUNNER_ENVIRONMENT: '${{ runner.environment }}'
run: |-
QWEN_SANDBOX=docker npx vitest run --root ./integration-tests interactive
@@ -0,0 +1,85 @@
# Web Shell workspace overview (sidebar)
Issue: https://github.com/QwenLM/qwen-code/issues/10399
Design: `docs/design/web-shell/web-shell-workspace-overview.md`
## Baseline
- `main` @ d853f09f: a workspace row shows the folder name, untrusted /
read-only badges and a git chip. The `⋮` menu has one entry, Remove
workspace, and only on removable secondary workspaces.
- The daemon already answers `GET /workspaces/:w/{mcp,skills,extensions,channels,memory,hooks}`
and `PATCH /workspaces/:w` (rename) and `POST /workspaces/:w/reload`.
## Local setup
1. `npm run build` from the repository root so `dist/cli.js` bundles the
Web Shell from this branch.
2. Isolated `QWEN_HOME` with a settings.json that registers one stdio MCP
server that cannot start (for example `command: /nonexistent`) plus one
that can, and at least one project skill under `.qwen/skills`.
3. `node dist/cli.js serve --workspace <primary> --workspace <secondary>`
on loopback; open the served Web Shell.
## Scenarios
### Header counts and chips
Expand the primary workspace with one session running a prompt.
Expected:
- The folder header shows `●1` (success tone) and the total session count; a
session waiting for permission adds a warning-tone count.
- Under the header: the full path, then chips `MCP a/b · Skills n ·
Extensions n · Channels n · Context n`. The MCP chip is warning-toned and
its tooltip names the failed server count.
- Before the ACP child has initialized (fresh daemon, no session yet) the
MCP / Skills chips show `—` with tooltip "not initialized yet", never `0`.
The Context chip is answered by the daemon from disk and settles at its
file count (`0` for a workspace without a QWEN.md) as soon as the first
round lands.
### Request gating
With the Network panel filtered to `/workspaces/`:
- A collapsed workspace issues no facet requests.
- An expanded one issues one request per facet on expand, on window focus and
every 30 s while the tab is visible; nothing while the tab is hidden.
### Workspace menu
Hover a trusted secondary workspace and open `⋮`.
Expected:
- Rename… (only when the daemon advertises `dynamic_workspace_registration`),
Copy path, New task, New worktree task, Reload runtime, Remove workspace.
- Rename opens a dialog prefilled with the current display name; saving
updates the row label without a reload; clearing the name restores the
folder name.
- Copy path puts the absolute path on the clipboard.
- Reload runtime issues `POST /workspaces/<cwd>/reload` and refreshes the
chips.
- New worktree task opens a new draft with the composer's git mode set to
worktree.
On the primary workspace the menu additionally shows a Manage group (MCP
servers, Skills, Extensions, Channels, Settings) with the chip counts; each
opens the corresponding page. Secondary workspaces do not get the group.
### Embedding switch
Mount `<WebShell sidebar={{ workspaceOverview: false }} />`.
Expected: folder headers, path line, chips and Projects count are gone; the
`⋮` menu still offers the non-overview actions.
## Automated coverage
- `packages/web-shell/client/components/sidebar/{workspaceOverviewModel,useWorkspaceOverview,WorkspaceOverview,WorkspaceMenu,WorkspaceRenameDialog}.test.*`
- `packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx`
(menu gating, rename, reload, management targets)
- `packages/web-shell/client/e2e/web-shell.workspace-overview.spec.ts`
(chips, counts, request gating, menu contents against the mock daemon)
@@ -0,0 +1,141 @@
# Surface provider error detail in `turn_error` messages
Date: 2026-08-30
Status: proposed
## Problem statement
When a daemon-hosted prompt turn fails because the model provider rejects the
request, the Web Shell shows the bare text `Internal error`. The actual
provider reason (e.g. `The engine is currently overloaded, please try again
later`) never reaches any user-visible surface, so users cannot distinguish a
transient provider overload from a genuine daemon bug. Observed in production
on session `eeab9c1c-305d-4259-8a37-2ef8d07ca934`: four turn failures, all
displayed as `Internal error`, all caused by the upstream
`engine_overloaded_error` body.
## Current state
The error travels through five layers; the detail is dropped at the third:
1. **core (agent child).** The provider returns the error as a stream chunk
(`finish_reason: "error_finish"`, JSON body in `delta.content`). The
pipeline throws `StreamContentError` whose `.message` is the raw upstream
JSON string
(`packages/core/src/core/openaiContentGenerator/pipeline.ts`).
2. **ACP SDK (agent child).** The catch-all in `#tryCallRequestHandler`
serializes a non-`RequestError` throw as
`RequestError.internalError(JSON.parse(error.message))` — i.e. wire shape
`{code: -32603, message: 'Internal error', data: <parsed message>}`.
For a JSON message like ours this yields
`data: {error: {message, type}}`; for a plain-text message that fails
`JSON.parse` it yields `data: {details: <message>}`
(`@agentclientprotocol/sdk`, `acp.js`).
3. **acp-bridge (daemon).** `broadcastTurnError`
(`packages/acp-bridge/src/bridge.ts`) builds the `turn_error` SSE event
with `message = extractErrorMessage(err)`. `extractJsonRpcErrorDetail`
today reads a string `data`, `data.details`, and `data.message` — but
**not** the nested `data.error.message` the JSON-parsed shape produces.
It therefore falls back to `err.message`, the generic `Internal error`.
4. **sdk-typescript.** `turn_error` validates with only
`sessionId` + `message` required and normalizes to a
`DaemonUiErrorEvent` whose `text` is that message.
5. **webui.** `turn_error` stays in the transcript (never routes to notices)
and renders as a `system_error` block whose text is the event message.
Because the plain-text shape (`data.details`) already surfaces through the
existing extractor, only the JSON-parsed shape loses its detail. The gap is a
missing branch in one helper, not an absent wire field.
`extractErrorMessage` has eight call sites, all in `bridge.ts`:
| Call site | Surface |
| ------------------------------------------- | --------------------------------------------------- |
| `broadcastTurnError` | `turn_error`, `entry.turnError`, and refresh replay |
| timed-out `newSession` cleanup quarantine | daemon stderr |
| two `model_switch_failed` publishers | SSE event message |
| approval-mode restore | daemon stderr |
| timed-out session-action cleanup quarantine | daemon stderr |
| `sendPrompt` forward failure | daemon stderr |
| pending-prompt cancel-forward failure | daemon stderr |
`transcript-replay.ts` defines a separate local helper with the same name;
it is not a call site of the bridge helper and is untouched by this change.
`classifyTurnErrorKind` exact-matches the message against `terminated`; that
error arrives via the plain-text `data.details` shape and is unaffected by
adding a nested-`error` branch.
## Proposed change
Extend `extractJsonRpcErrorDetail` in `packages/acp-bridge/src/bridge.ts`
with one more fallback: after the existing `data` string / `data.details` /
`data.message` checks, read a nested `data.error` — accepting either a plain
string or an object with a string `message` while preserving the existing
top-level precedence. Update the `extractErrorMessage` doc comment to name the
`data.error.message` shape as the ACP SDK's JSON-parsed-message artifact.
That is the entire production change. `broadcastTurnError` then publishes the
provider's own message as `turn_error.data.message`. The Web Shell transcript
error block, live-state `turnError` summary, refresh replay, and SDK consumers
of that `turn_error` (including `DaemonHttpError`) then receive the real reason
with no wire schema change and no SDK or webui edits. The separate
turn-status overlay normalization path is unchanged.
Result for the motivating failure: the transcript shows
`The engine is currently overloaded, please try again later` instead of
`Internal error`.
## Key decisions
- **Fix at message extraction, not an additive wire field.** An alternative
considered was adding `turn_error.data.detail` and plumbing it through
`DaemonTurnErrorData`, the UI normalizer, transcript block types, and the
webui adapter. That preserves the generic `message` for hypothetical
string matchers but costs four packages of churn for the same user-visible
result. The eight bridge call sites above are display, log, or event
surfaces. The only behavioral check on the extracted message recognizes
`terminated`, which already arrives through `data.details`, so enriching
the nested shape is safe and minimal.
- **Preserve existing precedence.** A string `data`, top-level `details`, or
top-level `message` continues to win over the new nested `error.message`
fallback.
- **No new length bound.** The existing `data.details` path is already
unbounded, so a pathological provider blob flows today; this change keeps
parity instead of inventing a second policy. Render-side control-character
sanitization (`sanitizeDaemonTerminalText`) and event-bus frame byte
accounting already apply. A bound can be revisited as its own change.
- **No new `errorKind`.** `DaemonErrorKind` stays closed; this change only
improves human-readable text. A structured kind (e.g. provider-overload)
belongs with retry-classification work, not text plumbing.
## Files affected
| File | Change |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `packages/acp-bridge/src/bridge.ts` | nested `data.error` branch in `extractJsonRpcErrorDetail`; doc comment |
| `packages/acp-bridge/src/bridge.test.ts` | new `extractErrorMessage` cases: nested `error.message`, string `error`, precedence over/absence of top-level keys, non-JSON fallback unchanged |
| `integration-tests/fake-openai-server.ts` | test infrastructure: `errorContent` choice field emitting a single `error_finish` chunk (error body and finish reason on the same chunk, matching real gateways) |
| `integration-tests/fake-openai-server.test.ts` | self-test pinning the same-chunk invariant |
| `integration-tests/cli/qwen-serve-streaming.test.ts` | regression case: fake OpenAI server emits an `error_finish` chunk with a JSON error body; assert the session SSE `turn_error` `message` carries the provider text |
| `docs/developers/daemon/09-event-schema.md` | `turn_error` row: note that `message` may carry provider-supplied detail for `-32603` agent failures |
## Scope boundaries
- No retry-behavior change. Classifying `engine_overloaded_error` as
retryable (503-equivalent) is a separate, independent follow-up.
- No change to `matchTurnEvent`, `normalizeTurnResultError`, the prompt
ledger record shape, or the Java SDK.
- TUI (non-daemon) error rendering is untouched; it does not traverse the
bridge extractor.
- No `DaemonErrorKind` additions and no Web Shell UI restructuring.
## Open questions
- Should the separate `normalizeTurnResultError` path used by turn-status
polling also extract provider detail or carry the provider `type` (e.g.
`engine_overloaded_error`) as a structured code? Deferred — this PR only
changes bridge extraction for `turn_error`; the terminal overlay may still
retain the generic `Internal error`.
- If provider blobs prove unwieldy in practice, where should the length cap
live — the bridge extractor or the event publisher? Deferred until observed.
@@ -0,0 +1,145 @@
# Web Shell workspace overview
Issue: https://github.com/QwenLM/qwen-code/issues/10399
## Goal
Make a workspace a first-class object in the Web Shell sidebar: show what it
contains and whether it is healthy without opening a session, and put every
workspace-level action behind its own menu. This is layer A of the plan in the
issue — a frontend-only change on top of daemon routes that already exist.
## Behavior
### Folder header
- The header keeps its name, badges and git chip. It gains session counts at
its right edge: sessions waiting on the user (warning tone), sessions with a
prompt in flight (success tone), and the total. A total from a truncated
catalog page shows as `N+`. Collapsing a row disables its catalog query, so
the row keeps the last counts it computed: they stay visible while collapsed
and refresh on the next expand. While the query is active and a page is
missing (a session-source switch), no counts are shown rather than stale
ones above an empty list.
- The name carries the full path as a tooltip. While the section is expanded
the path is printed under the header.
- The Projects label shows the number of registered workspaces once there is
more than one.
### Facet chips
While a trusted workspace is expanded, a chip row summarizes MCP servers
(`connected/enabled`), skills (enabled), extensions (active, or
`active/total` when they differ), channels (`connected/configured`) and
context files (count). Hooks are available but off by default.
- MCP, skills and hooks are discovered by the workspace's ACP child. Until it
reports `initialized`, the chip shows `—` and the tooltip says the runtime
is not initialized yet. A placeholder is never rendered as `0`.
- Context files are read from disk by the daemon itself, so its answer is
always definitive: a workspace without a QWEN.md shows `0`.
- The MCP chip takes the warning tone when a server errored or discovery
finished with an enabled server still not connected; the channels chip when
an instance is in the error state.
- Below the sidebar's tight width the chips drop their text labels.
- Extensions, channels and context files are daemon-side facets. When they
are unknown the daemon lacks the route or the fetch failed, so their tooltip
says "unavailable on this daemon" rather than "not initialized yet". The
chip row itself appears only once the first fetch round has landed, so a
round still in flight never reads as a missing route.
- Chips are read-only. Opening a management page is a menu action, so the
chips never take the button role and their accessible names cannot collide
with the navigation buttons that share the same words.
### Workspace menu
The hover `⋮` on a workspace row replaces the single-item removal menu:
- Rename… (dynamic registration daemons, on registration-backed rows only —
the daemon's bound workspace has no registration to persist a name in;
opens a dialog; an empty name falls back to the folder name and control
characters are refused before the request is sent), Copy path, New task,
New worktree task (only when
the git poll reports a branch — a worktree needs a repository, and the
composer would otherwise have no chip to show or undo the armed intent).
- Manage: MCP servers, Skills, Extensions, Channels, Settings, with the chip
counts next to the first four (part of the item's accessible name). The
menu keeps the row's last snapshot while the row is collapsed, so the
counts do not vanish on collapse; the action area stays visible while its
menu is open so Escape returns focus to the trigger.
- Reload runtime (`POST /workspaces/:w/reload`), then Remove workspace.
Each entry appears only when the workspace's state allows it: untrusted rows
that cannot be removed still show nothing, locked-workspace renderers still
suppress the whole action area.
The Manage group is offered on the daemon's primary workspace only. The
management pages read the connection's bound workspace, so a secondary row
cannot open its own view yet; that is layer B1 of the issue.
## Data flow
`useWorkspaceOverview(client, cwd, { enabled, items })` fans out over
`client.workspaceByCwd(cwd)` to `/mcp`, `/skills`, `/extensions`,
`/channels`, `/memory` and `/hooks`, one request per requested facet. Each
call fails independently — an older daemon without a route, a transient
error, or a malformed body — leaves that facet `undefined` and keeps the
others. A facet keeps its last known value across up to three consecutive
unanswered rounds, then reads as unavailable, so a route that stays gone
after a rollback cannot freeze a stale count on the chip. Rounds that time
out after the next tick has already replaced them still count: the SDK's
request deadline equals the poll cadence, so during a daemon hang every
round is superseded before it lands, and only their observed misses can
expire the facet. The budget is scoped to one bookkeeping session: a reset
boundary (a cwd change, the section collapsing) advances an epoch, and a
round launched before the boundary can neither book misses into the fresh
session nor refill it with a stale success.
Fetching is gated on the section being expanded, the workspace trusted and the
default header rendered (a locked sidebar's custom header has no chip or menu
to feed), and polls every 30 s only while the document is visible, plus a
refetch on window focus and on the sidebar's reload token. Collapsed rows cost
nothing, and a synthetic fallback workspace without a real cwd is never asked.
Measured against the mock daemon (`npm run dev`, React StrictMode, 5 trusted
workspaces all expanded, tab visible): after the initial round the sidebar
issued 25 facet requests per 30 s tick — 50 over 60 s — next to the 33
session-catalog and git-status requests the same rows already made in that
window. Without StrictMode the initial round is 25 requests, not 50. That is
the cost the layer-C overview endpoint collapses to 5 per tick.
Session counts come from the catalog page the row already lists; the primary
workspace, whose sessions the sidebar lists itself, gets its counts passed in.
## Embedding
- `sidebar.workspaceOverview: false` keeps the plain folder headers;
`{ items: [...] }` selects the chips.
- `onOpenWorkspaceManagement(target, workspaceCwd)` and
`onNewWorktreeSession(workspaceCwd)` are new sidebar callbacks; the app
wires them to `openPanel` and to `createNewSession` with a worktree git
intent. The intent is set in the same synchronous step that clears the
previous one, so it belongs to that draft from the start: a prompt
submitted while the clear is still in flight gets the worktree, and any
later session start or draft workspace switch resets it like any other
intent. An armed intent survives a transient git-status gap — only a
session, an untrusted workspace, a no-branch answer for the draft's own
workspace (the status is keyed by `workspaceCwd`, so the answer of a
workspace being left never clears an intent armed for the new one) or a
draft workspace switch clears it; re-selecting the draft's own workspace
from the composer picker is a no-op, and an intent set while a session
already exists is cleared immediately.
## Follow-ups (layers B and C in the issue)
- A Trust… menu entry. `POST /workspaces/:w/trust/request` only records a
request that needs operator action and a daemon restart
(`accepted: false, requiresOperatorAction: true`), so an entry today would
promise a change it cannot make; it needs an "operator action required"
feedback surface first.
- Bind the management pages to a chosen workspace so every row can open its
own MCP / Skills / Extensions view.
- A Workspaces overview page with a table across workspaces.
- `GET /workspaces/:w/overview` on the daemon to collapse the fan-out into one
request, advertised as `workspace_overview`, once the workspace-runtime
stack has landed.
+10 -10
View File
@@ -126,16 +126,16 @@ These events are workspace-keyed, not session-keyed. The session reducer treats
### Turn lifecycle / assistant pushes
| Type | Direction | Trigger | Key payload fields |
| --------------------- | --------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prompt_cancelled` | S->C | Prompt was cancelled through explicit `cancelSession` route **or** originator SSE disconnect | Envelope stamps `originatorClientId` for the canceling client. This means "cancellation requested", not "cancellation confirmed". Peer subscribers learn that the prompt has ended. |
| `turn_complete` | S->C | A turn completed successfully | `sessionId, stopReason, promptId?, branchPoint?`. `promptId` links to non-blocking prompt responses (`202`). Eligible completed turns include `branchPoint: { assistantRecordUuid, checkpointUuid }`. |
| `turn_error` | S->C | A turn failed | `sessionId, message, code?, promptId?`; same `promptId` correlation mechanism. |
| `session_rewound` | S->C | `POST /session/:id/rewind` succeeded | `sessionId, promptId, targetTurnIndex, filesChanged[], filesFailed[], originatorClientId?` |
| `session_branched` | S->C | Legacy compatibility event; the current branch endpoint returns its result directly and does not publish this event | `sourceSessionId, newSessionId, displayName, originatorClientId?`. Readers retain support for older producers. |
| `followup_suggestion` | S->C | ACP child generated ghost-text follow-up suggestions after `end_turn`, forwarded over per-session SSE | `sessionId, suggestion, promptId`; wire only carries suggestions whose `getFilterReason()===null`. Clients render them as input-placeholder ghost text and invalidate them on next `sendPrompt`. |
| `user_shell_command` | S->C | User started a shell command through `POST /session/:id/shell`; fanned out to other subscribers in the same session | `sessionId, command, shellId, originatorClientId?`. There is no typed `DaemonXxxData` interface yet; `asKnownDaemonEvent` returns `undefined` and the UI normalizer parses it ad hoc. |
| `user_shell_result` | S->C | Result of the shell command above | `sessionId, shellId, exitCode, output, aborted`. Same ad hoc parsing note as `user_shell_command`. |
| Type | Direction | Trigger | Key payload fields |
| --------------------- | --------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prompt_cancelled` | S->C | Prompt was cancelled through explicit `cancelSession` route **or** originator SSE disconnect | Envelope stamps `originatorClientId` for the canceling client. This means "cancellation requested", not "cancellation confirmed". Peer subscribers learn that the prompt has ended. |
| `turn_complete` | S->C | A turn completed successfully | `sessionId, stopReason, promptId?, branchPoint?`. `promptId` links to non-blocking prompt responses (`202`). Eligible completed turns include `branchPoint: { assistantRecordUuid, checkpointUuid }`. |
| `turn_error` | S->C | A turn failed | `sessionId, message, code?, promptId?`; same `promptId` correlation mechanism. For agent-side `-32603` failures `message` carries the provider's own detail when the agent supplied one (`data.details` / `data.message` / nested `data.error.message`), not the generic JSON-RPC text. |
| `session_rewound` | S->C | `POST /session/:id/rewind` succeeded | `sessionId, promptId, targetTurnIndex, filesChanged[], filesFailed[], originatorClientId?` |
| `session_branched` | S->C | Legacy compatibility event; the current branch endpoint returns its result directly and does not publish this event | `sourceSessionId, newSessionId, displayName, originatorClientId?`. Readers retain support for older producers. |
| `followup_suggestion` | S->C | ACP child generated ghost-text follow-up suggestions after `end_turn`, forwarded over per-session SSE | `sessionId, suggestion, promptId`; wire only carries suggestions whose `getFilterReason()===null`. Clients render them as input-placeholder ghost text and invalidate them on next `sendPrompt`. |
| `user_shell_command` | S->C | User started a shell command through `POST /session/:id/shell`; fanned out to other subscribers in the same session | `sessionId, command, shellId, originatorClientId?`. There is no typed `DaemonXxxData` interface yet; `asKnownDaemonEvent` returns `undefined` and the UI normalizer parses it ad hoc. |
| `user_shell_result` | S->C | Result of the shell command above | `sessionId, shellId, exitCode, output, aborted`. Same ad hoc parsing note as `user_shell_command`. |
## Architecture
+12 -3
View File
@@ -50,7 +50,7 @@ independent.
## Linux: systemd user unit
> **Find your `qwen` binary first.** The unit file's `ExecStart=` must hold an **absolute path** — service managers don't read your shell's `PATH`. Run `which qwen` to discover it. Common locations: `/usr/local/bin/qwen` (Linuxbrew, manual installs), `~/.nvm/versions/node/vX.Y.Z/bin/qwen` (nvm), `~/.fnm/aliases/default/bin/qwen` (fnm), `~/.volta/bin/qwen` (Volta). Substitute the actual path everywhere the templates below show `/PATH/TO/qwen`.
> **Find your `qwen` binary and trusted tool directories first.** The unit file's `ExecStart=` must hold an **absolute path**, and its explicit `PATH` must include trusted directories for tools that daemon sessions need, such as `gh`, `git`, `npm`, and the `node` interpreter used by a script-based `qwen` launcher. Service managers don't read your shell profile. Run `which qwen gh git npm node` in your normal shell, then substitute the actual executable and directories everywhere the template below shows `/PATH/TO/qwen` and `/PATH/TO/USER/BIN`.
`~/.config/systemd/user/qwen-serve.service`:
@@ -65,6 +65,9 @@ Type=simple
WorkingDirectory=%h/project-a
# Run `which qwen` to find the absolute path. systemd does NOT read $PATH.
ExecStart=/PATH/TO/qwen serve --hostname 127.0.0.1 --port 4170 --workspace %h/project-a --workspace %h/project-b
# Replace the first entry with trusted directories that contain qwen's
# interpreter and user-installed tools. systemd does not read shell profiles.
Environment=PATH=/PATH/TO/USER/BIN:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
# Read the bearer token from a chmod 600 file rather than inlining it
# in the unit. `Environment=` would expose the token in the unit file
# (typically 644 = world-readable). EnvironmentFile keeps the token in
@@ -99,11 +102,11 @@ systemctl --user disable --now qwen-serve.service
Without `loginctl enable-linger`, the user-level systemd instance shuts down when the user logs out and only restarts on next login — on a headless dev box the daemon would not survive an SSH session ending. `enable-linger` is what makes "across reboots" actually work.
**System-wide alternative** (shared dev hosts, less common): drop the unit at `/etc/systemd/system/[email protected]` with `User=%i`, manage via `sudo systemctl enable --now qwen-serve@<username>.service`. Same `[Service]` body otherwise — but world-readable `Environment=` exposure is even more problematic at this level, so always use `EnvironmentFile=` pointing at the user's `chmod 600` file. Pick user-level + linger for single-user workstations.
**System-wide alternative** (shared dev hosts, less common): drop the unit at `/etc/systemd/system/[email protected]` with `User=%i`, manage via `sudo systemctl enable --now qwen-serve@<username>.service`. Same `[Service]` body otherwise. The non-sensitive `PATH` can remain in `Environment=`, but never put the bearer token there: use `EnvironmentFile=` pointing at the user's `chmod 600` file. Pick user-level + linger for single-user workstations.
## macOS: launchd user agent
> **Find your `qwen` binary first.** Same constraint as systemd — `ProgramArguments` must hold an **absolute path**. Run `which qwen` to discover it. Common locations on macOS: `/opt/homebrew/bin/qwen` (Homebrew on Apple Silicon), `/usr/local/bin/qwen` (Homebrew on Intel, manual installs), `~/.nvm/versions/node/vX.Y.Z/bin/qwen` (nvm), `~/.volta/bin/qwen` (Volta). Substitute below where the template shows `/PATH/TO/qwen`.
> **Find your `qwen` binary and trusted tool directories first.** Same constraint as systemd: `ProgramArguments` must hold an **absolute path**, while `EnvironmentVariables.PATH` must include trusted directories containing tools daemon sessions need. Run `which qwen gh git npm node` in your normal shell. Common locations on macOS include `/opt/homebrew/bin` (Homebrew on Apple Silicon), `/usr/local/bin` (Homebrew on Intel and manual installs), `~/.nvm/versions/node/vX.Y.Z/bin` (nvm), and `~/.volta/bin` (Volta). Substitute the actual absolute paths below; launchd does not expand `~` or shell variables.
`~/Library/LaunchAgents/com.qwenlm.qwen-serve.plist`:
@@ -133,6 +136,10 @@ Without `loginctl enable-linger`, the user-level systemd instance shuts down whe
<string>/Users/YOUR-USERNAME/project-a</string>
<key>EnvironmentVariables</key>
<dict>
<!-- launchd does not read shell profiles. Replace the first entry with
trusted directories containing qwen's interpreter and user tools. -->
<key>PATH</key>
<string>/PATH/TO/USER/BIN:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<!-- DO NOT COMMIT this file with a real token. Also chmod 600 the
plist itself so the inlined token is not world-readable. -->
<key>QWEN_SERVER_TOKEN</key>
@@ -180,6 +187,8 @@ tail -f ~/Library/Logs/qwen-serve/out.log ~/Library/Logs/qwen-serve/err.log
After editing the plist (e.g., rotating the token) you must `unload` then `load` again — `launchctl` does not auto-reload on plist changes the way `systemd daemon-reload` does. Note: each `load` truncates the log files, so save them off if you're investigating an incident before rotating.
After starting or restarting either service, open a new daemon session and verify that the tools it needs resolve without changing `PATH` inside the command, for example `command -v gh`. If a tool is missing, add its trusted, absolute containing directory to the service-level `PATH` and reload the service; do not rely on `~/.zshrc`, `~/.bashrc`, or another interactive-shell profile.
## tmux session (interactive supervision)
Assumes `QWEN_SERVER_TOKEN` is already exported in your shell (see the setup section above):
@@ -197,6 +197,27 @@ beforeAll(async () => {
return { content: 'The test Todo remains unfinished.' };
}
// Gate out the memory-selection side query (its system prompt starts
// with "You are selecting memories"): it replays the prompt text, so a
// bare marker match would error it too and muddy the request count.
if (
messages.includes('turn-error-provider-detail-e2e') &&
!messages.includes('You are selecting memories')
) {
// Reproduces the production failure shape: the gateway reports an
// upstream overload as a single `error_finish` chunk whose content is
// the provider's JSON error body instead of an HTTP error status.
return {
errorContent: JSON.stringify({
error: {
message:
'The engine is currently overloaded, please try again later',
type: 'engine_overloaded_error',
},
}),
};
}
if (messages.includes('turn-final-answer-boundary-e2e')) {
const toolCallId = 'call_turn_final_answer_boundary';
if (!messages.includes(toolCallId)) {
@@ -537,6 +558,79 @@ describePOSIX('qwen serve — pollable turn results', () => {
}, 60_000);
});
describePOSIX('qwen serve — turn_error provider detail', () => {
it('publishes the provider message as the turn_error message', async () => {
const marker = 'turn-error-provider-detail-e2e';
const session = await client.createOrAttachSession({
workspaceCwd: workspaceDir,
sessionScope: 'thread',
});
const events: DaemonEvent[] = [];
const ac = new AbortController();
let promptId: string | undefined;
const subscriber = (async () => {
try {
for await (const event of sseFrames(session.sessionId, {
signal: ac.signal,
})) {
events.push(event);
const data = event.data as { promptId?: string } | undefined;
if (event.type === 'turn_error' && data?.promptId === promptId) {
break;
}
}
} catch {
/* aborted */
}
})();
const requestStart = fakeServer.requests.length;
try {
await new Promise((resolve) => setTimeout(resolve, 200));
const accepted = asAccepted(
await client.promptNonBlocking(session.sessionId, {
prompt: [{ type: 'text', text: marker }],
}),
);
expect(accepted).toBeDefined();
if (!accepted) return;
promptId = accepted.promptId;
const findTurnError = () =>
events.find((event) => {
if (event.type !== 'turn_error') return false;
const data = event.data as { promptId?: string } | undefined;
return data?.promptId === promptId;
});
await expect.poll(findTurnError, { timeout: 30_000 }).toBeDefined();
const data = findTurnError()!.data as { message?: string };
expect(data.message).toBe(
'The engine is currently overloaded, please try again later',
);
// The JSON error body carries no numeric code, so no rate-limit retry
// fires: the turn must fail after exactly one model request. The
// memory-selection side query also carries the marker text (it replays
// the prompt), so exclude it from the count the same way the fake
// server callback does.
const modelRequests = fakeServer.requests
.slice(requestStart)
.map((request) => JSON.stringify(request.body['messages'] ?? []))
.filter(
(messages) =>
messages.includes(marker) &&
!messages.includes('You are selecting memories'),
);
expect(modelRequests).toHaveLength(1);
} finally {
ac.abort();
await subscriber;
await client.closeSession(session.sessionId).catch(() => undefined);
}
}, 60_000);
});
describePOSIX('qwen serve — child-crash recovery (real SIGKILL)', () => {
it('publishes session_died after the qwen --acp child is SIGKILL-ed', async () => {
const session = await client.createOrAttachSession({
@@ -89,6 +89,43 @@ describe('fake OpenAI server', () => {
expect(server.requests).toHaveLength(2);
});
it('streams errorContent as a single error_finish chunk', async () => {
server = await startFakeOpenAIServer(() => ({
errorContent: '{"error":{"message":"overloaded"}}',
}));
const response = await fetch(`${server.baseUrl}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: 'fake-model',
stream: true,
messages: [{ role: 'user', content: 'hi' }],
}),
});
expect(response.status).toBe(200);
const streamText = await response.text();
expect(streamText).toContain('"finish_reason":"error_finish"');
expect(streamText).toContain('overloaded');
expect(streamText).toContain('data: [DONE]');
// The error body and finish reason must ride the SAME chunk: the CLI
// pipeline reads delta.content off the chunk that carries
// finish_reason === 'error_finish'.
const errorFrame = streamText
.split('\n\n')
.find((frame) => frame.includes('error_finish'));
expect(errorFrame).toBeDefined();
const payload = JSON.parse(
errorFrame!.replace(/^data: /, ''),
) as unknown as {
choices: Array<{ delta: { content?: string } }>;
};
expect(payload.choices[0]?.delta.content).toBe(
'{"error":{"message":"overloaded"}}',
);
});
it('can close response connections for isolated latency measurements', async () => {
server = await startFakeOpenAIServer(
() => ({ content: 'no connection reuse' }),
+15
View File
@@ -29,6 +29,7 @@ export type FakeOpenAIResponse = {
model?: string;
content?: string;
contentChunks?: string[];
errorContent?: string;
disconnectAfterContentChunks?: number;
toolCalls?: FakeOpenAIToolCall[];
finishReason?: 'stop' | 'tool_calls' | 'length';
@@ -47,6 +48,13 @@ export type FakeOpenAIChoice = {
index: number;
content?: string;
contentChunks?: string[];
/**
* Streaming-only mid-stream provider error: emits one chunk carrying this
* string as `delta.content` together with `finish_reason: 'error_finish'`,
* then ends the stream. Mirrors gateways that report upstream failures as
* stream content instead of an HTTP error status.
*/
errorContent?: string;
toolCalls?: FakeOpenAIToolCall[];
finishReason?: 'stop' | 'tool_calls' | 'length';
};
@@ -298,6 +306,12 @@ function writeStreamed(
const choices = responseChoices(message);
for (const [choicePosition, choice] of choices.entries()) {
send(chunk(choice.index, { role: 'assistant' }));
if (choice.errorContent !== undefined) {
send(
chunk(choice.index, { content: choice.errorContent }, 'error_finish'),
);
continue;
}
for (const [contentIndex, content] of (
choice.contentChunks ?? []
).entries()) {
@@ -373,6 +387,7 @@ function responseChoices(message: FakeOpenAIResponse): FakeOpenAIChoice[] {
index: 0,
content: message.content,
contentChunks: message.contentChunks,
errorContent: message.errorContent,
toolCalls: message.toolCalls,
finishReason: message.finishReason,
},
+7 -4
View File
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const timeoutMinutes = Number(process.env['TB_TIMEOUT_MINUTES'] || '5');
const testTimeoutMs = timeoutMinutes * 60 * 1000;
const isSelfHostedRunner = process.env['RUNNER_ENVIRONMENT'] === 'self-hosted';
export default defineConfig({
test: {
@@ -30,8 +31,11 @@ export default defineConfig({
pool: 'forks',
poolOptions: {
forks: {
minForks: 2,
maxForks: 4,
// Each ECS host runs several Actions runners. Keep every E2E shard to
// one child process there so concurrent jobs cannot multiply the host
// load and starve latency-sensitive integration paths.
minForks: isSelfHostedRunner ? 1 : 2,
maxForks: isSelfHostedRunner ? 1 : 4,
},
},
// The worker->main `onTaskUpdate` RPC runs on a 60s budget; under
@@ -44,8 +48,7 @@ export default defineConfig({
// the run; only unhandled errors stop being fatal — github-hosted Linux
// (the nightly isolated legs) and local Linux runs keep the signal.
dangerouslyIgnoreUnhandledErrors:
process.platform !== 'linux' ||
process.env['RUNNER_ENVIRONMENT'] === 'self-hosted',
process.platform !== 'linux' || isSelfHostedRunner,
},
resolve: {
alias: {
+94 -1
View File
@@ -3202,7 +3202,9 @@ describe('createAcpSessionBridge', () => {
availableSkills: [],
});
await expect(
bridge.getSessionTasksStatus(session.sessionId),
bridge.getSessionTasksStatus(session.sessionId, {
includeWorkflows: true,
}),
).resolves.toMatchObject({
sessionId: session.sessionId,
tasks: [],
@@ -3226,6 +3228,10 @@ describe('createAcpSessionBridge', () => {
'qwen/status/session/tasks',
'qwen/status/session/lsp',
]);
expect(handles[0]?.agent.extMethodCalls[2]?.params).toMatchObject({
sessionId: session.sessionId,
includeWorkflows: true,
});
await bridge.shutdown();
});
@@ -24075,6 +24081,19 @@ describe('createAcpSessionBridge', () => {
{ clientId: 'client-not-issued' },
),
).rejects.toBeInstanceOf(InvalidClientIdError);
await expect(
bridge.cancelSessionTask(session.sessionId, 'task-1', 'workflow', {
clientId: 'client-not-issued',
}),
).rejects.toBeInstanceOf(InvalidClientIdError);
await expect(
bridge.controlSessionWorkflowTask(
session.sessionId,
'task-1',
'rerun',
{ clientId: 'client-not-issued' },
),
).rejects.toBeInstanceOf(InvalidClientIdError);
await bridge.shutdown();
});
@@ -29579,6 +29598,80 @@ describe('extractErrorMessage', () => {
).toBe('<503> model serving is throttled');
});
it('extracts nested provider messages from data.error.message', () => {
// Shape the ACP SDK produces when the agent throws an error whose
// message is a JSON string: internalError(JSON.parse(message)).
expect(
extractErrorMessage(
new RequestError(-32603, 'Internal error', {
error: {
message:
'The engine is currently overloaded, please try again later',
type: 'engine_overloaded_error',
},
}),
),
).toBe('The engine is currently overloaded, please try again later');
});
it('extracts string data.error from JSON-RPC error data', () => {
expect(
extractErrorMessage({
code: -32603,
message: 'Internal error',
data: { error: 'plain string detail' },
}),
).toBe('plain string detail');
});
it('prefers top-level data.details over nested data.error.message', () => {
expect(
extractErrorMessage({
code: -32603,
message: 'Internal error',
data: { details: 'top', error: { message: 'nested' } },
}),
).toBe('top');
});
it('prefers top-level data.message over nested data.error.message', () => {
expect(
extractErrorMessage({
code: -32603,
message: 'Internal error',
data: { message: 'top', error: { message: 'nested' } },
}),
).toBe('top');
});
it('falls back to message when data.error has no usable string', () => {
expect(
extractErrorMessage(
new RequestError(-32603, 'Internal error', {
error: { type: 'engine_overloaded_error' },
}),
),
).toBe('Internal error');
});
it('falls back to message when data.error is an empty string', () => {
expect(
extractErrorMessage(
new RequestError(-32603, 'Internal error', { error: '' }),
),
).toBe('Internal error');
});
it('falls back to message when data.error.message is an empty string', () => {
expect(
extractErrorMessage(
new RequestError(-32603, 'Internal error', {
error: { message: '' },
}),
),
).toBe('Internal error');
});
it('extracts details from Error subclasses with JSON-RPC data', () => {
expect(
extractErrorMessage(
+35 -3
View File
@@ -80,6 +80,7 @@ import {
type ServeSessionContextStatus,
type ServeSessionLspStatus,
type ServeSessionTasksStatus,
type ServeSessionWorkflowTaskStatus,
type ServeWorkspaceMcpResourcesStatus,
type ServeWorkspaceMcpStatus,
type ServeWorkspaceMcpToolsStatus,
@@ -1765,7 +1766,10 @@ function broadcastTurnComplete(
* objects with a `message` property.
* JSON-RPC internal errors carry the generic `"Internal error"` as
* `message`; the actual detail often lives in `data.details` or
* provider-specific `data.message`.
* provider-specific `data.message`. When the agent throws an error whose
* message is itself a JSON string (e.g. a provider error body surfaced as
* stream content), the ACP SDK ships `JSON.parse(message)` as `data`, which
* nests the provider text at `data.error.message` — read that shape too.
*/
export function extractErrorMessage(err: unknown): string {
if (err instanceof Error) {
@@ -1790,6 +1794,16 @@ function extractJsonRpcErrorDetail(data: unknown): string | undefined {
if (typeof details === 'string' && details.length > 0) return details;
const message = (data as Record<string, unknown>)['message'];
if (typeof message === 'string' && message.length > 0) return message;
return extractNestedErrorDetail((data as Record<string, unknown>)['error']);
}
return undefined;
}
function extractNestedErrorDetail(error: unknown): string | undefined {
if (typeof error === 'string' && error.length > 0) return error;
if (typeof error === 'object' && error !== null) {
const message = (error as Record<string, unknown>)['message'];
if (typeof message === 'string' && message.length > 0) return message;
}
return undefined;
}
@@ -11416,10 +11430,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
);
},
async getSessionTasksStatus(sessionId) {
async getSessionTasksStatus(sessionId, opts) {
return requestSessionStatus<ServeSessionTasksStatus>(
sessionId,
SERVE_STATUS_EXT_METHODS.sessionTasks,
{ includeWorkflows: opts?.includeWorkflows === true },
);
},
@@ -11434,7 +11449,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return requestSessionTranscriptPage(req);
},
async cancelSessionTask(sessionId, taskId, taskKind) {
async cancelSessionTask(sessionId, taskId, taskKind, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
resolveTrustedClientId(entry, context?.clientId);
return requestSessionStatus<{ cancelled: boolean }>(
sessionId,
SERVE_CONTROL_EXT_METHODS.sessionTaskCancel,
@@ -11442,6 +11460,20 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
);
},
async controlSessionWorkflowTask(sessionId, taskId, action, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
resolveTrustedClientId(entry, context?.clientId);
return requestSessionStatus<{
changed: boolean;
status?: ServeSessionWorkflowTaskStatus['status'];
taskId?: string;
}>(sessionId, SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, {
taskId,
action,
});
},
async controlSessionGoal(sessionId, request, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
+33 -5
View File
@@ -46,6 +46,7 @@ import type {
ServeSessionLspStatus,
ServeSessionSupportedCommandsStatus,
ServeSessionTasksStatus,
ServeSessionWorkflowTaskStatus,
ServeWorkspaceExtensionsStatus,
ServeWorkspaceHooksStatus,
ServeWorkspaceMcpToolsStatus,
@@ -316,11 +317,15 @@ export const ACTIVE_WORK_MAX_SESSION_HOLDS = 1024;
export const WORKTREE_MCP_DEFER_META_KEY = 'qwen.session.deferMcpDiscovery';
/**
* Work categories a child reports holds for. Monitors, workflows, and cron
* remain outside `activeWork`'s declared scope. The category travels on every
* Work categories a child reports holds for. Monitors and cron remain outside
* `activeWork`'s declared scope. The category travels on every
* hold so peers can negotiate coverage explicitly when the scope widens.
*/
export type ActiveWorkHoldCategory = 'agent' | 'notification' | 'shell';
export type ActiveWorkHoldCategory =
| 'agent'
| 'notification'
| 'shell'
| 'workflow';
/** Categories understood by active-work v1 before category negotiation was
* added to the daemon's initialize request. */
@@ -331,6 +336,7 @@ export const ACTIVE_WORK_HOLD_CATEGORIES: readonly ActiveWorkHoldCategory[] = [
'agent',
'notification',
'shell',
'workflow',
];
export interface ActiveWorkHeartbeatCapabilityV1 {
@@ -1784,7 +1790,10 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
): Promise<ServeSessionSupportedCommandsStatus>;
/** Read the live background task snapshot for a live session. */
getSessionTasksStatus(sessionId: string): Promise<ServeSessionTasksStatus>;
getSessionTasksStatus(
sessionId: string,
opts?: { includeWorkflows?: boolean },
): Promise<ServeSessionTasksStatus>;
/** Read sanitized LSP server status for a live session. */
getSessionLspStatus(sessionId: string): Promise<ServeSessionLspStatus>;
@@ -1802,9 +1811,28 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
cancelSessionTask(
sessionId: string,
taskId: string,
taskKind: 'agent' | 'shell' | 'monitor',
taskKind: 'agent' | 'shell' | 'monitor' | 'workflow',
context?: BridgeClientRequestContext,
): Promise<{ cancelled: boolean }>;
/** Control a run, delete history, or start a saved workflow definition. */
controlSessionWorkflowTask(
sessionId: string,
taskId: string,
action:
| 'pause'
| 'resume'
| 'retry'
| 'rerun'
| 'delete-history'
| 'run-saved',
context?: BridgeClientRequestContext,
): Promise<{
changed: boolean;
status?: ServeSessionWorkflowTaskStatus['status'];
taskId?: string;
}>;
/** Clear an active goal in a live session without cancelling the running prompt. */
clearSessionGoal(
sessionId: string,
+123 -1
View File
@@ -176,6 +176,7 @@ export const SERVE_CONTROL_EXT_METHODS = {
workspaceMemoryDream: 'qwen/control/workspace/memory/dream',
// Runtime MCP server mutation ext-methods
sessionTaskCancel: 'qwen/control/session/task/cancel',
sessionWorkflowTaskAction: 'qwen/control/session/task/workflow-action',
sessionGoalControl: 'qwen/control/session/goal/control',
sessionGoalClear: 'qwen/control/session/goal/clear',
/**
@@ -617,6 +618,13 @@ export interface ServeSessionSupportedCommandsStatus {
sessionId: string;
availableCommands: AvailableCommand[];
availableSkills: string[];
/** Whether Workflow is available for this session. */
workflowsEnabled?: boolean;
/** Reusable workflow definitions visible to this session. */
savedWorkflows?: Array<{
name: string;
source: 'project' | 'user';
}>;
}
export interface ServeLspServerStatus {
@@ -728,10 +736,124 @@ export interface ServeSessionMonitorTaskStatus {
toolUseId?: string;
}
export interface ServeWorkflowPhaseVisit {
id: string;
index: number;
title: string;
startedAt: number;
endedAt?: number;
}
export type ServeWorkflowDispatchStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed'
| 'cancelled'
| 'cached';
export interface ServeWorkflowDispatchStatusEntry {
id: string;
phaseVisitId: string | null;
label: string;
prompt: string;
subagentId?: string;
status: ServeWorkflowDispatchStatus;
dependsOn: string[];
queuedAt: number;
startedAt?: number;
endedAt?: number;
error?: string;
}
export interface ServeWorkflowApprovalStatusEntry {
approvalId: string;
subagentId: string;
name: string;
description: string;
at: number;
}
interface ServeWorkflowEventBase {
id: string;
at: number;
}
export type ServeWorkflowEvent =
| (ServeWorkflowEventBase & {
type: 'phase-started';
phaseVisitId: string;
title: string;
})
| (ServeWorkflowEventBase & {
type: 'phase-completed';
phaseVisitId: string;
})
| (ServeWorkflowEventBase & {
type:
| 'dispatch-queued'
| 'dispatch-started'
| 'dispatch-completed'
| 'dispatch-cancelled'
| 'dispatch-cached';
dispatchId: string;
})
| (ServeWorkflowEventBase & {
type: 'dispatch-failed';
dispatchId: string;
error: string;
})
| (ServeWorkflowEventBase & { type: 'log'; message: string })
| (ServeWorkflowEventBase & {
type: 'approval-requested' | 'approval-settled';
name: string;
dispatchId?: string;
})
| (ServeWorkflowEventBase & {
type: 'workflow-completed' | 'workflow-cancelled';
})
| (ServeWorkflowEventBase & {
type: 'workflow-failed';
error: string;
});
export interface ServeSessionWorkflowTaskStatus {
kind: 'workflow';
id: string;
/** Tool call in the parent session that launched this workflow. */
toolUseId?: string;
/** Restored from the project snapshot store; controls are read-only. */
isHistorical?: boolean;
sourceRunId?: string;
startMode?: 'retry' | 'rerun';
label: string;
description: string;
status: ServeSessionTaskLifecycleStatus | 'pausing';
startTime: number;
endTime?: number;
runtimeMs: number;
outputFile?: string;
isBackgrounded: boolean;
currentPhase: string | null;
phaseVisits: ServeWorkflowPhaseVisit[];
dispatches: ServeWorkflowDispatchStatusEntry[];
agentsDispatched: number;
agentsCompleted: number;
tokensSpent: number;
tokenBudgetTotal: number | null;
recentLogs: string[];
/** Ordered runtime facts; absent for snapshots created before event tracing. */
events?: ServeWorkflowEvent[];
pendingApprovalCount: number;
pendingApprovals?: ServeWorkflowApprovalStatusEntry[];
error?: string;
}
export type ServeSessionTaskStatus =
| ServeSessionAgentTaskStatus
| ServeSessionShellTaskStatus
| ServeSessionMonitorTaskStatus;
| ServeSessionMonitorTaskStatus
| ServeSessionWorkflowTaskStatus;
export interface ServeSessionTasksStatus {
v: typeof STATUS_SCHEMA_VERSION;
File diff suppressed because it is too large. Load diff
+442 -9
View File
@@ -134,6 +134,14 @@ import {
type WorkspaceRememberContextMode,
type ChatRecord,
type ToolInvocationGuard,
type WorkflowParams,
type WorkflowToolResult,
type WorkflowRunRegistry,
getWorkflowTaskMutationKey,
isTerminalWorkflowStatus,
tryWithWorkflowTaskMutation,
listSavedWorkflows,
listWorkflowSnapshots,
type TurnResultRecordPayload,
sessionIdContext,
} from '@qwen-code/qwen-code-core';
@@ -437,6 +445,25 @@ import {
type GenerationEvent,
} from './generation.js';
type SessionOwnedWorkflowTool = {
buildSessionOwnedBackground(
params: Omit<WorkflowParams, 'run_in_background'>,
): {
execute(signal: AbortSignal): Promise<WorkflowToolResult>;
};
};
function isSessionOwnedWorkflowTool(
value: unknown,
): value is SessionOwnedWorkflowTool {
return (
typeof value === 'object' &&
value !== null &&
'buildSessionOwnedBackground' in value &&
typeof value.buildSessionOwnedBackground === 'function'
);
}
const debugLogger = createDebugLogger('ACP_AGENT');
const QWEN_ACP_LOCAL_READ_ROOTS_ENV = 'QWEN_ACP_LOCAL_READ_ROOTS';
const POSIX_TMP_LOCAL_READ_ROOT = '/tmp';
@@ -3375,6 +3402,13 @@ class QwenAgent implements Agent {
private modelProviderReloadRevision = 0;
private readonly historyMutationTails = new Map<string, Promise<void>>();
private readonly startingSessionIds = new Set<string>();
/**
* R7-10: workflow registries of sessions already removed from
* `this.sessions` whose runs have not finished settling. Consulted by
* `isWorkflowRunLiveOutsideSession` so a delete-history cannot delete
* an orphaned live run out from under itself. Pruned as they drain.
*/
private readonly detachedWorkflowRegistries = new Set<WorkflowRunRegistry>();
private activePromptCalls = new Map<string, Set<ActivePromptCall>>();
private workspaceMcpDiscoveryConfig: Config | undefined;
private workspaceMcpDiscoveryPromise: Promise<void> | undefined;
@@ -4016,6 +4050,25 @@ class QwenAgent implements Agent {
cleanupErrors.push(error);
}
this.sessions.delete(sessionId);
// R7-10: the registry outlives the session map entry while its runs
// settle. Keep it reachable so the liveness gate still sees them.
// Retention is bookkeeping, not cleanup: a Config that cannot answer
// must not turn a successful close into a shutdown failure.
try {
// Prune here as well as on the delete-history path: a daemon that
// closes sessions mid-run but never deletes history would otherwise
// retain every one of their registries for its whole lifetime.
this.pruneDrainedWorkflowRegistries();
const registry = session.getConfig().getWorkflowRunRegistry?.();
if (registry && QwenAgent.isWorkflowRegistryDraining(registry)) {
this.detachedWorkflowRegistries.add(registry);
}
} catch (error) {
debugLogger.warn(
`Session ${sessionId}: could not retain its workflow registry for liveness checks:`,
error,
);
}
// A Session missing from the next snapshot is how the daemon learns the
// child released it — including when it never saw our close response.
this.activeWorkReporter?.notifyChanged();
@@ -7581,19 +7634,49 @@ class QwenAgent implements Agent {
sessionId: string,
): Promise<ServeSessionSupportedCommandsStatus> {
const session = this.sessionOrThrow(sessionId);
const config = session.getConfig();
const { availableCommands, availableSkills } =
await session.buildAvailableCommandsSnapshot();
const workflowsEnabled = this.canUseWorkflowControls(config);
const savedWorkflows = workflowsEnabled
? (await listSavedWorkflows(config)).map(({ name, source }) => ({
name,
source,
}))
: [];
return {
v: STATUS_SCHEMA_VERSION,
sessionId,
availableCommands,
availableCommands:
workflowsEnabled || !config.isWorkflowsEnabled()
? availableCommands
: availableCommands.filter((command) => command.name !== 'workflows'),
availableSkills: availableSkills ?? [],
workflowsEnabled,
savedWorkflows,
};
}
private buildSessionTasksStatus(sessionId: string): ServeSessionTasksStatus {
private canUseWorkflowControls(config: Config): boolean {
return (
config.isWorkflowsEnabled() &&
!config.getBareMode() &&
(!config.getFolderTrustFeature() || config.getFolderTrust())
);
}
private async buildSessionTasksStatus(
sessionId: string,
includeWorkflows = false,
): Promise<ServeSessionTasksStatus> {
const session = this.sessionOrThrow(sessionId);
return buildSessionTasksStatus(sessionId, session.getConfig());
return buildSessionTasksStatus(
sessionId,
session.getConfig(),
Date.now(),
includeWorkflows ? await session.refreshWorkflowHistory() : [],
{ includeWorkflows },
);
}
private buildSessionLspStatus(sessionId: string): ServeSessionLspStatus {
@@ -8394,10 +8477,12 @@ class QwenAgent implements Agent {
'Invalid or missing sessionId',
);
}
return this.buildSessionTasksStatus(sessionId) as unknown as Record<
string,
unknown
>;
const session = this.sessionOrThrow(sessionId);
return (await this.buildSessionTasksStatus(
sessionId,
params['includeWorkflows'] === true &&
this.canUseWorkflowControls(session.getConfig()),
)) as unknown as Record<string, unknown>;
}
case SERVE_STATUS_EXT_METHODS.sessionLspStatus: {
const sessionId = params['sessionId'];
@@ -10860,11 +10945,12 @@ class QwenAgent implements Agent {
if (
taskKind !== 'agent' &&
taskKind !== 'shell' &&
taskKind !== 'monitor'
taskKind !== 'monitor' &&
taskKind !== 'workflow'
) {
throw RequestError.invalidParams(
undefined,
'taskKind must be "agent", "shell", or "monitor"',
'taskKind must be "agent", "shell", "monitor", or "workflow"',
);
}
debugLogger.info(
@@ -10925,12 +11011,249 @@ class QwenAgent implements Agent {
);
return { cancelled: true, status: task.status };
}
case 'workflow': {
if (!this.canUseWorkflowControls(config)) {
return { cancelled: false, reason: 'disabled' };
}
const registry = config.getWorkflowRunRegistry();
const task = registry.get(taskId);
// A reserved-but-unregistered run has no entry yet: the runner
// is still loading its script or replaying its journal. The
// liveness gate already treats that window as live; cancel
// must too, or the client is told "not_found" about a run it
// can see starting, and cannot stop it until it registers.
// A retry reuses its runId, so during ITS starting window the
// old terminal entry is still there — a live reservation
// shadowed by a terminal entry is the same starting run, not
// a "not_running" one.
if (
(!task || isTerminalWorkflowStatus(task.status)) &&
registry.isStarting?.(taskId)
) {
registry.cancelStarting(taskId);
debugLogger.info(
`sessionTaskCancel completed sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} status=starting`,
);
return { cancelled: true, status: 'cancelled' };
}
if (
!task ||
(task.status !== 'running' &&
task.status !== 'pausing' &&
task.status !== 'paused')
) {
const reason = task ? 'not_running' : 'not_found';
debugLogger.info(
`sessionTaskCancel skipped sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} reason=${reason} status=${task?.status ?? 'missing'}`,
);
return { cancelled: false, reason, status: task?.status };
}
const handle = registry.getHandle(taskId);
registry.cancel(taskId, Date.now());
if (handle) await handle.completion;
debugLogger.info(
`sessionTaskCancel completed sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} status=${task.status}`,
);
return { cancelled: true, status: task.status };
}
default: {
const exhaustive: never = taskKind;
throw new Error(`Unhandled task kind: ${exhaustive}`);
}
}
}
case SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction: {
const sessionId = params['sessionId'];
if (typeof sessionId !== 'string' || sessionId.length === 0) {
throw RequestError.invalidParams(
undefined,
'Invalid or missing sessionId',
);
}
const taskId = params['taskId'];
if (typeof taskId !== 'string' || taskId.length === 0) {
throw RequestError.invalidParams(
undefined,
'Invalid or missing taskId',
);
}
const action = params['action'];
if (
action !== 'pause' &&
action !== 'resume' &&
action !== 'retry' &&
action !== 'rerun' &&
action !== 'delete-history' &&
action !== 'run-saved'
) {
throw RequestError.invalidParams(
undefined,
'action must be "pause", "resume", "retry", "rerun", "delete-history", or "run-saved"',
);
}
const session = this.sessionOrThrow(sessionId);
const config = session.getConfig();
if (!this.canUseWorkflowControls(config)) {
return { changed: false };
}
const mutationClaim =
action === 'run-saved'
? getWorkflowTaskMutationKey(config, taskId, 'saved')
: getWorkflowTaskMutationKey(config, taskId);
if (action === 'delete-history') {
const attempt = await tryWithWorkflowTaskMutation(
mutationClaim,
async () => {
const changed = await session.deleteWorkflowHistory(taskId);
if (changed) {
// Every session shares the one store: drop the sibling
// registries' terminal entries too, or a retry from a
// sibling re-persists the just-deleted run — and mark the
// deletion in each sibling's history, or a sibling refresh
// that began reading the directory before this delete
// landed merges the stale listing and republishes the run.
for (const [siblingId, sibling] of this.sessions) {
if (siblingId === sessionId) continue;
sibling
.getConfig()
.getWorkflowRunRegistry()
.removeTerminal(taskId);
sibling.noteExternalWorkflowDeletion(taskId);
}
}
return { changed };
},
);
if (!attempt.acquired) {
return { changed: false };
}
return attempt.value;
}
const registry = config.getWorkflowRunRegistry();
if (action === 'run-saved') {
const attempt = await tryWithWorkflowTaskMutation(
mutationClaim,
async () => {
const savedWorkflow = (await listSavedWorkflows(config)).find(
(entry) => entry.name === taskId,
);
if (!savedWorkflow) return { changed: false };
const workflowTool = config
.getToolRegistry()
.getTool(ToolNames.WORKFLOW);
if (!isSessionOwnedWorkflowTool(workflowTool)) {
throw RequestError.invalidParams(
undefined,
'The workflow tool is unavailable; cannot run this saved workflow.',
);
}
const result = (await workflowTool
.buildSessionOwnedBackground({
scriptPath: savedWorkflow.scriptPath,
})
.execute(new AbortController().signal)) as WorkflowToolResult;
const startedTask = result.workflowRunId
? registry.get(result.workflowRunId)
: undefined;
return startedTask
? {
changed: true,
status: startedTask.status,
taskId: startedTask.runId,
}
: { changed: false };
},
);
if (!attempt.acquired) {
return { changed: false };
}
return attempt.value;
}
const task = registry.get(taskId);
if (!task) return { changed: false };
if (action === 'retry' || action === 'rerun') {
// A retry reuses its runId over the one journal/snapshot store
// every session shares, so "failed with no handle" in THIS
// session's registry is not enough: a sibling may have retried
// it already and be running it now — the task-global claim is
// released as soon as the background start returns, while the
// run is still live. Two runners on one runId interleave the
// journal and race the snapshot write. Checked synchronously
// beside canStart — no await precedes the claim — so the answer
// cannot go stale before the claim is taken.
const liveElsewhere =
action === 'retry' &&
(registry.isStarting?.(taskId) === true ||
this.isWorkflowRunLiveOutsideSession(sessionId, taskId));
const canStart =
action === 'retry'
? task.status === 'failed' &&
!registry.getHandle(taskId) &&
!liveElsewhere
: task.status === 'completed' ||
task.status === 'failed' ||
task.status === 'cancelled';
if (!canStart || !task.script) {
return { changed: false, status: task.status };
}
const attempt = await tryWithWorkflowTaskMutation(
mutationClaim,
async () => {
const workflowTool = config
.getToolRegistry()
.getTool(ToolNames.WORKFLOW);
if (!isSessionOwnedWorkflowTool(workflowTool)) {
throw RequestError.invalidParams(
undefined,
`The workflow tool is unavailable; cannot ${action} this run.`,
);
}
const startParams: Omit<WorkflowParams, 'run_in_background'> = {
script: task.script,
args: task.args,
...(action === 'retry' ? { resumeFromRunId: task.runId } : {}),
};
const result = (await workflowTool
.buildSessionOwnedBackground(startParams)
.execute(new AbortController().signal)) as WorkflowToolResult;
if (action === 'rerun') {
const rerunTask = result.workflowRunId
? registry.get(result.workflowRunId)
: undefined;
if (rerunTask) {
registry.setLineage(rerunTask.runId, task.runId, 'rerun');
}
return rerunTask
? {
changed: true,
status: rerunTask.status,
taskId: rerunTask.runId,
}
: { changed: false, status: task.status };
}
// `execute()` reports a start that never registered — a
// cancel landing in the retry's starting window, whether from
// `cancelStarting` or a session dispose — by omitting
// `workflowRunId`, the same shape the rerun and run-saved
// branches gate on. Answering `changed: true` there tells the
// client a run exists that nothing will ever progress.
return result.workflowRunId
? {
changed: true,
status: registry.get(result.workflowRunId)?.status,
}
: { changed: false, status: task.status };
},
);
if (!attempt.acquired) {
return { changed: false, status: task.status };
}
return attempt.value;
}
const changed =
action === 'pause' ? registry.pause(taskId) : registry.resume(taskId);
return { changed, status: task.status };
}
case SERVE_CONTROL_EXT_METHODS.sessionGoalClear: {
const sessionId = params['sessionId'];
if (typeof sessionId !== 'string' || sessionId.length === 0) {
@@ -12233,6 +12556,30 @@ class QwenAgent implements Agent {
);
config.setDisabledTools(new Set(disabled));
// `/capabilities` reads `tools.workflowsEnabled` live from
// the reloaded settings; a session alive before the reload
// was constructed with the old value and would keep
// answering canUseWorkflowControls with it, so the
// advertisement and the controls it gates would diverge.
// The merged view is already workspace-stripped, so a repo
// cannot self-grant here any more than at construction.
const workflowsWereEnabled = config.isWorkflowsEnabled();
config.setWorkflowsEnabled(
newMerged.tools?.workflowsEnabled === true,
);
if (config.isWorkflowsEnabled() !== workflowsWereEnabled) {
// The `workflows` slash command comes and goes with the
// flag; a client holding the old list would keep offering
// (or hiding) it.
try {
await session.sendAvailableCommandsUpdate();
} catch (err) {
debugLogger.warn(
`reload: sendAvailableCommandsUpdate failed for session ${id}: ${err}`,
);
}
}
const newMode = newMerged.tools?.approvalMode;
if (
newMode &&
@@ -12891,6 +13238,89 @@ class QwenAgent implements Agent {
config.setFileSystemService(acpFileSystemService);
}
/**
* All sessions in this child share one workflow snapshot store but each
* keeps a private run registry, so one session's history deletion must
* see every sibling registry — or it can delete a run another session
* is still executing or settling. A handle outlives the terminal
* transition until the snapshot write lands, so it blocks too.
*
* R7-10: iterating `this.sessions` alone left a blind spot the width of
* a whole run. A workflow can outlive its owning session's removal —
* explicit close/kill/shutdown use force semantics, and a background
* run owns a detached controller — so once `removeStoredSessionEntry`
* deleted the session, a still-settling run became invisible here and
* unreachable by the delete handler's sibling `removeTerminal` loop. A
* sibling `delete-history` then passed every check, removed the LIVE
* run's journal directory and snapshot, and answered `{changed: true}`;
* the orphan kept going and its settlement `writeWorkflowSnapshot`
* recreated the file — resurrection, plus a run whose journal was rm'd
* under it.
*
* The repair has two halves. `Session.dispose()` now aborts its
* workflow registry the way it already aborts the agent registry, so an
* orphan settles instead of running on; and the registry of a removed
* session stays reachable here until its runs drain, so the gate keeps
* answering for the settlement window that abort cannot compress to
* zero (the handle is released only after the snapshot write lands).
*/
private isWorkflowRunLiveOutsideSession(
excludeSessionId: string,
runId: string,
): boolean {
for (const [sessionId, session] of this.sessions) {
if (sessionId === excludeSessionId) continue;
if (
QwenAgent.isWorkflowRunLiveInRegistry(
session.getConfig().getWorkflowRunRegistry(),
runId,
)
) {
return true;
}
}
this.pruneDrainedWorkflowRegistries();
for (const registry of this.detachedWorkflowRegistries) {
if (QwenAgent.isWorkflowRunLiveInRegistry(registry, runId)) return true;
}
return false;
}
private static isWorkflowRunLiveInRegistry(
registry: WorkflowRunRegistry,
runId: string,
): boolean {
if (registry.isStarting?.(runId)) return true;
const entry = registry.get(runId);
if (entry && !isTerminalWorkflowStatus(entry.status)) return true;
return registry.getHandle(runId) !== undefined;
}
/**
* Registries of removed sessions are retained only while they still
* hold work — an aborted run keeps its handle until settlement releases
* it. Dropping drained ones keeps this from becoming a leak that grows
* with every closed session.
*/
private pruneDrainedWorkflowRegistries(): void {
for (const registry of this.detachedWorkflowRegistries) {
if (!QwenAgent.isWorkflowRegistryDraining(registry)) {
this.detachedWorkflowRegistries.delete(registry);
}
}
}
/** Still holding work: an active entry, or a handle awaiting settlement. */
private static isWorkflowRegistryDraining(
registry: WorkflowRunRegistry,
): boolean {
if (registry.hasRunningEntries?.()) return true;
return (
registry.list?.().some((entry) => registry.getHandle(entry.runId)) ??
false
);
}
private async createAndStoreSession(
config: Config,
settings: LoadedSettings,
@@ -12938,6 +13368,7 @@ class QwenAgent implements Agent {
{ errorKind: 'session_id_conflict', sessionId },
);
}
const workflowHistory = await listWorkflowSnapshots(config);
const session = new Session(
sessionId,
config,
@@ -12945,6 +13376,8 @@ class QwenAgent implements Agent {
settings,
(operation) => this.runExclusiveHistoryMutation(sessionId, operation),
() => this.activeWorkReporter?.notifyChanged(),
workflowHistory,
(runId) => this.isWorkflowRunLiveOutsideSession(sessionId, runId),
);
const replaySessionHistory = async () => {
if (
@@ -215,6 +215,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
snapshot: vi.fn(() => ({})),
})),
restoreWorktreeContext: mockRestoreWorktreeContext,
listWorkflowSnapshots: vi.fn().mockResolvedValue([]),
HookEventName: {
PreToolUse: 'PreToolUse',
PostToolUse: 'PostToolUse',
@@ -168,6 +168,13 @@ describe('Session review-worktree lease sweep', () => {
clearStatusChangeCallback: vi.fn(),
hasRunningEntries: vi.fn().mockReturnValue(false),
}),
getWorkflowRunRegistry: vi.fn().mockReturnValue({
setStatusChangeCallback: vi.fn(),
clearStatusChangeCallback: vi.fn(),
setCompletionCallback: vi.fn(),
setSnapshotPersistedCallback: vi.fn(),
setApprovalRequestCallback: vi.fn(),
}),
setSubSessionSpawner: vi.fn(),
getSubSessionSpawner: vi.fn(),
// The Session constructor and Session.prompt both reach for the
File diff suppressed because it is too large. Load diff
@@ -53,6 +53,8 @@ import type {
ChatRecordingService,
TurnResultRecordPayload,
WorkflowApproval,
WorkflowSnapshot,
WorkflowTask,
BranchPoint,
} from '@qwen-code/qwen-code-core';
import {
@@ -202,6 +204,13 @@ import {
toolResultPartDiagnosticValues,
getInvocationContext,
runWithInvocationContext,
getWorkflowTaskMutationKey,
isTerminalWorkflowStatus,
tryWithWorkflowTaskMutation,
MAX_RETAINED_SNAPSHOTS,
toSnapshot,
deleteWorkflowSnapshot,
listWorkflowSnapshots,
truncateNotificationLabel,
buildBackgroundEntryLabel,
collectSessionTurnState,
@@ -528,6 +537,7 @@ type TodoStopGuardBackgroundBaseline = {
agents: Set<string>;
shells: Set<string>;
monitors: Set<string>;
workflows: Set<WorkflowTask>;
wakeups: Set<string>;
};
@@ -1389,7 +1399,7 @@ export interface BackgroundNotificationQueueItem {
modelText: string;
taskId: string;
status: string;
kind: 'agent' | 'monitor' | 'shell';
kind: 'agent' | 'monitor' | 'shell' | 'workflow';
toolUseId?: string;
todoWorkChainId?: string;
/** Structured fields for i18n rendering on the frontend. */
@@ -1981,13 +1991,14 @@ export class Session implements SessionContext {
private notificationAbortController: AbortController | null = null;
private notificationCompletion: Promise<void> | null = null;
private currentAgentNotificationTaskId: string | null = null;
private currentWorkflowNotificationTaskId: string | null = null;
private currentShellNotificationActive = false;
private readonly persistedBackgroundNotificationTaskIds = new Set<string>();
private readonly backgroundNotificationAcceptances = new Map<
string,
Promise<boolean>
>();
private readonly activeAgentNotificationAcceptances = new Set<string>();
private readonly activeNotificationAcceptances = new Set<string>();
private readonly goalQueue: AcpGoalTurn[] = [];
private goalProcessing = false;
@@ -2015,6 +2026,36 @@ export class Session implements SessionContext {
/** The exact status-change callback this Session installed, so dispose can
* retract its own and nobody else's. */
#statusChangeCallback: (() => void) | undefined;
#workflowStatusChangeCallback: ((entry?: WorkflowTask) => void) | undefined;
private workflowHistory: WorkflowSnapshot[];
/**
* R7-5: runIds whose snapshot write this session has observed. Latches
* `#rememberWorkflowHistory` off so a post-persistence status emission
* cannot resurrect a sibling-deleted run. See that method.
*/
private readonly persistedWorkflowRunIds = new Set<string>();
/**
* R7-4: every runId the last `refreshWorkflowHistory` merged, BEFORE the
* MAX_RETAINED_SNAPSHOTS cap. `workflowHistory` is the display window;
* this is what deletion tests membership against.
*/
private mergedWorkflowRunIds = new Set<string>();
private readonly unpersistedWorkflowHistory = new Map<
string,
WorkflowSnapshot
>();
/**
* Deletion order, so a refresh can tell which runs were deleted AFTER
* its disk read began. `refreshWorkflowHistory` reads the directory
* and then merges without holding a claim, while deletion holds one —
* a delete that lands between the read and the merge would otherwise
* be overwritten by the stale listing and the run would reappear
* until the next refresh. Keyed by runId so a later re-run of the same
* id (a retry reuses it) is not suppressed: its sequence predates that
* refresh's mark.
*/
private workflowDeletionSeq = 0;
private readonly workflowDeletionSeqByRunId = new Map<string, number>();
#shellStatusChangeCallback: (() => void) | undefined;
private readonly workflowApprovalAbortController = new AbortController();
private activeTodoPlanRevision?: {
@@ -2087,8 +2128,19 @@ export class Session implements SessionContext {
* a full snapshot; the Session itself keeps no reporting state.
*/
private readonly onActiveWorkChanged?: () => void,
workflowHistory: readonly WorkflowSnapshot[] = [],
/**
* Reports whether another session in this process owns a live or
* still-settling registry entry for the run. Every session here shares
* one on-disk workflow store but keeps a private registry, so history
* deletion must consult all of them, not just this session's.
*/
private readonly isWorkflowRunLiveInSiblingSession: (
runId: string,
) => boolean = () => false,
) {
this.sessionId = id;
this.workflowHistory = [...workflowHistory];
this.requiresManagedConversationBinding =
isReservedStandaloneSessionSourceType(
this.config.getSessionSourceType?.(),
@@ -2942,6 +2994,7 @@ export class Session implements SessionContext {
const agents = this.config.getBackgroundTaskRegistry?.()?.getAll?.() ?? [];
const shells = this.config.getBackgroundShellRegistry?.()?.getAll?.() ?? [];
const monitors = this.config.getMonitorRegistry?.()?.getAll?.() ?? [];
const workflows = this.config.getWorkflowRunRegistry?.()?.list?.() ?? [];
const wakeups = this.config.isCronEnabled?.()
? (this.config.getCronScheduler?.()?.list?.() ?? []).filter(
(job) => job.cronExpr === '@wakeup',
@@ -2967,6 +3020,7 @@ export class Session implements SessionContext {
.filter((item) => item.kind === 'monitor')
.map((item) => item.taskId),
]),
workflows: new Set(workflows),
wakeups: new Set([
...wakeups.map((job) => job.id),
...this.cronQueue.flatMap((item) =>
@@ -3069,6 +3123,17 @@ export class Session implements SessionContext {
return true;
}
const workflows = this.config.getWorkflowRunRegistry?.()?.list?.() ?? [];
if (
workflows.some(
(task) =>
!baseline.workflows.has(task) &&
!isTerminalWorkflowStatus(task.status),
)
) {
return true;
}
if (!this.config.isCronEnabled?.()) return false;
const wakeups = this.config.getCronScheduler?.()?.list?.() ?? [];
return wakeups.some(
@@ -3384,6 +3449,177 @@ export class Session implements SessionContext {
return this.config;
}
getWorkflowHistory(): readonly WorkflowSnapshot[] {
return this.workflowHistory;
}
async refreshWorkflowHistory(): Promise<readonly WorkflowSnapshot[]> {
const deletionMark = this.workflowDeletionSeq;
const persisted = await listWorkflowSnapshots(this.config);
const byRunId = new Map(
persisted.map((snapshot) => [snapshot.runId, snapshot]),
);
for (const [runId, seq] of this.workflowDeletionSeqByRunId) {
if (seq > deletionMark) byRunId.delete(runId);
}
for (const [runId, snapshot] of this.unpersistedWorkflowHistory) {
const stored = byRunId.get(runId);
if (stored === undefined) {
// Never persisted (write pending or failed): keep the cached
// projection visible. Once persistence is observed the entry is
// retired via the snapshot-persisted callback, so absence here
// afterwards means the run was deleted and must stay gone.
byRunId.set(runId, snapshot);
} else {
// A persisted copy is the newer authoritative projection: the
// runId settled (possibly re-run in another session), so a stale
// cache must not shadow it.
this.unpersistedWorkflowHistory.delete(runId);
}
}
// R7-4: the returned/stored history is the capped display window, but
// deletion must reason about the whole merged set — keep it before the
// slice rather than making callers re-derive it.
this.mergedWorkflowRunIds = new Set(byRunId.keys());
this.workflowHistory = [...byRunId.values()]
.sort((a, b) => b.startTime - a.startTime)
.slice(0, MAX_RETAINED_SNAPSHOTS);
this.#pruneUnpersistedWorkflowHistory();
return this.workflowHistory;
}
async deleteWorkflowHistory(runId: string): Promise<boolean> {
const attempt = await tryWithWorkflowTaskMutation(
getWorkflowTaskMutationKey(this.config, runId),
() => this.#deleteWorkflowHistoryClaimed(runId),
);
return attempt.acquired ? attempt.value : false;
}
async #deleteWorkflowHistoryClaimed(runId: string): Promise<boolean> {
const registry = this.config.getWorkflowRunRegistry();
const isDeletable = (): boolean => {
if (this.isWorkflowRunLiveInSiblingSession(runId)) return false;
if (registry.isStarting?.(runId)) return false;
const current = registry.get(runId);
return !current || isTerminalWorkflowStatus(current.status);
};
if (!isDeletable()) return false;
const handle = registry.getHandle(runId);
if (handle) {
await handle.completion;
if (!isDeletable()) return false;
}
await this.refreshWorkflowHistory();
if (!isDeletable()) return false;
// R7-4: membership must be tested against everything the client can
// SEE, not against the capped window. `buildSessionTasksStatus`
// serializes every registry entry unconditionally, while
// `refreshWorkflowHistory` truncates to MAX_RETAINED_SNAPSHOTS by
// startTime — so a long run that settles after ~30 newer ones started
// stays listed via the registry but falls out of the window, and the
// capped check answered `{changed: false}` forever. It was terminal,
// handle-free and live in no sibling: nothing but the window kept it
// undeletable. `deleteWorkflowSnapshot` already tolerates an absent
// target, so widening the gate cannot delete something that is not
// there.
if (
!this.mergedWorkflowRunIds.has(runId) &&
registry.get(runId) === undefined &&
!this.unpersistedWorkflowHistory.has(runId)
) {
return false;
}
// Retire the registry entry before touching the store. `removeTerminal`
// refuses a live or handle-held entry — the registry's own last word
// on whether the run is still active here — so `false` for an entry
// that exists means the run re-registered and must not be reported
// deleted; a persisted-only run has no entry to retire.
if (registry.get(runId) !== undefined && !registry.removeTerminal(runId)) {
return false;
}
if (!(await deleteWorkflowSnapshot(this.config, runId))) return false;
this.workflowDeletionSeqByRunId.set(runId, ++this.workflowDeletionSeq);
this.unpersistedWorkflowHistory.delete(runId);
this.mergedWorkflowRunIds.delete(runId);
this.persistedWorkflowRunIds.delete(runId);
this.workflowHistory = this.workflowHistory.filter(
(item) => item.runId !== runId,
);
this.#activeWorkChanged();
return true;
}
/**
* A sibling session deleted `runId` from the shared store. The
* deletion-sequence marker is per-Session — it records deletions THIS
* session issued — while the store and the delete entrance are
* process-wide, so without this a refresh of ours that began reading
* the directory before the sibling's delete landed would merge the
* stale listing and republish the run the sibling's client was just
* told was gone. Called under the sibling's task-mutation claim,
* symmetric to the registry `removeTerminal` sweep.
*
* The R7-5 persisted latch is deliberately kept: a late terminal
* emission for the deleted run must still not re-insert it.
*/
noteExternalWorkflowDeletion(runId: string): void {
this.workflowDeletionSeqByRunId.set(runId, ++this.workflowDeletionSeq);
this.unpersistedWorkflowHistory.delete(runId);
this.mergedWorkflowRunIds.delete(runId);
const retained = this.workflowHistory.filter(
(item) => item.runId !== runId,
);
if (retained.length === this.workflowHistory.length) return;
this.workflowHistory = retained;
this.#activeWorkChanged();
}
#rememberWorkflowHistory(entry: WorkflowTask): void {
if (!isTerminalWorkflowStatus(entry.status)) {
// Back in an active state means this runId was registered afresh
// (a retry/resume reuses it), so its next settlement must be
// remembered again — release the R7-5 latch here rather than
// wiring a second registry callback for it.
this.persistedWorkflowRunIds.delete(entry.runId);
return;
}
// R7-5: retirement is a latch, not a one-shot. The registry's
// dispatch-drain callbacks (onAgentCompleted / onBudgetUpdated /
// onDispatchSettled) emit status changes on TERMINAL entries with no
// status gate, and in-flight dispatches keep draining across the
// snapshot write — so a terminal emission routinely lands AFTER
// `notifySnapshotPersisted` retired the cache entry. Without this
// guard each late emission re-inserted the run as "never persisted",
// and a sibling session's deletion was then undone by the next
// refresh: absent on disk but present in the stale cache reads as a
// pending write, so the deleted run was republished and stayed for
// the life of the session. Released at the top of this method when
// the runId comes back non-terminal (a retry/resume re-registers it)
// so a genuine re-run of the same runId is remembered again.
if (this.persistedWorkflowRunIds.has(entry.runId)) return;
const snapshot = toSnapshot(entry);
this.unpersistedWorkflowHistory.set(snapshot.runId, snapshot);
this.workflowHistory = [
snapshot,
...this.workflowHistory.filter((item) => item.runId !== entry.runId),
]
.sort((a, b) => b.startTime - a.startTime)
.slice(0, MAX_RETAINED_SNAPSHOTS);
this.#pruneUnpersistedWorkflowHistory();
}
#pruneUnpersistedWorkflowHistory(): void {
const retainedRunIds = new Set(
this.workflowHistory.map((item) => item.runId),
);
for (const runId of this.unpersistedWorkflowHistory.keys()) {
if (!retainedRunIds.has(runId)) {
this.unpersistedWorkflowHistory.delete(runId);
}
}
}
reloadModelProvidersFromDisk(): void {
if (
!this.settings.reloadScopesFromDiskAtomically([
@@ -3640,14 +3876,19 @@ export class Session implements SessionContext {
}
const notificationIds = new Set<string>();
for (const item of this.notificationQueue) {
if (item.kind === 'agent') notificationIds.add(item.taskId);
if (item.kind === 'agent' || item.kind === 'workflow') {
notificationIds.add(item.taskId);
}
}
for (const taskId of this.activeAgentNotificationAcceptances) {
for (const taskId of this.activeNotificationAcceptances) {
notificationIds.add(taskId);
}
if (this.currentAgentNotificationTaskId !== null) {
notificationIds.add(this.currentAgentNotificationTaskId);
}
if (this.currentWorkflowNotificationTaskId !== null) {
notificationIds.add(this.currentWorkflowNotificationTaskId);
}
for (const taskId of notificationIds) {
holds.push({ category: 'notification', id: taskId });
}
@@ -3658,6 +3899,26 @@ export class Session implements SessionContext {
if (shellActive) {
holds.push({ category: 'shell', id: 'background-shells' });
}
const workflowRegistry = this.config.getWorkflowRunRegistry();
// A reserved-but-unregistered run (script loading, journal replay)
// has no `list()` entry yet, but the registry's hasRunningEntries()
// and the delete/cancel liveness gates already count it as live. A
// daemon-initiated conditional close that read no hold here would
// dispose the session and abort the start under the client that just
// asked for it. The hold releases itself: registration takes over
// with the entry's running hold, and a failed or cancelled start
// drops the reservation via `releaseStart`.
for (const runId of workflowRegistry.listStartingRunIds?.() ?? []) {
holds.push({ category: 'workflow', id: runId });
}
for (const task of workflowRegistry.list()) {
// Mirror the registry's hasRunningEntries(): a paused run executes
// nothing and no backstop would ever release the hold, so it must
// not pin the session the way executing work does.
if (task.status === 'running' || task.status === 'pausing') {
holds.push({ category: 'workflow', id: task.runId });
}
}
return holds;
}
@@ -3851,6 +4112,24 @@ export class Session implements SessionContext {
shellRegistry.clearStatusChangeCallback(this.#shellStatusChangeCallback);
this.#shellStatusChangeCallback = undefined;
}
// R7-10: mirror the agent registry's treatment above. Without this a
// workflow outlives its session's removal — close/kill/shutdown use
// force semantics and a background run owns a detached controller —
// and an orphan that nothing can see keeps writing its snapshot,
// recreating history a sibling session just deleted. Abort BEFORE the
// callbacks are cleared so the cancellation still reaches this
// session's own bookkeeping.
this.config.getWorkflowRunRegistry().abortAll();
this.config.getWorkflowRunRegistry().setCompletionCallback(undefined);
this.config
.getWorkflowRunRegistry()
.setSnapshotPersistedCallback(undefined);
if (this.#workflowStatusChangeCallback) {
this.config
.getWorkflowRunRegistry()
.clearStatusChangeCallback(this.#workflowStatusChangeCallback);
this.#workflowStatusChangeCallback = undefined;
}
this.config.getChatRecordingService()?.setTitleRecordedCallback(undefined);
this.unsubscribeChatRecordingFailure?.();
this.unsubscribeChatRecordingFailure = undefined;
@@ -8955,6 +9234,36 @@ export class Session implements SessionContext {
});
});
const workflowRegistry = this.config.getWorkflowRunRegistry();
this.#workflowStatusChangeCallback = (entry) => {
this.#activeWorkChanged();
if (entry) this.#rememberWorkflowHistory(entry);
};
workflowRegistry.setStatusChangeCallback(
this.#workflowStatusChangeCallback,
);
workflowRegistry.setSnapshotPersistedCallback((runId) => {
// The run is safely on disk now; drop the unpersisted copy so a
// deletion by another session cannot resurrect it on refresh. The
// latch makes that retirement stick against the late terminal
// emissions draining dispatches still produce (R7-5).
this.persistedWorkflowRunIds.add(runId);
this.unpersistedWorkflowHistory.delete(runId);
});
workflowRegistry.setCompletionCallback((displayText, modelText, meta) => {
const entry = workflowRegistry.get(meta.runId);
this.#enqueueBackgroundNotification({
displayText,
modelText,
taskId: meta.runId,
status: meta.status,
kind: 'workflow',
continuesTodoStopGuardWorkChain:
!entry || !this.todoStopGuardBackgroundBaseline.workflows.has(entry),
todoWorkChainId: meta.todoWorkChainId,
});
});
// Session title recorded (auto-generated after a turn, or an in-process
// /rename) → notify attached clients. A title update is NOT an ACP
// `SessionUpdate` variant (the external @agentclientprotocol/sdk union
@@ -9042,8 +9351,8 @@ export class Session implements SessionContext {
const acceptance = this.#persistDaemonBackgroundNotification(item);
this.backgroundNotificationAcceptances.set(item.taskId, acceptance);
if (item.kind === 'agent') {
this.activeAgentNotificationAcceptances.add(item.taskId);
if (item.kind === 'agent' || item.kind === 'workflow') {
this.activeNotificationAcceptances.add(item.taskId);
this.#activeWorkChanged();
}
try {
@@ -9053,8 +9362,8 @@ export class Session implements SessionContext {
this.backgroundNotificationAcceptances.get(item.taskId) === acceptance
) {
this.backgroundNotificationAcceptances.delete(item.taskId);
if (item.kind === 'agent') {
this.activeAgentNotificationAcceptances.delete(item.taskId);
if (item.kind === 'agent' || item.kind === 'workflow') {
this.activeNotificationAcceptances.delete(item.taskId);
this.#activeWorkChanged();
}
}
@@ -9178,6 +9487,8 @@ export class Session implements SessionContext {
if (!item) break;
this.currentAgentNotificationTaskId =
item.kind === 'agent' ? item.taskId : null;
this.currentWorkflowNotificationTaskId =
item.kind === 'workflow' ? item.taskId : null;
this.currentShellNotificationActive = item.kind === 'shell';
this.#activeWorkChanged();
try {
@@ -9196,6 +9507,7 @@ export class Session implements SessionContext {
);
} finally {
this.currentAgentNotificationTaskId = null;
this.currentWorkflowNotificationTaskId = null;
this.currentShellNotificationActive = false;
this.#activeWorkChanged();
}
@@ -188,6 +188,13 @@ describe('Session.pendingWorktreeNotice', () => {
clearStatusChangeCallback: vi.fn(),
hasRunningEntries: vi.fn().mockReturnValue(false),
}),
getWorkflowRunRegistry: vi.fn().mockReturnValue({
setStatusChangeCallback: vi.fn(),
clearStatusChangeCallback: vi.fn(),
setCompletionCallback: vi.fn(),
setSnapshotPersistedCallback: vi.fn(),
setApprovalRequestCallback: vi.fn(),
}),
setSubSessionSpawner: vi.fn(),
getSubSessionSpawner: vi.fn(),
// The Session constructor and Session.prompt both reach for the
@@ -5,7 +5,13 @@
*/
import { describe, expect, it } from 'vitest';
import type { AgentTask, Config, MonitorTask } from '@qwen-code/qwen-code-core';
import type {
AgentTask,
Config,
MonitorTask,
WorkflowSnapshot,
WorkflowTask,
} from '@qwen-code/qwen-code-core';
import { buildSessionTasksStatus } from './tasksSnapshot.js';
import type { ServeSessionAgentTaskStatus } from '@qwen-code/acp-bridge/status';
@@ -25,14 +31,57 @@ function agentTask(overrides: Partial<AgentTask> = {}): AgentTask {
} as AgentTask;
}
function configWith(agents: AgentTask[]): Config {
function configWith(
agents: AgentTask[],
workflows: WorkflowTask[] = [],
): Config {
return {
getBackgroundTaskRegistry: () => ({ getAll: () => agents }),
getBackgroundShellRegistry: () => ({ getAll: () => [] }),
getMonitorRegistry: () => ({ getAll: () => [] }),
getWorkflowRunRegistry: () => ({ list: () => workflows }),
} as unknown as Config;
}
function workflowSnapshot(
overrides: Partial<WorkflowSnapshot> = {},
): WorkflowSnapshot {
return {
runId: 'wf_saved',
meta: { name: 'review-and-fix', description: 'Review and fix' },
status: 'failed',
script: 'return 1;',
phases: ['Inspect'],
phaseVisits: [
{
id: 'phase-1',
index: 0,
title: 'Inspect',
startedAt: 500,
endedAt: 900,
},
],
dispatches: [],
agentsDispatched: 2,
agentsCompleted: 1,
tokensSpent: 900,
tokenBudgetTotal: 4_000,
perPhaseTokens: [],
recentLogs: [],
events: [
{
id: 'event-1',
type: 'workflow-failed',
at: 1_000,
error: 'Review failed',
},
],
startTime: 500,
endTime: 1_000,
...overrides,
};
}
function serializedMonitor(
monitor: MonitorTask,
): Extract<
@@ -43,6 +92,7 @@ function serializedMonitor(
getBackgroundTaskRegistry: () => ({ getAll: () => [] }),
getBackgroundShellRegistry: () => ({ getAll: () => [] }),
getMonitorRegistry: () => ({ getAll: () => [monitor] }),
getWorkflowRunRegistry: () => ({ list: () => [] }),
} as unknown as Config;
return buildSessionTasksStatus('session-1', config, 2_000).tasks.find(
(task) => task.kind === 'monitor',
@@ -125,3 +175,251 @@ describe('buildSessionTasksStatus monitor correlation', () => {
expect(task.toolUseId).toBe('monitor-call-1');
});
});
describe('buildSessionTasksStatus workflow graph', () => {
it('omits workflow tasks unless the caller opts in', () => {
const snapshot = buildSessionTasksStatus(
'session-1',
configWith([]),
2_000,
[workflowSnapshot()],
);
expect(snapshot.tasks).toEqual([]);
});
it('exposes phase visits and dispatch dependencies from the workflow registry', () => {
const workflow = {
kind: 'workflow',
id: 'wf_graph',
runId: 'wf_graph',
toolUseId: 'workflow-call-1',
description: 'Review and fix',
meta: { name: 'review-and-fix', description: 'Review and fix' },
status: 'running',
startTime: 1_000,
isBackgrounded: true,
currentPhase: 'Review',
phases: ['Inspect', 'Review'],
phaseVisits: [
{
id: 'phase-1',
index: 0,
title: 'Inspect',
startedAt: 1_000,
endedAt: 1_200,
},
{ id: 'phase-2', index: 1, title: 'Review', startedAt: 1_200 },
],
currentPhaseVisitId: 'phase-2',
dispatches: [
{
id: 'dispatch-1',
phaseVisitId: 'phase-1',
label: 'Scope mapper',
prompt: 'Inspect the repository',
status: 'completed',
dependsOn: [],
queuedAt: 1_010,
startedAt: 1_020,
endedAt: 1_100,
},
{
id: 'dispatch-2',
phaseVisitId: 'phase-2',
label: 'Correctness',
prompt: 'Review correctness',
subagentId: 'correctness-agent-1',
status: 'running',
dependsOn: ['dispatch-1'],
queuedAt: 1_210,
startedAt: 1_220,
},
],
agentsDispatched: 2,
agentsCompleted: 1,
recentLogs: ['Review started'],
events: [
{
id: 'event-1',
type: 'log',
at: 1_250,
message: 'Review started',
},
{
id: 'event-2',
type: 'approval-requested',
at: 1_300,
name: 'write_file',
dispatchId: 'dispatch-2',
},
],
tokensSpent: 1_200,
tokenBudgetTotal: 8_000,
perPhaseTokens: new Map(),
script: '',
sourceRunId: 'wf_source',
startMode: 'rerun',
pendingApprovals: [
{
approvalId: 'wfap-1',
subagentId: 'correctness-agent-1',
callId: 'call-1',
name: 'write_file',
description: 'Update the implementation',
confirmationDetails: {} as never,
at: 1_300,
},
],
outputOffset: 0,
notified: false,
outputFile: '',
abortController: new AbortController(),
} as WorkflowTask;
const snapshot = buildSessionTasksStatus(
'session-1',
configWith([], [workflow]),
2_000,
[],
{ includeWorkflows: true },
);
const task = snapshot.tasks.find(
(candidate) => candidate.kind === 'workflow',
);
expect(task).toMatchObject({
kind: 'workflow',
id: 'wf_graph',
toolUseId: 'workflow-call-1',
label: 'review-and-fix',
currentPhase: 'Review',
agentsDispatched: 2,
agentsCompleted: 1,
tokensSpent: 1_200,
tokenBudgetTotal: 8_000,
sourceRunId: 'wf_source',
startMode: 'rerun',
phaseVisits: [
{ id: 'phase-1', title: 'Inspect' },
{ id: 'phase-2', title: 'Review' },
],
dispatches: [
{ id: 'dispatch-1', status: 'completed', dependsOn: [] },
{
id: 'dispatch-2',
status: 'running',
subagentId: 'correctness-agent-1',
dependsOn: ['dispatch-1'],
},
],
pendingApprovalCount: 1,
pendingApprovals: [
{
approvalId: 'wfap-1',
subagentId: 'correctness-agent-1',
name: 'write_file',
description: 'Update the implementation',
},
],
events: [
{
id: 'event-1',
type: 'log',
at: 1_250,
message: 'Review started',
},
{
id: 'event-2',
type: 'approval-requested',
at: 1_300,
name: 'write_file',
dispatchId: 'dispatch-2',
},
],
});
});
it('restores persisted workflow runs as read-only task history', () => {
const snapshot = buildSessionTasksStatus(
'session-1',
configWith([]),
2_000,
[workflowSnapshot()],
{ includeWorkflows: true },
);
expect(snapshot.tasks).toEqual([
expect.objectContaining({
kind: 'workflow',
id: 'wf_saved',
label: 'review-and-fix',
status: 'failed',
runtimeMs: 500,
isHistorical: true,
agentsDispatched: 2,
agentsCompleted: 1,
tokensSpent: 900,
events: [
{
id: 'event-1',
type: 'workflow-failed',
at: 1_000,
error: 'Review failed',
},
],
}),
]);
});
it('prefers the in-memory workflow task over a persisted duplicate', () => {
const workflow = {
kind: 'workflow',
id: 'wf_saved',
runId: 'wf_saved',
description: 'Live entry',
meta: { name: 'review-and-fix', description: 'Review and fix' },
status: 'completed',
startTime: 500,
endTime: 1_100,
isBackgrounded: true,
currentPhase: null,
phases: [],
phaseVisits: [],
currentPhaseVisitId: null,
dispatches: [],
agentsDispatched: 3,
agentsCompleted: 3,
recentLogs: [],
events: [],
tokensSpent: 1_200,
tokenBudgetTotal: 4_000,
perPhaseTokens: new Map(),
script: '',
pendingApprovals: [],
outputOffset: 0,
notified: true,
outputFile: '',
abortController: new AbortController(),
} as WorkflowTask;
const snapshot = buildSessionTasksStatus(
'session-1',
configWith([], [workflow]),
2_000,
[workflowSnapshot()],
{ includeWorkflows: true },
);
const workflows = snapshot.tasks.filter(
(candidate) => candidate.kind === 'workflow',
);
expect(workflows).toHaveLength(1);
expect(workflows[0]).toMatchObject({
id: 'wf_saved',
agentsCompleted: 3,
tokensSpent: 1_200,
});
expect(workflows[0]).not.toHaveProperty('isHistorical');
});
});
@@ -10,6 +10,8 @@ import {
type Config,
type MonitorTask,
type ShellTask,
type WorkflowSnapshot,
type WorkflowTask,
} from '@qwen-code/qwen-code-core';
import {
STATUS_SCHEMA_VERSION,
@@ -18,6 +20,7 @@ import {
type ServeSessionShellTaskStatus,
type ServeSessionTaskStatus,
type ServeSessionTasksStatus,
type ServeSessionWorkflowTaskStatus,
} from '@qwen-code/acp-bridge/status';
function runtimeMs(
@@ -122,11 +125,99 @@ function serializeMonitorTask(
};
}
function serializeWorkflowTask(
entry: WorkflowTask,
now: number,
): ServeSessionWorkflowTaskStatus {
return {
kind: 'workflow',
id: entry.runId,
...optionalField('toolUseId', entry.toolUseId),
...optionalField('sourceRunId', entry.sourceRunId),
...optionalField('startMode', entry.startMode),
label: entry.meta?.name ?? entry.description ?? entry.runId,
description: entry.meta?.description ?? entry.description,
status: entry.status,
startTime: entry.startTime,
runtimeMs: runtimeMs(entry, now),
outputFile: entry.outputFile,
...optionalField('endTime', entry.endTime),
isBackgrounded: entry.isBackgrounded === true,
currentPhase: entry.currentPhase,
phaseVisits: entry.phaseVisits.map((visit) => ({ ...visit })),
dispatches: entry.dispatches.map((dispatch) => ({
...dispatch,
dependsOn: [...dispatch.dependsOn],
})),
agentsDispatched: entry.agentsDispatched,
agentsCompleted: entry.agentsCompleted,
tokensSpent: entry.tokensSpent,
tokenBudgetTotal: entry.tokenBudgetTotal,
recentLogs: [...entry.recentLogs],
events: entry.events.map((event) => ({ ...event })),
pendingApprovalCount: entry.pendingApprovals.length,
pendingApprovals: entry.pendingApprovals.map((approval) => ({
approvalId: approval.approvalId,
subagentId: approval.subagentId,
name: approval.name,
description: approval.description,
at: approval.at,
})),
...optionalField('error', entry.error),
};
}
function serializeWorkflowSnapshot(
snapshot: WorkflowSnapshot,
): ServeSessionWorkflowTaskStatus {
return {
kind: 'workflow',
id: snapshot.runId,
isHistorical: true,
...optionalField('sourceRunId', snapshot.sourceRunId),
...optionalField('startMode', snapshot.startMode),
label: snapshot.meta?.name ?? snapshot.description ?? snapshot.runId,
description:
snapshot.meta?.description ?? snapshot.description ?? snapshot.runId,
status: snapshot.status,
startTime: snapshot.startTime,
...optionalField('endTime', snapshot.endTime),
runtimeMs: runtimeMs(snapshot, snapshot.endTime ?? snapshot.startTime),
isBackgrounded: false,
currentPhase: snapshot.phases.at(-1) ?? null,
phaseVisits: (snapshot.phaseVisits ?? []).map((visit) => ({ ...visit })),
dispatches: (snapshot.dispatches ?? []).map((dispatch) => ({
...dispatch,
dependsOn: [...dispatch.dependsOn],
})),
agentsDispatched: snapshot.agentsDispatched,
agentsCompleted: snapshot.agentsCompleted,
tokensSpent: snapshot.tokensSpent,
tokenBudgetTotal: snapshot.tokenBudgetTotal,
recentLogs: [...snapshot.recentLogs],
...optionalField(
'events',
snapshot.events?.map((event) => ({ ...event })),
),
pendingApprovalCount: 0,
...optionalField('error', snapshot.error),
};
}
export function buildSessionTasksStatus(
sessionId: string,
config: Config,
now = Date.now(),
workflowHistory: readonly WorkflowSnapshot[] = [],
options: { includeWorkflows?: boolean } = {},
): ServeSessionTasksStatus {
const includeWorkflows = options.includeWorkflows === true;
const workflowTasks = includeWorkflows
? config.getWorkflowRunRegistry().list()
: [];
const inMemoryWorkflowIds = new Set(
workflowTasks.map((entry) => entry.runId),
);
const tasks: ServeSessionTaskStatus[] = [
...config
.getBackgroundTaskRegistry()
@@ -140,6 +231,14 @@ export function buildSessionTasksStatus(
.getMonitorRegistry()
.getAll()
.map((entry) => serializeMonitorTask(entry, now)),
...(includeWorkflows
? workflowTasks.map((entry) => serializeWorkflowTask(entry, now))
: []),
...(includeWorkflows
? workflowHistory
.filter((snapshot) => !inMemoryWorkflowIds.has(snapshot.runId))
.map(serializeWorkflowSnapshot)
: []),
].sort((a, b) => a.startTime - b.startTime);
return {
+117 -3
View File
@@ -108,6 +108,10 @@ import {
setupGithubEventData,
} from '../routes/workspace-setup-github.js';
import { parseWorkspaceVoiceUpdateParams } from '../routes/workspace-voice.js';
import {
redactWorkflowsFromAvailableCommandsEvent,
redactWorkflowsFromSupportedCommands,
} from '../workflow-session-gate.js';
import { MAX_TRUST_REASON_LENGTH } from '../validation-limits.js';
import {
publicErrorMessage,
@@ -278,6 +282,8 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [
`${QWEN_METHOD_NS}session/detach`,
`${QWEN_METHOD_NS}session/context_usage`,
`${QWEN_METHOD_NS}session/tasks`,
`${QWEN_METHOD_NS}session/tasks/cancel`,
`${QWEN_METHOD_NS}session/tasks/workflow_action`,
`${QWEN_METHOD_NS}session/lsp`,
`${QWEN_METHOD_NS}session/artifacts`,
`${QWEN_METHOD_NS}session/artifacts/add`,
@@ -3010,10 +3016,14 @@ export class AcpDispatcher {
case `${QWEN_METHOD_NS}session/supported_commands`: {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
const status =
await this.bridge.getSessionSupportedCommandsStatus(sessionId);
this.replyConn(
conn,
id,
await this.bridge.getSessionSupportedCommandsStatus(sessionId),
this.isWorkspaceTrusted()
? status
: redactWorkflowsFromSupportedCommands(status),
);
return;
}
@@ -3744,11 +3754,112 @@ export class AcpDispatcher {
case `${QWEN_METHOD_NS}session/tasks`: {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
const result = await this.bridge.getSessionTasksStatus(sessionId);
const result = await this.bridge.getSessionTasksStatus(sessionId, {
// Same fail-closed shape as the workflow control surfaces:
// opting in here leaks strictly more than the redacted
// supported-commands surface on an untrusted workspace.
includeWorkflows:
this.isWorkspaceTrusted() && params['includeWorkflows'] === true,
});
this.replyConn(conn, id, result as unknown);
return;
}
case `${QWEN_METHOD_NS}session/tasks/cancel`: {
const sessionId = String(params['sessionId'] ?? '');
await this.withMutableOwned(conn, sessionId, id, async () => {
const taskId = String(params['taskId'] ?? '');
if (!taskId) {
if (id !== undefined) {
conn.sendConn(
error(id, RPC.INVALID_PARAMS, '`taskId` is required'),
);
}
return;
}
const kind = params['kind'];
if (
kind !== 'agent' &&
kind !== 'shell' &&
kind !== 'monitor' &&
kind !== 'workflow'
) {
if (id !== undefined) {
conn.sendConn(
error(
id,
RPC.INVALID_PARAMS,
'`kind` must be "agent", "shell", "monitor", or "workflow"',
),
);
}
return;
}
if (kind === 'workflow' && !this.isWorkspaceTrusted()) {
this.replyConn(conn, id, {
cancelled: false,
reason: 'disabled',
});
return;
}
const result = await this.bridge.cancelSessionTask(
sessionId,
taskId,
kind,
this.sessionCtx(conn, sessionId, loopback),
);
this.replyConn(conn, id, result as unknown);
});
return;
}
case `${QWEN_METHOD_NS}session/tasks/workflow_action`: {
const sessionId = String(params['sessionId'] ?? '');
await this.withMutableOwned(conn, sessionId, id, async () => {
const taskId = String(params['taskId'] ?? '');
if (!taskId) {
if (id !== undefined) {
conn.sendConn(
error(id, RPC.INVALID_PARAMS, '`taskId` is required'),
);
}
return;
}
const action = params['action'];
if (
action !== 'pause' &&
action !== 'resume' &&
action !== 'retry' &&
action !== 'rerun' &&
action !== 'delete-history' &&
action !== 'run-saved'
) {
if (id !== undefined) {
conn.sendConn(
error(
id,
RPC.INVALID_PARAMS,
'`action` must be "pause", "resume", "retry", "rerun", "delete-history", or "run-saved"',
),
);
}
return;
}
if (!this.isWorkspaceTrusted()) {
this.replyConn(conn, id, { changed: false });
return;
}
const result = await this.bridge.controlSessionWorkflowTask(
sessionId,
taskId,
action,
this.sessionCtx(conn, sessionId, loopback),
);
this.replyConn(conn, id, result as unknown);
});
return;
}
case `${QWEN_METHOD_NS}session/lsp`: {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
@@ -5410,9 +5521,12 @@ export class AcpDispatcher {
// `event.data` is the ACP `SessionNotification` (params shape).
// `event.id` is the bus cursor → SSE `id:` line for `Last-Event-ID`
// resume (the content frames §1.8 recovers all flow through here).
const shaped = this.isWorkspaceTrusted()
? event
: redactWorkflowsFromAvailableCommandsEvent(event);
conn.sendSession(
sessionId,
notification('session/update', event.data),
notification('session/update', shaped.data),
event.id,
);
return;
@@ -20,6 +20,7 @@ import type {
BridgeEvent,
SessionReplaySnapshot,
} from '@qwen-code/acp-bridge/eventBus';
import type { ServeSessionSupportedCommandsStatus } from '@qwen-code/acp-bridge/status';
import {
SessionArtifactAuthorizationError,
SessionArtifactValidationError,
@@ -417,7 +418,9 @@ class FakeBridge {
},
};
}
async getSessionSupportedCommandsStatus(sessionId: string) {
async getSessionSupportedCommandsStatus(
sessionId: string,
): Promise<ServeSessionSupportedCommandsStatus> {
return { v: 1, sessionId, availableCommands: [], availableSkills: [] };
}
/** Per-session in-memory pr bindings, mirroring the real bridge's
@@ -549,9 +552,48 @@ class FakeBridge {
async getSessionContextUsageStatus(sessionId: string) {
return { sessionId, used: 100, total: 1000 };
}
async getSessionTasksStatus(sessionId: string) {
lastSessionTasksOptions: { includeWorkflows?: boolean } | undefined;
async getSessionTasksStatus(
sessionId: string,
opts?: { includeWorkflows?: boolean },
) {
this.lastSessionTasksOptions = opts;
return { sessionId, tasks: [] };
}
lastCancelledTask:
| {
sessionId: string;
taskId: string;
kind: Parameters<HttpAcpBridge['cancelSessionTask']>[2];
context: Parameters<HttpAcpBridge['cancelSessionTask']>[3];
}
| undefined;
async cancelSessionTask(
sessionId: string,
taskId: string,
kind: Parameters<HttpAcpBridge['cancelSessionTask']>[2],
context: Parameters<HttpAcpBridge['cancelSessionTask']>[3],
) {
this.lastCancelledTask = { sessionId, taskId, kind, context };
return { cancelled: true };
}
lastWorkflowAction:
| {
sessionId: string;
taskId: string;
action: Parameters<HttpAcpBridge['controlSessionWorkflowTask']>[2];
context: Parameters<HttpAcpBridge['controlSessionWorkflowTask']>[3];
}
| undefined;
async controlSessionWorkflowTask(
sessionId: string,
taskId: string,
action: Parameters<HttpAcpBridge['controlSessionWorkflowTask']>[2],
context: Parameters<HttpAcpBridge['controlSessionWorkflowTask']>[3],
) {
this.lastWorkflowAction = { sessionId, taskId, action, context };
return { changed: true, status: 'running' as const };
}
async getSessionLspStatus(sessionId: string) {
return {
v: 1,
@@ -7844,7 +7886,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
sessionId: 'sess-1',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [{ name: 'help', description: 'Help' }],
availableCommands: [
{ name: 'help', description: 'Help' },
{ name: 'workflows', description: 'Manage workflows' },
],
_meta: {
availableSkills: ['bugfix'],
availableSkillDetails: skillDetails,
@@ -7865,7 +7910,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
sessionId: 'sess-1',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [{ name: 'help', description: 'Help' }],
availableCommands: [
{ name: 'help', description: 'Help' },
{ name: 'workflows', description: 'Manage workflows' },
],
_meta: {
availableSkills: ['bugfix'],
availableSkillDetails: skillDetails,
@@ -8553,12 +8601,198 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
jsonrpc: '2.0',
id: 57,
method: '_qwen/session/tasks',
params: { sessionId: 'sess-1' },
params: { sessionId: 'sess-1', includeWorkflows: true },
});
const frames = await takeFrames(await streamRes, 2);
expect(frames[1]).toMatchObject({
result: { sessionId: 'sess-1', tasks: [] },
});
expect(bridge.lastSessionTasksOptions).toEqual({
includeWorkflows: true,
});
});
it('_qwen/session/tasks/cancel forwards the task and trusted client context', async () => {
const connId = await initialize();
const streamRes = openStream(connId);
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 99,
method: 'session/new',
params: {},
});
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 58,
method: '_qwen/session/tasks/cancel',
params: {
sessionId: 'sess-1',
taskId: 'workflow-1',
kind: 'workflow',
clientId: 'forged-client',
},
});
const frames = await takeFrames(await streamRes, 2);
expect(frames[1]).toMatchObject({ result: { cancelled: true } });
expect(bridge.lastCancelledTask).toEqual({
sessionId: 'sess-1',
taskId: 'workflow-1',
kind: 'workflow',
context: { clientId: 'client-1', fromLoopback: true },
});
});
it('_qwen/session/tasks/workflow_action forwards the action result and trusted client context', async () => {
const connId = await initialize();
const streamRes = openStream(connId);
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 99,
method: 'session/new',
params: {},
});
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 59,
method: '_qwen/session/tasks/workflow_action',
params: {
sessionId: 'sess-1',
taskId: 'workflow-1',
action: 'retry',
clientId: 'forged-client',
},
});
const frames = await takeFrames(await streamRes, 2);
expect(frames[1]).toMatchObject({
result: { changed: true, status: 'running' },
});
expect(bridge.lastWorkflowAction).toEqual({
sessionId: 'sess-1',
taskId: 'workflow-1',
action: 'retry',
context: { clientId: 'client-1', fromLoopback: true },
});
});
it('fails the Workflow surfaces closed for an untrusted workspace', async () => {
await restartServer({ primaryTrusted: false });
bridge.getSessionSupportedCommandsStatus = async (
sessionId: string,
): Promise<ServeSessionSupportedCommandsStatus> => ({
v: 1,
sessionId,
availableCommands: [
{ name: 'init', description: 'Initialize', input: null },
{ name: 'workflows', description: 'Manage workflows', input: null },
],
availableSkills: [],
workflowsEnabled: true,
savedWorkflows: [{ name: 'slow-phases', source: 'project' as const }],
});
const connId = await initialize();
const streamRes = openStream(connId);
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 99,
method: 'session/new',
params: {},
});
await new Promise((r) => setTimeout(r, 30));
const sessionStream = await openStream(connId, 'sess-1');
const sessionFrame = takeFrames(sessionStream, 1);
await new Promise((r) => setTimeout(r, 30));
bridge.queues.get('sess-1')?.push({
type: 'session_update',
data: {
sessionId: 'sess-1',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{ name: 'init', description: 'Initialize' },
{ name: 'workflows', description: 'Manage workflows' },
],
},
},
});
await post(connId, {
jsonrpc: '2.0',
id: 60,
method: '_qwen/session/supported_commands',
params: { sessionId: 'sess-1' },
});
await post(connId, {
jsonrpc: '2.0',
id: 61,
method: '_qwen/session/tasks/cancel',
params: { sessionId: 'sess-1', taskId: 'wf-1', kind: 'workflow' },
});
await post(connId, {
jsonrpc: '2.0',
id: 62,
method: '_qwen/session/tasks/workflow_action',
params: {
sessionId: 'sess-1',
taskId: 'slow-phases',
action: 'run-saved',
},
});
await post(connId, {
jsonrpc: '2.0',
id: 63,
method: '_qwen/session/tasks',
params: { sessionId: 'sess-1', includeWorkflows: true },
});
const frames = await takeFrames(await streamRes, 7);
const byId = new Map(
frames
.filter(
(frame): frame is { id: number; result?: unknown } =>
typeof frame === 'object' &&
frame !== null &&
'id' in frame &&
typeof frame.id === 'number',
)
.map((frame) => [frame.id, frame]),
);
expect(byId.get(60)).toMatchObject({
result: {
workflowsEnabled: false,
savedWorkflows: [],
availableCommands: [{ name: 'init', description: 'Initialize' }],
},
});
expect(byId.get(61)).toMatchObject({
result: { cancelled: false, reason: 'disabled' },
});
expect(byId.get(62)).toMatchObject({ result: { changed: false } });
expect(byId.get(63)).toMatchObject({
result: { sessionId: 'sess-1', tasks: [] },
});
await expect(sessionFrame).resolves.toEqual([
expect.objectContaining({
method: 'session/update',
params: expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'available_commands_update',
availableCommands: [{ name: 'init', description: 'Initialize' }],
}),
}),
}),
]);
// The untrusted-workspace gate fail-closes the read path too: the
// includeWorkflows opt-in must not reach the child.
expect(bridge.lastSessionTasksOptions).toEqual({
includeWorkflows: false,
});
expect(bridge.lastCancelledTask).toBeUndefined();
expect(bridge.lastWorkflowAction).toBeUndefined();
});
it('_qwen/session/lsp returns status', async () => {
@@ -142,7 +142,7 @@ interface FakeBridge extends AcpSessionBridge {
readonly taskCancelCalls: Array<{
sessionId: string;
taskId: string;
taskKind: 'agent' | 'shell' | 'monitor';
taskKind: 'agent' | 'shell' | 'monitor' | 'workflow';
}>;
readonly goalClearCalls: string[];
readonly continueCalls: Array<{
@@ -758,11 +758,27 @@ function makeBridge(
async cancelSessionTask(
sessionId: string,
taskId: string,
taskKind: 'agent' | 'shell' | 'monitor',
taskKind: 'agent' | 'shell' | 'monitor' | 'workflow',
) {
taskCancelCalls.push({ sessionId, taskId, taskKind });
return { cancelled: workspaceCwd === SECONDARY_CWD };
},
async controlSessionWorkflowTask(
_sessionId: string,
_taskId: string,
action:
| 'pause'
| 'resume'
| 'retry'
| 'rerun'
| 'delete-history'
| 'run-saved',
) {
return {
changed: workspaceCwd === SECONDARY_CWD,
status: action === 'pause' ? 'pausing' : 'running',
};
},
async clearSessionGoal(sessionId: string) {
goalClearCalls.push(sessionId);
return {
@@ -1132,6 +1148,7 @@ function makeHarness(opts?: {
undefined,
{
workspaceRegistry: registry,
daemonEnv: {},
...(opts?.daemonLog ? { daemonLog: opts.daemonLog } : {}),
...(opts?.liveConversationWorkspace
? { liveConversationWorkspace: opts.liveConversationWorkspace }
@@ -1180,13 +1197,20 @@ describe('multi-workspace session dispatch', () => {
expect(res.body.features).toContain('workspace_archived_session_export');
expect(res.body.features).toContain('workspace_display_name');
expect(res.body.workspaces).toEqual([
{ id: 'primary-id', cwd: PRIMARY_CWD, primary: true, trusted: true },
{
id: 'primary-id',
cwd: PRIMARY_CWD,
primary: true,
trusted: true,
workflowsEnabled: false,
},
{
id: 'secondary-id',
cwd: SECONDARY_CWD,
displayName: 'Secondary workspace',
primary: false,
trusted: true,
workflowsEnabled: false,
},
]);
expect(res.body.limits.maxSessionsPerWorkspace).toBe(32);
+25 -1
View File
@@ -18,7 +18,10 @@ import {
type CapabilitiesEnvelope,
type ServeOptions,
} from '../types.js';
import type { WorkspaceRegistry } from '../workspace-registry.js';
import type {
WorkspaceRegistry,
WorkspaceRuntime,
} from '../workspace-registry.js';
interface RegisterCapabilitiesRoutesDeps {
qwenCodeVersion?: string;
@@ -32,6 +35,23 @@ interface RegisterCapabilitiesRoutesDeps {
maxPendingPromptsPerSession: ServeOptions['maxPendingPromptsPerSession'];
sessionRestoreTimeoutMs: number;
languageCodes: string[];
daemonEnv: Readonly<NodeJS.ProcessEnv>;
}
function workflowsEnabledForRuntime(
runtime: WorkspaceRuntime | undefined,
daemonEnv: Readonly<NodeJS.ProcessEnv>,
): boolean {
if (!runtime || !runtime.trusted) return false;
const env =
runtime.env.mode === 'runtime-overlay'
? (runtime.env.effectiveEnv ?? {})
: (runtime.env.effectiveEnv ?? daemonEnv);
if (env['QWEN_CODE_DISABLE_WORKFLOWS'] === '1') return false;
return (
env['QWEN_CODE_ENABLE_WORKFLOWS'] === '1' ||
runtime.env.workflowsEnabledBySettings === true
);
}
export function registerCapabilitiesRoutes(
@@ -103,6 +123,10 @@ export function registerCapabilitiesRoutes(
primary: entry.primary,
trusted:
entry.state === 'active' && entry.current?.runtime.trusted === true,
workflowsEnabled: workflowsEnabledForRuntime(
entry.state === 'active' ? entry.current?.runtime : undefined,
deps.daemonEnv,
),
...(runtimeRemoval ? { removable: entry.removable } : {}),
...(entry.current?.runtime.provenance === 'live-conversation'
? { kind: 'live' as const }
+135 -21
View File
@@ -44,6 +44,7 @@ import {
DAEMON_PROMPT_DISPLAY_TEXT_META_KEY,
type BridgeBranchedSession,
} from '@qwen-code/acp-bridge/bridgeTypes';
import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
import { parseSessionSource } from '@qwen-code/acp-bridge';
import {
isReservedLiveSessionSource,
@@ -163,6 +164,11 @@ import {
sendUntrustedWorkspaceResponse,
sendWorkspaceRuntimeUnavailable,
} from '../workspace-route-runtime.js';
import {
redactWorkflowsFromAvailableCommandsEvent,
redactWorkflowsFromReplayArrays,
redactWorkflowsFromSupportedCommands,
} from '../workflow-session-gate.js';
import type {
WorkspaceEntry,
WorkspaceRegistry,
@@ -192,6 +198,26 @@ import {
// storage is case-folding on macOS/Windows.
const GIT_RESERVED_BRANCH = 'HEAD';
function redactSdkSurfaceEvent<T extends { type: string; data: unknown }>(
event: T,
workspaceTrusted: boolean,
): T {
const shaped = omitSkillDetailsForSdkSurface(event);
return workspaceTrusted
? shaped
: redactWorkflowsFromAvailableCommandsEvent(shaped);
}
function redactSdkSurfaceReplay<
T extends {
compactedReplay?: BridgeEvent[];
liveJournal?: BridgeEvent[];
},
>(session: T, workspaceTrusted: boolean): T {
const shaped = omitSkillDetailsFromReplayArrays(session);
return workspaceTrusted ? shaped : redactWorkflowsFromReplayArrays(shaped);
}
// Byte-length caps for branch names. git creates loose refs as files under
// `.git/refs/heads/`, so each `/`-separated component is bounded by the
// filesystem's per-component name limit (255 bytes on Linux/macOS, minus the
@@ -3404,7 +3430,9 @@ export function registerSessionRoutes(
}
// Same replay-array shape as the load response; redact skill
// bodies for the browser surface (#9234).
res.status(200).json(omitSkillDetailsFromReplayArrays(session));
res
.status(200)
.json(redactSdkSurfaceReplay(session, runtime.trusted));
} catch (err) {
sendBridgeError(res, err, { route, sessionId });
}
@@ -3522,7 +3550,9 @@ export function registerSessionRoutes(
}
return;
}
res.status(200).json(omitSkillDetailsFromReplayArrays(session));
res
.status(200)
.json(redactSdkSurfaceReplay(session, runtime.trusted));
return;
}
} catch (error) {
@@ -3912,7 +3942,7 @@ export function registerSessionRoutes(
}
// The load response embeds the replay snapshot inline; redact the
// skill bodies there just like the SSE egress does (#9234).
res.status(200).json(omitSkillDetailsFromReplayArrays(session));
res.status(200).json(redactSdkSurfaceReplay(session, runtime.trusted));
} catch (err) {
if (err instanceof RequestedSessionIdAdmissionError) {
sendRequestedSessionIdAdmissionError(res, err, route);
@@ -4111,7 +4141,10 @@ export function registerSessionRoutes(
res
.status(201)
.json(
omitSkillDetailsFromReplayArrays(result as BridgeBranchedSession),
redactSdkSurfaceReplay(
result as BridgeBranchedSession,
runtime.trusted,
),
);
},
{ rejectStandalone: true },
@@ -4181,7 +4214,7 @@ export function registerSessionRoutes(
}
return;
}
res.status(201).json(omitSkillDetailsFromReplayArrays(result));
res.status(201).json(redactSdkSurfaceReplay(result, runtime.trusted));
},
{ rejectStandalone: true },
),
@@ -4334,6 +4367,7 @@ export function registerSessionRoutes(
return;
}
let workspaceTrusted = false;
try {
const result = await archiveCoordinator.runSharedMany(
[sessionId],
@@ -4345,6 +4379,7 @@ export function registerSessionRoutes(
cursor !== undefined,
);
if (!runtime) return undefined;
workspaceTrusted = runtime.trusted;
captureRuntimeGenerationAssertion(runtime)?.();
return runtime.bridge.getSessionTranscriptPage({
sessionId,
@@ -4360,7 +4395,9 @@ export function registerSessionRoutes(
.set('Cache-Control', 'no-store')
.json({
...result,
events: (result.events ?? []).map(omitSkillDetailsForSdkSurface),
events: (result.events ?? []).map((event) =>
redactSdkSurfaceEvent(event, workspaceTrusted),
),
});
} catch (err) {
sendBridgeError(res, err, {
@@ -4489,11 +4526,14 @@ export function registerSessionRoutes(
v: 1 as const,
sessionId,
events: replay.updates.map((update) =>
omitSkillDetailsForSdkSurface({
v: 1 as const,
type: 'session_update' as const,
data: update,
}),
redactSdkSurfaceEvent(
{
v: 1 as const,
type: 'session_update' as const,
data: update,
},
runtime.trusted,
),
),
...(replay.nextCursor && !cursorTooLarge
? { nextCursor: replay.nextCursor }
@@ -4638,10 +4678,14 @@ export function registerSessionRoutes(
withOwnerReadSession(
'GET /session/:id/supported-commands',
async (_req, res, sessionId, runtime) => {
const status =
await runtime.bridge.getSessionSupportedCommandsStatus(sessionId);
res
.status(200)
.json(
await runtime.bridge.getSessionSupportedCommandsStatus(sessionId),
runtime.trusted
? status
: redactWorkflowsFromSupportedCommands(status),
);
},
),
@@ -4651,10 +4695,16 @@ export function registerSessionRoutes(
'/session/:id/tasks',
withOwnerReadSession(
'GET /session/:id/tasks',
async (_req, res, sessionId, runtime) => {
res
.status(200)
.json(await runtime.bridge.getSessionTasksStatus(sessionId));
async (req, res, sessionId, runtime) => {
res.status(200).json(
await runtime.bridge.getSessionTasksStatus(sessionId, {
// Same fail-closed shape as the workflow control surfaces:
// opting in here leaks strictly more than the redacted
// supported-commands surface on an untrusted workspace.
includeWorkflows:
runtime.trusted && req.query['includeWorkflows'] === 'true',
}),
);
},
),
);
@@ -4807,16 +4857,80 @@ export function registerSessionRoutes(
}
const body = safeBody(req);
const kind = body['kind'];
if (kind !== 'agent' && kind !== 'shell' && kind !== 'monitor') {
res
.status(400)
.json({ error: '`kind` must be "agent", "shell", or "monitor"' });
if (
kind !== 'agent' &&
kind !== 'shell' &&
kind !== 'monitor' &&
kind !== 'workflow'
) {
res.status(400).json({
error: '`kind` must be "agent", "shell", "monitor", or "workflow"',
});
return;
}
if (kind === 'workflow' && !runtime.trusted) {
res.status(200).json({ cancelled: false, reason: 'disabled' });
return;
}
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
res
.status(200)
.json(
await runtime.bridge.cancelSessionTask(sessionId, taskId, kind),
await runtime.bridge.cancelSessionTask(
sessionId,
taskId,
kind,
clientId !== undefined ? { clientId } : undefined,
),
);
},
),
);
app.post(
'/session/:id/tasks/:taskId/workflow-action',
mutate({ strict: true }),
withOwnerMutableSession(
'POST /session/:id/tasks/:taskId/workflow-action',
async (req, res, sessionId, runtime) => {
const taskId = req.params['taskId'];
if (!taskId) {
res.status(400).json({
error: '`taskId` route parameter is required',
});
return;
}
const action = safeBody(req)['action'];
if (
action !== 'pause' &&
action !== 'resume' &&
action !== 'retry' &&
action !== 'rerun' &&
action !== 'delete-history' &&
action !== 'run-saved'
) {
res.status(400).json({
error:
'`action` must be "pause", "resume", "retry", "rerun", "delete-history", or "run-saved"',
});
return;
}
if (!runtime.trusted) {
res.status(200).json({ changed: false });
return;
}
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
res
.status(200)
.json(
await runtime.bridge.controlSessionWorkflowTask(
sessionId,
taskId,
action,
clientId !== undefined ? { clientId } : undefined,
),
);
},
),
+11 -3
View File
@@ -35,6 +35,7 @@ import {
} from '../server/request-helpers.js';
import { parseEventEpochHeader } from '../sse-last-event-id.js';
import { omitSkillDetailsForSdkSurface } from '../skill-details-redaction.js';
import { redactWorkflowsFromAvailableCommandsEvent } from '../workflow-session-gate.js';
import type { WorkspaceRegistry } from '../workspace-registry.js';
import { isInternalWorkspaceRuntime } from '../workspace-runtime-visibility.js';
import { requireSessionRuntime } from './session-runtime.js';
@@ -128,8 +129,13 @@ interface RegisterSseEventsRoutesDeps {
type OmitId<T> = Omit<T, 'id'>;
function formatSseFrame(event: BridgeEvent | OmitId<BridgeEvent>): string {
const shaped = omitSkillDetailsForSdkSurface(event);
function formatSseFrame(
event: BridgeEvent | OmitId<BridgeEvent>,
workspaceTrusted = false,
): string {
const shaped = omitSkillDetailsForSdkSurface(
workspaceTrusted ? event : redactWorkflowsFromAvailableCommandsEvent(event),
);
// SSE format: id (optional), event (optional), data, blank line.
// The `id:` line is intentionally omitted when `event.id` is absent —
// terminal/synthetic frames (e.g. daemon-side `stream_error`) must not
@@ -333,6 +339,7 @@ export function registerSseEventsRoutes(
let iter: AsyncIterator<BridgeEvent> | undefined;
let busEpoch: string | undefined;
let workspaceTrusted = false;
const abort = new AbortController();
try {
const virtualKey = parseVirtualSubagentSessionId(sessionId);
@@ -344,6 +351,7 @@ export function registerSseEventsRoutes(
daemonLog,
});
if (!runtime) return;
workspaceTrusted = runtime.trusted;
const snapshot = req.query['snapshot'] === '1';
const openSubscription = async (): Promise<
{ iter: AsyncIterator<BridgeEvent>; busEpoch?: string } | undefined
@@ -981,7 +989,7 @@ export function registerSseEventsRoutes(
const liveEvent = liveTimingEnabled;
const serverTimestamp = next.value._meta?.['serverTimestamp'];
const outcome = await writeWithBackpressure(
formatSseFrame(next.value),
formatSseFrame(next.value, workspaceTrusted),
);
if (outcome === 'closed') break;
eventFramesWriteSettled += 1;
@@ -20,7 +20,11 @@ import { registerStandaloneSessionRoutes } from './standalone-sessions.js';
const sessionId = '11111111-1111-4111-8111-111111111111';
function createHarness() {
function createHarness({
isWorkspaceTrusted = () => true,
}: {
isWorkspaceTrusted?: () => boolean;
} = {}) {
const service = {
create: vi.fn(async () => ({
session: {
@@ -90,6 +94,7 @@ function createHarness() {
service: service as unknown as StandaloneSessionService,
mutate,
sendBridgeError,
isWorkspaceTrusted,
});
return { app, service, mutate };
}
@@ -424,6 +429,48 @@ describe('standalone session routes', () => {
expect(JSON.stringify(response.body)).not.toContain('full skill body');
});
it.each(['load', 'resume'] as const)(
'redacts workflows from untrusted standalone %s replay arrays',
async (action) => {
const { app, service } = createHarness({
isWorkspaceTrusted: () => false,
});
const commandsEvent = {
id: 1,
v: 1,
type: 'session_update',
data: {
sessionId,
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{ name: 'help', description: 'Help' },
{ name: 'workflows', description: 'Manage workflows' },
],
},
},
};
service[action].mockResolvedValueOnce({
sessionId,
compactedReplay: [commandsEvent],
liveJournal: [commandsEvent],
} as never);
const response = await request(app)
.post(`/standalone/sessions/${sessionId}/${action}`)
.send({});
expect(response.status).toBe(200);
for (const replayKey of ['compactedReplay', 'liveJournal'] as const) {
expect(
response.body[replayKey][0].data.update.availableCommands.map(
(command: { name: string }) => command.name,
),
).toEqual(['help']);
}
},
);
it('repairs an exact directory only with an empty object body', async () => {
const { app, service } = createHarness();
@@ -19,6 +19,7 @@ import type {
StandaloneSessionService,
} from '../conversations/standalone-session-service.js';
import { omitSkillDetailsFromReplayArrays } from '../skill-details-redaction.js';
import { redactWorkflowsFromReplayArrays } from '../workflow-session-gate.js';
import type { SendBridgeError } from '../server/error-response.js';
import { InvalidCursorError } from '../server/session-list.js';
import {
@@ -33,6 +34,7 @@ export interface RegisterStandaloneSessionRoutesDeps {
service: StandaloneSessionService;
mutate: (options?: { strict?: boolean }) => RequestHandler;
sendBridgeError: SendBridgeError;
isWorkspaceTrusted: () => boolean;
}
function sendInvalidRequest(res: Response, message: string): void {
@@ -426,7 +428,14 @@ export function registerStandaloneSessionRoutes(
await cleanupRestore()?.catch(() => undefined);
return;
}
res.status(200).json(omitSkillDetailsFromReplayArrays(restored));
const shaped = omitSkillDetailsFromReplayArrays(restored);
res
.status(200)
.json(
deps.isWorkspaceTrusted()
? shaped
: redactWorkflowsFromReplayArrays(shaped),
);
},
),
);
+33 -2
View File
@@ -86,6 +86,7 @@ import {
import { getDeferredRuntimeRequestTiming } from './server/request-helpers.js';
import type { WorkspaceFileSystemFactory } from './fs/workspace-file-system.js';
import { ConversationWorkspace } from './conversations/conversation-workspace.js';
import type { WorkspaceRuntimeProvenance } from './managed-scratch-workspace.js';
import * as scheduledTaskKeepalive from './scheduled-task-keepalive.js';
const originalTestRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
@@ -7335,6 +7336,7 @@ describe('runQwenServe runtime startup failures', () => {
() =>
({
merged: {
tools: { workflowsEnabled: !runtimeMounted },
advanced: {
runtimeOutputDir: runtimeMounted
? '.runtime-reloaded'
@@ -7421,6 +7423,7 @@ describe('runQwenServe runtime startup failures', () => {
const pinnedRuntimeBaseDir = path.join(tmpDir, '.runtime-boot');
expect(primaryRuntime?.sessionRuntimeBaseDir).toBe(pinnedRuntimeBaseDir);
expect(capturedRuntimeEnv['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
expect(primaryRuntime?.env.workflowsEnabledBySettings).toBe(true);
await workspace!.reload({
route: 'POST /workspace/reload',
@@ -7437,6 +7440,7 @@ describe('runQwenServe runtime startup failures', () => {
expect(capturedRuntimeEnv['QWEN_TEST_RELOAD_LEAK']).toBeUndefined();
expect(primaryRuntime?.sessionRuntimeBaseDir).toBe(pinnedRuntimeBaseDir);
expect(capturedRuntimeEnv['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
expect(primaryRuntime?.env.workflowsEnabledBySettings).toBe(false);
reloadedRuntimeValue = 'hot-synced';
await expect(
@@ -7696,15 +7700,18 @@ describe('runQwenServe runtime startup failures', () => {
);
const primary = path.join(tmpDir, 'primary');
const secondary = path.join(tmpDir, 'secondary');
const dynamic = path.join(tmpDir, 'dynamic');
const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
delete process.env['QWEN_RUNTIME_DIR'];
fs.mkdirSync(primary);
fs.mkdirSync(secondary);
fs.mkdirSync(dynamic);
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
enabled: false,
sensitiveSpanAttributeMaxLength: 1024 * 1024,
});
let runtimeMounted = false;
let dynamicReloaded = false;
let providerRuntimeMutation = false;
let failEnvFileRead = false;
vi.spyOn(settingsRuntime, 'loadSettings').mockImplementation(
@@ -7713,6 +7720,10 @@ describe('runQwenServe runtime startup failures', () => {
const isSecondary = workspace === secondary;
return {
merged: {
tools: {
workflowsEnabled:
workspace === dynamic ? !dynamicReloaded : !runtimeMounted,
},
advanced: {
runtimeOutputDir: isSecondary
? runtimeMounted
@@ -7776,10 +7787,17 @@ describe('runQwenServe runtime startup failures', () => {
let workspaceRegistry:
| import('./workspace-registry.js').WorkspaceRegistry
| undefined;
let createWorkspaceRuntime:
| ((
cwd: string,
options: { provenance: WorkspaceRuntimeProvenance },
) => Promise<import('./workspace-registry.js').WorkspaceRuntime>)
| undefined;
vi.spyOn(serverModule, 'createServeApp').mockImplementation(
(_opts, _getPort, deps) => {
runtimeMounted = true;
workspaceRegistry = deps?.workspaceRegistry;
createWorkspaceRuntime = deps?.createWorkspaceRuntime;
return express();
},
);
@@ -7815,6 +7833,7 @@ describe('runQwenServe runtime startup failures', () => {
pinnedRuntimeBaseDir,
);
expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
expect(env.workflowsEnabledBySettings).toBe(true);
await expect(
secondaryRuntime!.workspaceService.reload({
@@ -7832,6 +7851,18 @@ describe('runQwenServe runtime startup failures', () => {
pinnedRuntimeBaseDir,
);
expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
expect(env.workflowsEnabledBySettings).toBe(false);
const dynamicRuntime = await createWorkspaceRuntime!(dynamic, {
provenance: 'existing',
});
expect(dynamicRuntime.env.workflowsEnabledBySettings).toBe(true);
dynamicReloaded = true;
await dynamicRuntime.workspaceService.reload({
route: 'POST /workspace/reload',
workspaceCwd: dynamic,
});
expect(dynamicRuntime.env.workflowsEnabledBySettings).toBe(false);
providerRuntimeMutation = true;
await expect(
@@ -7840,7 +7871,7 @@ describe('runQwenServe runtime startup failures', () => {
workspaceCwd: secondary,
}),
).resolves.toEqual({ status: 'applied' });
expect(reloadEnvironment).toHaveBeenCalledOnce();
expect(reloadEnvironment).toHaveBeenCalledTimes(2);
expect(env.effectiveEnv?.['QWEN_TEST_SECONDARY_ENV']).toBe(
'provider-reloaded',
);
@@ -7852,7 +7883,7 @@ describe('runQwenServe runtime startup failures', () => {
workspaceCwd: secondary,
}),
).resolves.toMatchObject({ runtimeEnvironmentApplied: false });
expect(reloadEnvironment).toHaveBeenCalledOnce();
expect(reloadEnvironment).toHaveBeenCalledTimes(2);
expect(env.effectiveEnv?.['QWEN_TEST_SECONDARY_ENV']).toBe(
'provider-reloaded',
);
+13
View File
@@ -4778,6 +4778,7 @@ async function runQwenServeImpl(
overlayKeys: string[];
envFilePaths: string[];
effectiveEnv: NodeJS.ProcessEnv;
workflowsEnabledBySettings: boolean;
envFileReadFailed: boolean;
envFileReadFailures: Array<{ path: string; error: string }>;
fallbackReason?: string;
@@ -4785,6 +4786,8 @@ async function runQwenServeImpl(
mode: 'runtime-overlay' as const,
overlayKeys: [...runtimeEnvSnapshot.overlayKeys],
effectiveEnv: runtimeEffectiveEnv,
workflowsEnabledBySettings:
runtimeBootSettings?.merged.tools?.workflowsEnabled === true,
envFilePaths: [...runtimeEnvSnapshot.envFilePaths],
envFileReadFailed: runtimeEnvSnapshot.envFileReadFailed,
envFileReadFailures: [...runtimeEnvSnapshot.envFileReadFailures],
@@ -5565,6 +5568,8 @@ async function runQwenServeImpl(
};
}
replaceRuntimeEffectiveEnv(refreshedRuntimeEnv.effectiveEnv);
primaryRuntimeEnv.workflowsEnabledBySettings =
fresh.merged.tools?.workflowsEnabled === true;
delete primaryRuntimeEnv.fallbackReason;
primaryRuntimeEnv.envFileReadFailed =
refreshedRuntimeEnv.envFileReadFailed;
@@ -5655,6 +5660,7 @@ async function runQwenServeImpl(
overlayKeys: string[];
envFilePaths: string[];
effectiveEnv: NodeJS.ProcessEnv;
workflowsEnabledBySettings: boolean;
envFileReadFailed: boolean;
envFileReadFailures: Array<{ path: string; error: string }>;
fallbackReason?: string;
@@ -5692,6 +5698,7 @@ async function runQwenServeImpl(
overlayKeys: string[];
envFilePaths: string[];
effectiveEnv: NodeJS.ProcessEnv;
workflowsEnabledBySettings: boolean;
envFileReadFailed: boolean;
envFileReadFailures: Array<{ path: string; error: string }>;
fallbackReason?: string;
@@ -5699,6 +5706,8 @@ async function runQwenServeImpl(
mode: 'runtime-overlay',
overlayKeys: [...snapshot.overlayKeys],
effectiveEnv,
workflowsEnabledBySettings:
settings?.merged.tools?.workflowsEnabled === true,
envFilePaths: [...snapshot.envFilePaths],
envFileReadFailed: snapshot.envFileReadFailed,
envFileReadFailures: [...snapshot.envFileReadFailures],
@@ -6141,6 +6150,8 @@ async function runQwenServeImpl(
}
try {
secondaryEnv.replace(refreshedRuntimeEnv.effectiveEnv);
secondaryEnv.metadata.workflowsEnabledBySettings =
fresh.merged.tools?.workflowsEnabled === true;
secondaryEnv.metadata.envFileReadFailed =
refreshedRuntimeEnv.envFileReadFailed;
secondaryEnv.metadata.envFileReadFailures.splice(
@@ -6822,6 +6833,8 @@ async function runQwenServeImpl(
}
try {
wsEnv.replace(refreshedRuntimeEnv.effectiveEnv);
wsEnv.metadata.workflowsEnabledBySettings =
fresh.merged.tools?.workflowsEnabled === true;
wsEnv.metadata.envFileReadFailed =
refreshedRuntimeEnv.envFileReadFailed;
wsEnv.metadata.envFileReadFailures.splice(
+578 -22
View File
@@ -934,14 +934,30 @@ interface FakeBridgeOpts {
sessionId: string,
) => Promise<ServeSessionSupportedCommandsStatus>;
sessionStatsImpl?: (sessionId: string) => Promise<ServeSessionStatsStatus>;
sessionTasksImpl?: (sessionId: string) => Promise<ServeSessionTasksStatus>;
sessionTasksImpl?: (
sessionId: string,
opts?: { includeWorkflows?: boolean },
) => Promise<ServeSessionTasksStatus>;
sessionLspImpl?: (sessionId: string) => Promise<ServeSessionLspStatus>;
sessionTranscriptImpl?: AcpSessionBridge['getSessionTranscriptPage'];
cancelSessionTaskImpl?: (
sessionId: string,
taskId: string,
taskKind: 'agent' | 'shell' | 'monitor',
taskKind: 'agent' | 'shell' | 'monitor' | 'workflow',
context?: BridgeClientRequestContext,
) => Promise<{ cancelled: boolean }>;
controlSessionWorkflowTaskImpl?: (
sessionId: string,
taskId: string,
action:
| 'pause'
| 'resume'
| 'retry'
| 'rerun'
| 'delete-history'
| 'run-saved',
context?: BridgeClientRequestContext,
) => Promise<{ changed: boolean; status?: string; taskId?: string }>;
clearSessionGoalImpl?: (
sessionId: string,
) => Promise<{ cleared: boolean; condition?: string }>;
@@ -1229,6 +1245,7 @@ interface FakeBridge extends AcpSessionBridge {
sessionSupportedCommandsCalls: string[];
sessionStatsCalls: string[];
sessionTasksCalls: string[];
sessionTasksOptions: Array<{ includeWorkflows?: boolean } | undefined>;
sessionLspCalls: string[];
sessionTranscriptCalls: Array<
Parameters<AcpSessionBridge['getSessionTranscriptPage']>[0]
@@ -1236,7 +1253,20 @@ interface FakeBridge extends AcpSessionBridge {
cancelSessionTaskCalls: Array<{
sessionId: string;
taskId: string;
taskKind: 'agent' | 'shell' | 'monitor';
taskKind: 'agent' | 'shell' | 'monitor' | 'workflow';
context?: BridgeClientRequestContext;
}>;
controlSessionWorkflowTaskCalls: Array<{
sessionId: string;
taskId: string;
action:
| 'pause'
| 'resume'
| 'retry'
| 'rerun'
| 'delete-history'
| 'run-saved';
context?: BridgeClientRequestContext;
}>;
clearSessionGoalCalls: string[];
controlSessionGoalCalls: Array<{
@@ -1424,9 +1454,13 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
const sessionSupportedCommandsCalls: string[] = [];
const sessionStatsCalls: string[] = [];
const sessionTasksCalls: string[] = [];
const sessionTasksOptions: Array<{ includeWorkflows?: boolean } | undefined> =
[];
const sessionLspCalls: string[] = [];
const sessionTranscriptCalls: FakeBridge['sessionTranscriptCalls'] = [];
const cancelSessionTaskCalls: FakeBridge['cancelSessionTaskCalls'] = [];
const controlSessionWorkflowTaskCalls: FakeBridge['controlSessionWorkflowTaskCalls'] =
[];
const clearSessionGoalCalls: string[] = [];
const controlSessionGoalCalls: FakeBridge['controlSessionGoalCalls'] = [];
const continueSessionCalls: string[] = [];
@@ -1770,6 +1804,9 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
}));
const cancelSessionTaskImpl =
opts.cancelSessionTaskImpl ?? (async () => ({ cancelled: true }));
const controlSessionWorkflowTaskImpl =
opts.controlSessionWorkflowTaskImpl ??
(async () => ({ changed: true, status: 'pausing' }));
const clearSessionGoalImpl =
opts.clearSessionGoalImpl ?? (async () => ({ cleared: true }));
const controlSessionGoalImpl =
@@ -2038,9 +2075,11 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
sessionSupportedCommandsCalls,
sessionStatsCalls,
sessionTasksCalls,
sessionTasksOptions,
sessionLspCalls,
sessionTranscriptCalls,
cancelSessionTaskCalls,
controlSessionWorkflowTaskCalls,
clearSessionGoalCalls,
controlSessionGoalCalls,
continueSessionCalls,
@@ -2327,9 +2366,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
sessionStatsCalls.push(sessionId);
return sessionStatsImpl(sessionId);
},
async getSessionTasksStatus(sessionId) {
async getSessionTasksStatus(sessionId, opts) {
sessionTasksCalls.push(sessionId);
return sessionTasksImpl(sessionId);
sessionTasksOptions.push(opts);
return sessionTasksImpl(sessionId, opts);
},
async getSessionLspStatus(sessionId) {
sessionLspCalls.push(sessionId);
@@ -2339,9 +2379,23 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
sessionTranscriptCalls.push(req);
return sessionTranscriptImpl(req);
},
async cancelSessionTask(sessionId, taskId, taskKind) {
cancelSessionTaskCalls.push({ sessionId, taskId, taskKind });
return cancelSessionTaskImpl(sessionId, taskId, taskKind);
async cancelSessionTask(sessionId, taskId, taskKind, context) {
cancelSessionTaskCalls.push({
sessionId,
taskId,
taskKind,
...(context ? { context } : {}),
});
return cancelSessionTaskImpl(sessionId, taskId, taskKind, context);
},
async controlSessionWorkflowTask(sessionId, taskId, action, context) {
controlSessionWorkflowTaskCalls.push({
sessionId,
taskId,
action,
...(context ? { context } : {}),
});
return controlSessionWorkflowTaskImpl(sessionId, taskId, action, context);
},
async clearSessionGoal(sessionId) {
clearSessionGoalCalls.push(sessionId);
@@ -4038,6 +4092,90 @@ describe('createServeApp', () => {
});
describe('GET /capabilities', () => {
it('advertises workflow availability per workspace before a session exists', async () => {
const primaryBridge = fakeBridge();
const primary = makeWorkspaceRuntimeForTest({
workspaceId: 'primary-id',
workspaceCwd: WS_BOUND,
primary: true,
bridge: primaryBridge,
});
const secondary: WorkspaceRuntime = {
...makeWorkspaceRuntimeForTest({
workspaceId: 'secondary-id',
workspaceCwd: '/workspace/secondary',
primary: false,
bridge: fakeBridge(),
}),
env: {
mode: 'runtime-overlay',
overlayKeys: [
'QWEN_CODE_ENABLE_WORKFLOWS',
'QWEN_CODE_DISABLE_WORKFLOWS',
],
effectiveEnv: {
QWEN_CODE_ENABLE_WORKFLOWS: '1',
QWEN_CODE_DISABLE_WORKFLOWS: '1',
},
},
};
const settingsEnabled = {
...makeWorkspaceRuntimeForTest({
workspaceId: 'settings-enabled-id',
workspaceCwd: '/workspace/settings-enabled',
primary: false,
bridge: fakeBridge(),
}),
env: {
mode: 'runtime-overlay' as const,
overlayKeys: [],
effectiveEnv: {},
workflowsEnabledBySettings: true,
},
};
const untrusted = makeWorkspaceRuntimeForTest({
workspaceId: 'untrusted-id',
workspaceCwd: '/workspace/untrusted',
primary: false,
bridge: fakeBridge(),
trusted: false,
});
const app = createServeApp(baseOpts, undefined, {
bridge: primaryBridge,
workspaceRegistry: createWorkspaceRegistry([
primary,
secondary,
settingsEnabled,
untrusted,
]),
daemonEnv: { QWEN_CODE_ENABLE_WORKFLOWS: '1' },
});
const response = await request(app)
.get('/capabilities')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(response.status).toBe(200);
expect(response.body.workspaces).toEqual([
expect.objectContaining({
id: 'primary-id',
workflowsEnabled: true,
}),
expect.objectContaining({
id: 'secondary-id',
workflowsEnabled: false,
}),
expect.objectContaining({
id: 'settings-enabled-id',
workflowsEnabled: true,
}),
expect.objectContaining({
id: 'untrusted-id',
workflowsEnabled: false,
}),
]);
});
it('advertises the scoped workspace-memory tags on the wire', async () => {
// The three tags this PR adds are pinned against a real `/capabilities`
// body only in integration-tests/cli/qwen-serve-routes.test.ts, which no
@@ -4282,6 +4420,7 @@ describe('createServeApp', () => {
workspaceRegistry: registry,
createWorkspaceRuntime: vi.fn(),
workspaceRegistrationStore: {} as unknown as WorkspaceRegistrationStore,
daemonEnv: {},
});
const before = await request(app)
@@ -4302,6 +4441,7 @@ describe('createServeApp', () => {
cwd: WS_BOUND,
primary: true,
trusted: true,
workflowsEnabled: false,
},
]);
@@ -4345,6 +4485,7 @@ describe('createServeApp', () => {
const app = createServeApp(baseOpts, undefined, {
bridge: primaryBridge,
workspaceRegistry: registry,
daemonEnv: {},
});
const response = await request(app)
@@ -4362,6 +4503,7 @@ describe('createServeApp', () => {
displayName: 'Conversations',
primary: false,
trusted: true,
workflowsEnabled: false,
kind: 'live',
});
expect(response.body.features).not.toContain('multi_workspace_sessions');
@@ -9399,7 +9541,7 @@ describe('createServeApp', () => {
const app = createServeApp(
{ ...baseOpts, workspace: WS_BOUND },
undefined,
{ bridge },
{ bridge, primaryWorkspaceTrusted: true },
);
const contextRes = await request(app)
@@ -9414,6 +9556,9 @@ describe('createServeApp', () => {
const tasksRes = await request(app)
.get('/session/s-1/tasks')
.set('Host', `127.0.0.1:${baseOpts.port}`);
const workflowTasksRes = await request(app)
.get('/session/s-1/tasks?includeWorkflows=true')
.set('Host', `127.0.0.1:${baseOpts.port}`);
const lspRes = await request(app)
.get('/session/s-1/lsp')
.set('Host', `127.0.0.1:${baseOpts.port}`);
@@ -9427,12 +9572,18 @@ describe('createServeApp', () => {
expect(statsRes.body).toEqual(stats);
expect(tasksRes.status).toBe(200);
expect(tasksRes.body).toEqual(tasks);
expect(workflowTasksRes.status).toBe(200);
expect(workflowTasksRes.body).toEqual(tasks);
expect(lspRes.status).toBe(200);
expect(lspRes.body).toEqual(lsp);
expect(bridge.sessionContextCalls).toEqual(['s-1']);
expect(bridge.sessionSupportedCommandsCalls).toEqual(['s-1']);
expect(bridge.sessionStatsCalls).toEqual(['s-1']);
expect(bridge.sessionTasksCalls).toEqual(['s-1']);
expect(bridge.sessionTasksCalls).toEqual(['s-1', 's-1']);
expect(bridge.sessionTasksOptions).toEqual([
{ includeWorkflows: false },
{ includeWorkflows: true },
]);
expect(bridge.sessionLspCalls).toEqual(['s-1']);
});
@@ -9682,12 +9833,12 @@ describe('createServeApp', () => {
expect(res.status).toBe(400);
expect(res.body.error).toBe(
'`kind` must be "agent", "shell", or "monitor"',
'`kind` must be "agent", "shell", "monitor", or "workflow"',
);
expect(bridge.cancelSessionTaskCalls).toEqual([]);
});
it('cancels a session task through the bridge', async () => {
it('cancels a workflow task through the bridge', async () => {
const bridge = fakeBridge({
cancelSessionTaskImpl: async () => ({ cancelled: true }),
});
@@ -9695,22 +9846,188 @@ describe('createServeApp', () => {
const app = createServeApp(
{ ...tokenOpts, workspace: WS_BOUND },
undefined,
{ bridge },
{ bridge, primaryWorkspaceTrusted: true },
);
const res = await request(app)
.post('/session/s-1/tasks/task-1/cancel')
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret')
.send({ kind: 'agent' });
.set('X-Qwen-Client-Id', 'client-1')
.send({ kind: 'workflow' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ cancelled: true });
expect(bridge.cancelSessionTaskCalls).toEqual([
{ sessionId: 's-1', taskId: 'task-1', taskKind: 'agent' },
{
sessionId: 's-1',
taskId: 'task-1',
taskKind: 'workflow',
context: { clientId: 'client-1' },
},
]);
});
it('controls live runs, saved definitions, and history through one route', async () => {
const bridge = fakeBridge({
controlSessionWorkflowTaskImpl: async (_sessionId, _taskId, action) =>
action === 'pause'
? { changed: true, status: 'pausing' }
: { changed: true, status: 'running' },
});
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
const app = createServeApp(
{ ...tokenOpts, workspace: WS_BOUND },
undefined,
{ bridge, primaryWorkspaceTrusted: true },
);
const pauseRes = await request(app)
.post('/session/s-1/tasks/task-1/workflow-action')
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret')
.set('X-Qwen-Client-Id', 'client-1')
.send({ action: 'pause' });
const resumeRes = await request(app)
.post('/session/s-1/tasks/task-1/workflow-action')
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret')
.send({ action: 'resume' });
const retryRes = await request(app)
.post('/session/s-1/tasks/task-1/workflow-action')
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret')
.send({ action: 'retry' });
const rerunRes = await request(app)
.post('/session/s-1/tasks/task-1/workflow-action')
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret')
.send({ action: 'rerun' });
const deleteRes = await request(app)
.post('/session/s-1/tasks/task-1/workflow-action')
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret')
.send({ action: 'delete-history' });
const runSavedRes = await request(app)
.post('/session/s-1/tasks/deep-review/workflow-action')
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret')
.send({ action: 'run-saved' });
expect(pauseRes.status).toBe(200);
expect(pauseRes.body).toEqual({ changed: true, status: 'pausing' });
expect(resumeRes.status).toBe(200);
expect(resumeRes.body).toEqual({ changed: true, status: 'running' });
expect(retryRes.status).toBe(200);
expect(retryRes.body).toEqual({ changed: true, status: 'running' });
expect(rerunRes.status).toBe(200);
expect(rerunRes.body).toEqual({ changed: true, status: 'running' });
expect(deleteRes.status).toBe(200);
expect(deleteRes.body).toEqual({ changed: true, status: 'running' });
expect(runSavedRes.status).toBe(200);
expect(runSavedRes.body).toEqual({ changed: true, status: 'running' });
expect(bridge.controlSessionWorkflowTaskCalls).toEqual([
{
sessionId: 's-1',
taskId: 'task-1',
action: 'pause',
context: { clientId: 'client-1' },
},
{ sessionId: 's-1', taskId: 'task-1', action: 'resume' },
{ sessionId: 's-1', taskId: 'task-1', action: 'retry' },
{ sessionId: 's-1', taskId: 'task-1', action: 'rerun' },
{ sessionId: 's-1', taskId: 'task-1', action: 'delete-history' },
{ sessionId: 's-1', taskId: 'deep-review', action: 'run-saved' },
]);
});
it('fails the Workflow surfaces closed for an untrusted primary workspace', async () => {
const bridge = fakeBridge({
sessionSupportedCommandsImpl: async (sessionId) => ({
v: 1 as const,
sessionId,
availableCommands: [
{
name: 'init',
description: 'Initialize',
input: null,
_meta: { source: 'builtin' },
},
{
name: 'workflows',
description: 'Manage workflows',
input: null,
_meta: { source: 'builtin' },
},
],
availableSkills: [],
workflowsEnabled: true,
savedWorkflows: [{ name: 'slow-phases', source: 'project' as const }],
}),
cancelSessionTaskImpl: async () => ({ cancelled: true }),
controlSessionWorkflowTaskImpl: async () => ({
changed: true,
status: 'running',
taskId: 'wf-1',
}),
});
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
const app = createServeApp(
{ ...tokenOpts, workspace: WS_BOUND },
undefined,
{
bridge,
workspaceRegistry: createWorkspaceRegistry([
makeWorkspaceRuntimeForTest({
workspaceId: 'primary-id',
workspaceCwd: WS_BOUND,
primary: true,
trusted: false,
bridge,
}),
]),
},
);
const commandsRes = await request(app)
.get('/session/s-1/supported-commands')
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret');
const runSavedRes = await request(app)
.post('/session/s-1/tasks/slow-phases/workflow-action')
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret')
.send({ action: 'run-saved' });
const cancelRes = await request(app)
.post('/session/s-1/tasks/wf-1/cancel')
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret')
.send({ kind: 'workflow' });
const tasksRes = await request(app)
.get('/session/s-1/tasks?includeWorkflows=true')
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret');
expect(commandsRes.status).toBe(200);
expect(commandsRes.body).toMatchObject({
workflowsEnabled: false,
savedWorkflows: [],
});
expect(commandsRes.body.availableCommands).toEqual([
expect.objectContaining({ name: 'init' }),
]);
expect(runSavedRes.status).toBe(200);
expect(runSavedRes.body).toEqual({ changed: false });
expect(cancelRes.status).toBe(200);
expect(cancelRes.body).toEqual({ cancelled: false, reason: 'disabled' });
expect(tasksRes.status).toBe(200);
// The untrusted-workspace gate fail-closes the read path too: the
// includeWorkflows opt-in must not reach the child.
expect(bridge.sessionTasksOptions).toEqual([{ includeWorkflows: false }]);
expect(bridge.controlSessionWorkflowTaskCalls).toEqual([]);
expect(bridge.cancelSessionTaskCalls).toEqual([]);
});
it.each([
['agent%3A8', 'agent:8'],
['agent%2F8', 'agent/8'],
@@ -13736,7 +14053,10 @@ describe('createServeApp', () => {
sessionId: 'persisted-replay',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [{ name: 'help', description: 'Help' }],
availableCommands: [
{ name: 'help', description: 'Help' },
{ name: 'workflows', description: 'Manage workflows' },
],
_meta: {
availableSkills: ['bugfix'],
availableSkillDetails: [
@@ -13768,7 +14088,10 @@ describe('createServeApp', () => {
sessionId: 'persisted-replay',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [{ name: 'help', description: 'Help' }],
availableCommands: [
{ name: 'help', description: 'Help' },
{ name: 'workflows', description: 'Manage workflows' },
],
_meta: {
availableSkills: ['bugfix'],
availableSkillDetails: [
@@ -13789,11 +14112,26 @@ describe('createServeApp', () => {
liveJournal: [journalCommandsEvent, textEvent],
}),
});
const app = createServeApp(baseOpts, undefined, { bridge });
const app = createServeApp(
{ ...baseOpts, workspace: WS_BOUND },
undefined,
{
bridge,
workspaceRegistry: createWorkspaceRegistry([
makeWorkspaceRuntimeForTest({
workspaceId: 'primary-id',
workspaceCwd: WS_BOUND,
primary: true,
trusted: true,
bridge,
}),
]),
},
);
const res = await request(app)
.post('/session/persisted-replay/load')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({});
.send({ cwd: WS_BOUND });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ sessionId: 'persisted-replay' });
@@ -13885,6 +14223,61 @@ describe('createServeApp', () => {
expect(JSON.stringify(res.body)).not.toContain('LEAK-CANARY-SKILL-BODY');
});
it('redacts workflows from untrusted load response replay arrays', async () => {
const commandsEvent = {
id: 1,
v: 1,
type: 'session_update',
data: {
sessionId: 'untrusted-replay',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{ name: 'help', description: 'Help' },
{ name: 'workflows', description: 'Manage workflows' },
],
},
},
} satisfies BridgeEvent;
const bridge = fakeBridge({
loadImpl: async (req) => ({
sessionId: req.sessionId,
workspaceCwd: req.workspaceCwd,
attached: false,
clientId: 'client-load',
state: {},
compactedReplay: [commandsEvent],
}),
});
const app = createServeApp(
{ ...baseOpts, workspace: WS_BOUND },
undefined,
{
bridge,
workspaceRegistry: createWorkspaceRegistry([
makeWorkspaceRuntimeForTest({
workspaceId: 'primary-id',
workspaceCwd: WS_BOUND,
primary: true,
trusted: false,
bridge,
}),
]),
},
);
const res = await request(app)
.post('/session/untrusted-replay/load')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ cwd: WS_BOUND });
expect(res.status).toBe(200);
expect(res.body.compactedReplay[0].data.update.availableCommands).toEqual(
[{ name: 'help', description: 'Help' }],
);
expect(commandsEvent.data.update.availableCommands).toHaveLength(2);
});
it('passes client identity headers through to load/resume bridge calls', async () => {
for (const action of ['load', 'resume'] as const) {
const bridge = fakeBridge();
@@ -25235,7 +25628,10 @@ describe('createServeApp', () => {
type: 'session_update',
data: {
sessionUpdate: 'available_commands_update',
availableCommands: [{ name: 'help', description: 'Help' }],
availableCommands: [
{ name: 'help', description: 'Help' },
{ name: 'workflows', description: 'Manage workflows' },
],
_meta: {
availableSkills: ['bugfix'],
availableSkillDetails: [
@@ -25252,6 +25648,15 @@ describe('createServeApp', () => {
const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, {
bridge,
boundWorkspace: wsDir,
workspaceRegistry: createWorkspaceRegistry([
makeWorkspaceRuntimeForTest({
workspaceId: 'primary-id',
workspaceCwd: wsDir,
primary: true,
trusted: true,
bridge,
}),
]),
});
const res = await request(app)
@@ -25261,12 +25666,63 @@ describe('createServeApp', () => {
expect(res.status).toBe(200);
const event = res.body.events[0] as { data: Record<string, unknown> };
expect(event.data['sessionUpdate']).toBe('available_commands_update');
expect(event.data['availableCommands']).toEqual([
{ name: 'help', description: 'Help' },
{ name: 'workflows', description: 'Manage workflows' },
]);
const meta = event.data['_meta'] as Record<string, unknown>;
expect(meta['availableSkills']).toEqual(['bugfix']);
expect(meta).not.toHaveProperty('availableSkillDetails');
expect(JSON.stringify(res.body)).not.toContain('x'.repeat(64));
});
it('redacts workflows from untrusted transcript events', async () => {
const sid = '55555555-bbbb-cccc-dddd-aaaaaaaaaaad';
const commandsEvent = {
v: 1,
type: 'session_update',
data: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{ name: 'help', description: 'Help' },
{ name: 'workflows', description: 'Manage workflows' },
],
},
} satisfies Omit<BridgeEvent, 'id'>;
const bridge = fakeBridge({
sessionTranscriptImpl: async (req) => ({
v: 1,
sessionId: req.sessionId,
events: [commandsEvent],
hasMore: false,
}),
});
await writeTranscriptSession(sid);
const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, {
bridge,
boundWorkspace: wsDir,
workspaceRegistry: createWorkspaceRegistry([
makeWorkspaceRuntimeForTest({
workspaceId: 'primary-id',
workspaceCwd: wsDir,
primary: true,
trusted: false,
bridge,
}),
]),
});
const res = await request(app)
.get(`/session/${sid}/transcript`)
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(200);
expect(res.body.events[0].data.availableCommands).toEqual([
{ name: 'help', description: 'Help' },
]);
expect(commandsEvent.data.availableCommands).toHaveLength(2);
});
it('forwards an exclusive persisted-record boundary', async () => {
const sid = '55555555-bbbb-cccc-dddd-bbbbbbbbbbbb';
const bridge = fakeBridge({
@@ -31034,7 +31490,10 @@ describe('GET /session/:id/events (SSE)', () => {
// entries and the skill name list.
const sharedUpdate = {
sessionUpdate: 'available_commands_update',
availableCommands: [{ name: 'help', description: 'Help' }],
availableCommands: [
{ name: 'help', description: 'Help' },
{ name: 'workflows', description: 'Manage workflows' },
],
_meta: {
availableSkills: ['bugfix'],
availableSkillDetails: [
@@ -31050,6 +31509,13 @@ describe('GET /session/:id/events (SSE)', () => {
},
};
const bridge = fakeBridge({
summaryImpl: (sessionId) => ({
sessionId,
workspaceCwd: WS_BOUND,
createdAt: '2026-08-26T00:00:00.000Z',
clientCount: 1,
hasActivePrompt: false,
}),
async *subscribeImpl() {
yield {
id: 1,
@@ -31071,7 +31537,22 @@ describe('GET /session/:id/events (SSE)', () => {
};
},
});
const app = createServeApp(baseOpts, undefined, { bridge });
const app = createServeApp(
{ ...baseOpts, workspace: WS_BOUND },
undefined,
{
bridge,
workspaceRegistry: createWorkspaceRegistry([
makeWorkspaceRuntimeForTest({
workspaceId: 'primary-id',
workspaceCwd: WS_BOUND,
primary: true,
trusted: true,
bridge,
}),
]),
},
);
const res = await request(app)
.get('/session/sess-A/events')
@@ -31112,6 +31593,7 @@ describe('GET /session/:id/events (SSE)', () => {
expect(commandsUpdate['sessionUpdate']).toBe('available_commands_update');
expect(commandsUpdate['availableCommands']).toEqual([
{ name: 'help', description: 'Help' },
{ name: 'workflows', description: 'Manage workflows' },
]);
const meta = commandsUpdate['_meta'] as Record<string, unknown>;
expect(meta['availableSkills']).toEqual(['bugfix']);
@@ -31163,6 +31645,80 @@ describe('GET /session/:id/events (SSE)', () => {
expect(payload.data!.update).not.toHaveProperty('_meta');
});
it('redacts workflows from untrusted SSE frames', async () => {
const sharedUpdate = {
sessionUpdate: 'available_commands_update',
availableCommands: [
{ name: 'help', description: 'Help' },
{ name: 'workflows', description: 'Manage workflows' },
],
};
const bridge = fakeBridge({
summaryImpl: (sessionId) => ({
sessionId,
workspaceCwd: WS_BOUND,
createdAt: '2026-08-26T00:00:00.000Z',
clientCount: 1,
hasActivePrompt: false,
}),
async *subscribeImpl() {
yield {
id: 1,
v: 1,
type: 'session_update',
data: { sessionId: 'sess-A', update: sharedUpdate },
};
},
});
const app = createServeApp(
{ ...baseOpts, workspace: WS_BOUND },
undefined,
{
bridge,
workspaceRegistry: createWorkspaceRegistry([
makeWorkspaceRuntimeForTest({
workspaceId: 'primary-id',
workspaceCwd: WS_BOUND,
primary: true,
trusted: false,
bridge,
}),
]),
},
);
const res = await request(app)
.get('/session/sess-A/events')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(200);
const payload = res.text
.split('\n')
.filter((line) => line.startsWith('data: '))
.map(
(line) =>
JSON.parse(line.slice('data: '.length)) as {
type?: string;
data?: {
update?: {
sessionUpdate?: string;
availableCommands?: unknown[];
};
};
},
)
.find(
(candidate) =>
candidate.type === 'session_update' &&
candidate.data?.update?.sessionUpdate === 'available_commands_update',
);
expect(payload).toBeDefined();
expect(payload!.data!.update!.availableCommands).toEqual([
{ name: 'help', description: 'Help' },
]);
expect(sharedUpdate.availableCommands).toHaveLength(2);
});
it('correlates the SSE response, daemon lifecycle log, and request span', async () => {
const predecessor = '019535d9-3df7-7a61-8f6d-6f37c39c5f19';
const setAttribute = vi.fn();
+2
View File
@@ -2172,6 +2172,7 @@ export function createServeApp(
maxPendingPromptsPerSession: opts.maxPendingPromptsPerSession,
sessionRestoreTimeoutMs,
languageCodes,
daemonEnv: daemonEnvAtBoot,
});
if (liveVoiceSurfaceAvailable) {
@@ -2727,6 +2728,7 @@ export function createServeApp(
service: standaloneSessionService,
mutate,
sendBridgeError,
isWorkspaceTrusted: isPrimaryWorkspaceTrusted,
});
standaloneSessionsAvailable = true;
}
@@ -98,7 +98,7 @@ describe('legacy session telemetry route drift guard', () => {
.map(({ method, path }) => `${method} ${path}`)
.sort();
expect(registered).toHaveLength(61);
expect(registered).toHaveLength(62);
expect(registered).toEqual(catalog);
});
});
@@ -1062,17 +1062,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => {
});
describe('legacy session telemetry route catalog', () => {
it('contains 61 unique routes with the audited 59/2 attribution split', () => {
it('contains 62 unique routes with the audited 60/2 attribution split', () => {
const keys = legacySessionTelemetryRoutes.map(
({ method, path }) => `${method} ${path}`,
);
expect(keys).toHaveLength(61);
expect(new Set(keys).size).toBe(61);
expect(keys).toHaveLength(62);
expect(new Set(keys).size).toBe(62);
expect(
legacySessionTelemetryRoutes.filter(
({ attribution }) => attribution === 'handler_resolved',
),
).toHaveLength(59);
).toHaveLength(60);
expect(
legacySessionTelemetryRoutes.filter(
({ attribution }) => attribution === 'pre_resolved',
@@ -181,6 +181,12 @@ export const legacySessionTelemetryRoutes = [
attribution: 'handler_resolved',
route: 'POST /session/:id/tasks/:taskId/cancel',
},
{
method: 'POST',
path: '/session/:id/tasks/:taskId/workflow-action',
attribution: 'handler_resolved',
route: 'POST /session/:id/tasks/:taskId/workflow-action',
},
{
method: 'POST',
path: '/session/:id/goal',
+1
View File
@@ -442,6 +442,7 @@ export interface CapabilitiesEnvelope {
displayName?: string;
primary: boolean;
trusted: boolean;
workflowsEnabled?: boolean;
removable?: boolean;
kind?: 'live';
}>;
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
import {
redactWorkflowsFromAvailableCommandsEvent,
redactWorkflowsFromReplayArrays,
} from './workflow-session-gate.js';
interface CommandsData {
sessionUpdate: string;
availableCommands: Array<{ name: string; description: string }>;
}
interface WrappedEvent extends BridgeEvent {
data: { sessionId: string; update: CommandsData };
}
interface FlatEvent extends BridgeEvent {
data: CommandsData;
}
function wrappedCommandsEvent(): WrappedEvent {
return {
id: 1,
v: 1,
type: 'session_update',
data: {
sessionId: 'sess-1',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{ name: 'workflows', description: 'Run workflows' },
{ name: 'init', description: 'Initialize' },
],
},
},
};
}
function flatCommandsEvent(): FlatEvent {
return {
id: 2,
v: 1,
type: 'session_update',
data: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{ name: 'workflows', description: 'Run workflows' },
{ name: 'init', description: 'Initialize' },
],
},
};
}
describe('redactWorkflowsFromAvailableCommandsEvent', () => {
it('removes workflows from wrapped frames without mutating the source', () => {
const event = wrappedCommandsEvent();
const shaped = redactWorkflowsFromAvailableCommandsEvent(event);
expect(shaped.data.update.availableCommands).toEqual([
{ name: 'init', description: 'Initialize' },
]);
expect(event.data.update.availableCommands).toHaveLength(2);
});
it('removes workflows from flat persisted-transcript frames', () => {
const shaped =
redactWorkflowsFromAvailableCommandsEvent(flatCommandsEvent());
expect(shaped.data.availableCommands).toEqual([
{ name: 'init', description: 'Initialize' },
]);
});
it('passes through unrelated frames unchanged', () => {
const event: BridgeEvent = {
id: 3,
v: 1,
type: 'session_update',
data: {
sessionId: 'sess-1',
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'hi' },
},
},
};
expect(redactWorkflowsFromAvailableCommandsEvent(event)).toBe(event);
});
it('passes through command frames without workflows unchanged', () => {
const event = wrappedCommandsEvent();
event.data.update.availableCommands = [
{ name: 'init', description: 'Initialize' },
];
expect(redactWorkflowsFromAvailableCommandsEvent(event)).toBe(event);
});
});
describe('redactWorkflowsFromReplayArrays', () => {
it('redacts both replay arrays without mutating them', () => {
const compactedEvent = wrappedCommandsEvent();
const journalEvent = flatCommandsEvent();
const session = {
sessionId: 'sess-1',
compactedReplay: [compactedEvent],
liveJournal: [journalEvent],
};
const shaped = redactWorkflowsFromReplayArrays(session);
expect(
(shaped.compactedReplay[0] as WrappedEvent).data.update.availableCommands,
).toEqual([{ name: 'init', description: 'Initialize' }]);
expect((shaped.liveJournal[0] as FlatEvent).data.availableCommands).toEqual(
[{ name: 'init', description: 'Initialize' }],
);
expect(compactedEvent.data.update.availableCommands).toHaveLength(2);
expect(journalEvent.data.availableCommands).toHaveLength(2);
});
it('returns its input unchanged when no replay arrays are present', () => {
const session = { sessionId: 'sess-1', compactedReplay: undefined };
expect(redactWorkflowsFromReplayArrays(session)).toBe(session);
});
});
@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
import type { ServeSessionSupportedCommandsStatus } from '@qwen-code/acp-bridge/status';
// The daemon's workspace trust verdict never reaches the ACP child's
// workflow gate, so the daemon boundary redacts the surfaces itself with the
// same fail-closed shape the child produces when its own gate denies them.
export function redactWorkflowsFromSupportedCommands(
status: ServeSessionSupportedCommandsStatus,
): ServeSessionSupportedCommandsStatus {
return {
...status,
availableCommands: status.availableCommands.filter(
(command) => command.name !== 'workflows',
),
workflowsEnabled: false,
savedWorkflows: [],
};
}
export function redactWorkflowsFromAvailableCommandsEvent<
T extends { type: string; data: unknown },
>(event: T): T {
if (event.type !== 'session_update') return event;
const data = asRecord(event.data);
if (!data) return event;
const wrapped = asRecord(data['update']);
const flat = !wrapped && data['sessionUpdate'] !== undefined;
const candidate = flat ? data : wrapped;
if (
!candidate ||
candidate['sessionUpdate'] !== 'available_commands_update'
) {
return event;
}
const availableCommands = candidate['availableCommands'];
if (!Array.isArray(availableCommands)) return event;
const filteredCommands = availableCommands.filter(
(command) => asRecord(command)?.['name'] !== 'workflows',
);
if (filteredCommands.length === availableCommands.length) return event;
const nextCandidate = {
...candidate,
availableCommands: filteredCommands,
};
if (flat) return { ...event, data: nextCandidate };
return { ...event, data: { ...data, update: nextCandidate } };
}
export function redactWorkflowsFromReplayArrays<
T extends {
compactedReplay?: BridgeEvent[];
liveJournal?: BridgeEvent[];
},
>(session: T): T {
if (!session.compactedReplay && !session.liveJournal) return session;
return {
...session,
...(session.compactedReplay
? {
compactedReplay: session.compactedReplay.map(
redactWorkflowsFromAvailableCommandsEvent,
),
}
: {}),
...(session.liveJournal
? {
liveJournal: session.liveJournal.map(
redactWorkflowsFromAvailableCommandsEvent,
),
}
: {}),
};
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
@@ -18,6 +18,7 @@ export interface WorkspaceRuntimeEnvMetadata {
readonly mode: 'parent-process' | 'runtime-overlay';
readonly overlayKeys: readonly string[];
readonly effectiveEnv?: Readonly<NodeJS.ProcessEnv>;
readonly workflowsEnabledBySettings?: boolean;
readonly envFilePaths?: readonly string[];
readonly envFileReadFailed?: boolean;
readonly envFileReadFailures?: ReadonlyArray<{
@@ -92,6 +92,8 @@ describe('clearCommand', () => {
}),
getWorkflowRunRegistry: () => ({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -350,6 +352,8 @@ describe('clearCommand', () => {
}),
getWorkflowRunRegistry: () => ({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -428,6 +432,7 @@ describe('clearCommand', () => {
getWorkflowRunRegistry: vi.fn().mockReturnValue({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -501,6 +506,7 @@ describe('clearCommand', () => {
getWorkflowRunRegistry: vi.fn().mockReturnValue({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -570,6 +576,7 @@ describe('clearCommand', () => {
getWorkflowRunRegistry: vi.fn().mockReturnValue({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -640,6 +647,7 @@ describe('clearCommand', () => {
getWorkflowRunRegistry: vi.fn().mockReturnValue({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -50,6 +50,7 @@ describe('useBranchCommand', () => {
let workflowRunRegistry: {
hasRunningEntries: ReturnType<typeof vi.fn>;
list: ReturnType<typeof vi.fn>;
listStartingRunIds: ReturnType<typeof vi.fn>;
reset: ReturnType<typeof vi.fn>;
abortAll: ReturnType<typeof vi.fn>;
};
@@ -126,6 +127,7 @@ describe('useBranchCommand', () => {
workflowRunRegistry = {
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
};
@@ -52,6 +52,8 @@ function makeSwapSlotConfig(llmClient: SwapSlotClient) {
}),
getWorkflowRunRegistry: () => ({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -313,6 +315,8 @@ describe('useResumeCommand', () => {
}),
getWorkflowRunRegistry: () => ({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -415,6 +419,8 @@ describe('useResumeCommand', () => {
}),
getWorkflowRunRegistry: () => ({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -494,6 +500,8 @@ describe('useResumeCommand', () => {
}),
getWorkflowRunRegistry: () => ({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -578,6 +586,8 @@ describe('useResumeCommand', () => {
}),
getWorkflowRunRegistry: () => ({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -674,6 +684,8 @@ describe('useResumeCommand', () => {
}),
getWorkflowRunRegistry: () => ({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -749,6 +761,7 @@ describe('useResumeCommand', () => {
getWorkflowRunRegistry: () => ({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -824,6 +837,7 @@ describe('useResumeCommand', () => {
getWorkflowRunRegistry: () => ({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -893,6 +907,8 @@ describe('useResumeCommand', () => {
}),
getWorkflowRunRegistry: () => ({
hasRunningEntries: vi.fn().mockReturnValue(false),
list: vi.fn().mockReturnValue([]),
listStartingRunIds: vi.fn().mockReturnValue([]),
reset: vi.fn(),
abortAll: vi.fn(),
}),
@@ -0,0 +1,137 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { markdownToPlainText, stripAnsi } from './a11y-plain-text.js';
describe('stripAnsi', () => {
it('removes SGR color and attribute sequences', () => {
expect(stripAnsi('\x1b[31mred\x1b[0m')).toBe('red');
expect(stripAnsi('\x1b[1;33mbold yellow\x1b[0m')).toBe('bold yellow');
expect(stripAnsi('\x1b[38:5:208mext\x1b[0m')).toBe('ext');
});
it('removes cursor movement and erase sequences', () => {
expect(stripAnsi('\x1b[2K\x1b[1Atext\x1b[2J')).toBe('text');
expect(stripAnsi('\x1b[?25lhidden cursor\x1b[?25h')).toBe('hidden cursor');
});
it('removes OSC sequences with BEL or ST terminators', () => {
expect(stripAnsi('\x1b]8;;https://example.com\x07link\x1b]8;;\x07')).toBe(
'link',
);
expect(stripAnsi('\x1b]0;window title\x1b\\body')).toBe('body');
});
it('leaves plain text untouched', () => {
expect(stripAnsi('hello world')).toBe('hello world');
expect(stripAnsi('')).toBe('');
});
it('removes device escape sequences (R2-10)', () => {
// SGR mouse reports and DEC save/restore cursor leaked past the old
// hand-rolled pattern.
expect(stripAnsi('\x1b[<0;5;1Mclick')).toBe('click');
expect(stripAnsi('\x1b7x\x1b8')).toBe('x');
});
});
describe('markdownToPlainText', () => {
it('strips heading markers', () => {
expect(markdownToPlainText('# Title')).toBe('Title');
expect(markdownToPlainText('### Deep heading')).toBe('Deep heading');
});
it('strips emphasis and inline code markers', () => {
expect(markdownToPlainText('**bold** and *em* and _u_')).toBe(
'bold and em and u',
);
expect(markdownToPlainText('run `npm test` now')).toBe('run npm test now');
expect(markdownToPlainText('__strong__ ~~gone~~')).toBe('strong gone');
});
it('reduces links and images to their text', () => {
expect(markdownToPlainText('see [docs](https://x.dev) now')).toBe(
'see docs now',
);
expect(markdownToPlainText('logo ![alt text](img.png) end')).toBe(
'logo alt text end',
);
});
it('keeps fenced code bodies, dropping the fences', () => {
const md = ['```ts', 'const a = 1;', '```'].join('\n');
expect(markdownToPlainText(md)).toBe('const a = 1;');
});
it('drops blockquote prefixes and horizontal rules', () => {
expect(markdownToPlainText('> quoted line')).toBe('quoted line');
expect(markdownToPlainText('a\n---\nb')).toBe('a\n\nb');
});
it('keeps bullet markers and plain lines', () => {
expect(markdownToPlainText('- first\n- second')).toBe('- first\n- second');
expect(markdownToPlainText('just text')).toBe('just text');
});
it('leaves dunders and snake_case identifiers untouched', () => {
expect(markdownToPlainText('def __init__(self):')).toBe(
'def __init__(self):',
);
expect(markdownToPlainText('use snake_case_name here')).toBe(
'use snake_case_name here',
);
// Boundary-guarded underscore emphasis still works.
expect(markdownToPlainText('really _important_ now')).toBe(
'really important now',
);
});
it('keeps fence-like lines inside a block opened by the other character (R1-47/48)', () => {
// CommonMark: a fence only closes on the same character.
expect(markdownToPlainText('~~~\n```\nbody\n~~~\nafter')).toBe(
'```\nbody\nafter',
);
expect(markdownToPlainText('```\n~~~\nbody\n```\nafter')).toBe(
'~~~\nbody\nafter',
);
});
it('recognizes headings inside blockquotes (R1-1)', () => {
expect(markdownToPlainText('> # Title')).toBe('Title');
});
it('keeps code-span contents literal — no link/emphasis consumption (R1-1)', () => {
expect(markdownToPlainText('`[a](b)`')).toBe('[a](b)');
expect(markdownToPlainText('`**not bold**`')).toBe('**not bold**');
});
it('tracks fence length — a shorter run does not close a longer fence (R2-10)', () => {
expect(
markdownToPlainText('````\ncode\n```\nstill code\n````\nafter'),
).toBe('code\n```\nstill code\nafter');
});
it('keeps fence-like quoted lines inside a fence literal (R2-10)', () => {
// A de-quoted ``` inside a fenced body must not flip fence state.
expect(markdownToPlainText('```\n> ```\n```\nafter **bold**')).toBe(
'> ```\nafter bold',
);
});
it('keeps inner backticks in multi-backtick code spans (R2-10)', () => {
expect(markdownToPlainText('a ``b ` c`` d')).toBe('a b ` c d');
});
it('a fence line with info text does not close an open fence (R3-3)', () => {
// CommonMark: a closing fence cannot carry info text, so ```js inside
// an open block is literal body — not an early close that drops the
// block and inverts parse state for the rest of the document.
expect(markdownToPlainText('```\n```js\nbody\n```\nafter **x**')).toBe(
'```js\nbody\nafter x',
);
});
});
@@ -0,0 +1,141 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Plain-text conversions for screen-reader parity. Ink's screen-reader path
* renders squashed text only (no styles, borders or backgrounds), so the
* OpenTUI equivalent needs ANSI-stripped, markdown-reduced text for anything
* it would otherwise draw with colors or structure.
*
* The reduction stays line-based on purpose: ink's InlineMarkdownRenderer
* guards underscore emphasis at word boundaries so identifiers like
* `__init__` survive, and a CommonMark parser (markdown-it) emphasizes them.
* Fences, code spans and quote prefixes below follow the CommonMark rules
* ink's renderer applies.
*/
import stripAnsiLib from 'strip-ansi';
// strip-ansi 7.x does not strip CSI sequences with intermediate bytes
// (0x20-0x2F) or private parameter markers (e.g. SGR mouse \x1b[<0;5;1M);
// remove the full CSI production first: parameter bytes 0x30-0x3F,
// intermediate bytes 0x20-0x2F, final byte 0x40-0x7E — one regex
// covers both private and non-private CSI.
/* eslint-disable no-control-regex */
const CSI_SEQUENCE = /\x1b\[[0-9;:<=>?]*[\x20-\x2F]*[@-~]/g;
/* eslint-enable no-control-regex */
// strip-ansi also leaves DCS/SOS/PM/APC sequences (only the 2-byte
// introducer of a DCS is consumed) and unterminated OSC bodies in place;
// consume them through ST/BEL/end-of-input so SIXEL payloads or tmux
// passthroughs never reach the screen reader as announced garbage.
/* eslint-disable no-control-regex */
const OTHER_ESCAPE_SEQUENCE =
/\x1b[PX^_][\s\S]*?(?:\x1b\\|\x07|$)|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g;
/* eslint-enable no-control-regex */
/** Strips all ANSI escape sequences, leaving the readable text. */
export function stripAnsi(text: string): string {
return stripAnsiLib(
text.replace(CSI_SEQUENCE, '').replace(OTHER_ESCAPE_SEQUENCE, ''),
);
}
/**
* Reduces markdown to the plain text a screen reader should announce:
* headings lose their hashes, fenced code keeps its body, emphasis markers
* disappear, links and images reduce to their text/alt, blockquote prefixes
* and horizontal rules are dropped. Bullet markers stay — they are readable
* content in the ink parity path too.
*/
export function markdownToPlainText(markdown: string): string {
const result: string[] = [];
// The character AND length of the fence that opened the current code
// block, or null outside one. CommonMark: a fence only closes on the
// same character with at least the opening length.
let fenceChar: '`' | '~' | null = null;
let fenceLength = 0;
// CommonMark line endings: \r\n, \n, and lone \r all terminate a line;
// splitting on \n alone leaves \r on the line, which `.` excludes and
// `$` cannot see past, deadening fence detection for CRLF markdown.
for (const rawLine of markdown.split(/\r\n|\n|\r/)) {
// CommonMark fence: 3+ backticks/tildes, optionally indented up to 3
// spaces. An OPENING fence may carry info text (```js); a CLOSING
// fence cannot — a fence-like line with trailing content inside a
// block is literal body, not a close.
const fenceMatch = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(rawLine);
if (fenceMatch) {
const run = fenceMatch[1]!;
const trailing = fenceMatch[2] ?? '';
const char = run.charAt(0) as '`' | '~';
if (fenceChar === null) {
fenceChar = char;
fenceLength = run.length;
} else if (
fenceChar === char &&
run.length >= fenceLength &&
trailing.trim() === ''
) {
fenceChar = null;
fenceLength = 0;
} else {
result.push(rawLine);
}
continue;
}
if (fenceChar !== null) {
result.push(rawLine);
continue;
}
// Block-level passes run on the de-quoted view so headings inside
// blockquotes are recognized; the prefix is not content. (Only outside
// fences — a `>` line inside a fenced body is literal text.)
let text = rawLine.replace(/^(?:\s*>\s?)+/, '');
// Headings: "# Title" -> "Title".
text = text.replace(/^ {0,3}#{1,6}\s+/, '');
// Horizontal rules vanish in screen-reader output.
if (/^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/.test(text)) {
result.push('');
continue;
}
// Extract code spans before the other inline passes: their contents are
// literal text and must not be consumed as links/emphasis markup.
// Mirrors ink's INLINE_CODE_SPAN_PATTERN_SOURCE: non-empty content, and
// the closing run is neither preceded nor followed by another backtick,
// so `` and stray runs stay literal instead of being consumed reordered.
const codeSpans: string[] = [];
text = text.replace(
/(?<!`)(`+)(?!`)([\s\S]+?)(?<!`)\1(?!`)/g,
(_, _ticks, span) => {
codeSpans.push(span);
return `\u0000${codeSpans.length - 1}\u0000`;
},
);
// Images -> alt text, links -> link text.
text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1');
text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
// Bold before italic so "**x**" is not eaten twice.
text = text.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '$1');
// Underscore emphasis only applies at word boundaries (CommonMark), so
// "__init__" and snake_case identifiers survive untouched.
text = text.replace(/(^|\s)__(?=\S)([\s\S]*?\S)__(?=\s|$)/g, '$1$2');
text = text.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '$1');
text = text.replace(/\*(?=\S)([\s\S]*?\S)\*/g, '$1');
text = text.replace(/(^|\s)_(?=\S)([\s\S]*?\S)_(?=\s|$)/g, '$1$2');
// Restore the code-span contents last.
text = text.replace(
// eslint-disable-next-line no-control-regex -- NUL marks extracted code spans
/\u0000(\d+)\u0000/g,
(_, index: string) => codeSpans[Number(index)] ?? '',
);
result.push(text);
}
return result.join('\n');
}
@@ -0,0 +1,286 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies the OpenTUI screen-reader policy reproduces the original ink
* behavior flag by flag: plain text, no virtual viewport, no mouse,
* append-only output, and no redraw/sync wrappers.
*/
import { describe, it, expect } from 'vitest';
import {
applyScreenReaderPolicy,
eraseLines,
hardWrap,
isScreenReaderEnabled,
resolveScreenReaderPolicy,
screenReaderRendererOptions,
ScreenReaderOutputWriter,
} from './a11y-screen-reader.js';
describe('isScreenReaderEnabled', () => {
it('defaults to false when unset (config resolution parity)', () => {
expect(isScreenReaderEnabled(undefined)).toBe(false);
expect(isScreenReaderEnabled(false)).toBe(false);
expect(isScreenReaderEnabled(true)).toBe(true);
});
});
describe('resolveScreenReaderPolicy', () => {
it('enables plain text + append-only and disables mouse/VP/sync in SR mode', () => {
const policy = resolveScreenReaderPolicy({
screenReader: true,
useTerminalBuffer: true,
isTTY: true,
env: {},
});
expect(policy).toEqual({
enabled: true,
plainText: true,
appendOnly: true,
virtualViewport: false,
mouse: false,
synchronizedOutput: false,
redrawOptimizer: false,
});
});
it('keeps the normal interactive mode when SR is off', () => {
const policy = resolveScreenReaderPolicy({
screenReader: false,
useTerminalBuffer: true,
isTTY: true,
env: {},
});
expect(policy).toEqual({
enabled: false,
plainText: false,
appendOnly: false,
virtualViewport: true,
mouse: true,
synchronizedOutput: true,
redrawOptimizer: true,
});
});
it('disables VP on non-TTY stdout even with SR off', () => {
const policy = resolveScreenReaderPolicy({
screenReader: undefined,
isTTY: false,
env: {},
});
expect(policy.virtualViewport).toBe(false);
expect(policy.synchronizedOutput).toBe(false);
expect(policy.redrawOptimizer).toBe(false);
expect(policy.mouse).toBe(true);
});
it('honors useTerminalBuffer=false like the ink setting', () => {
const policy = resolveScreenReaderPolicy({
screenReader: false,
useTerminalBuffer: false,
isTTY: true,
env: {},
});
expect(policy.virtualViewport).toBe(false);
});
it('treats CI environments as non-interactive (isInteractiveTerminal parity)', () => {
const policy = resolveScreenReaderPolicy({
screenReader: false,
useTerminalBuffer: true,
isTTY: true,
env: { CI: 'true' },
});
expect(policy.virtualViewport).toBe(false);
});
});
describe('screenReaderRendererOptions', () => {
it('keeps the main screen and disables mouse in SR mode', () => {
const policy = resolveScreenReaderPolicy({
screenReader: true,
isTTY: true,
env: {},
});
expect(screenReaderRendererOptions(policy)).toEqual({
useMouse: false,
screenMode: 'main-screen',
});
});
it('preserves the opentui alternate-screen default otherwise', () => {
const policy = resolveScreenReaderPolicy({
screenReader: false,
isTTY: true,
env: {},
});
expect(screenReaderRendererOptions(policy)).toEqual({
useMouse: true,
screenMode: 'alternate-screen',
});
});
});
describe('applyScreenReaderPolicy', () => {
it('toggles the renderer mouse switch live', () => {
const renderer = { useMouse: true };
applyScreenReaderPolicy(
renderer,
resolveScreenReaderPolicy({ screenReader: true, isTTY: true, env: {} }),
);
expect(renderer.useMouse).toBe(false);
applyScreenReaderPolicy(
renderer,
resolveScreenReaderPolicy({ screenReader: false, isTTY: true, env: {} }),
);
expect(renderer.useMouse).toBe(true);
});
});
describe('eraseLines', () => {
it('emits nothing for zero lines', () => {
expect(eraseLines(0)).toBe('');
expect(eraseLines(-3)).toBe('');
});
it('erases line by line walking up and resets the cursor column (ink eraseLines parity)', () => {
expect(eraseLines(1)).toBe('\x1b[2K\x1b[G');
expect(eraseLines(3)).toBe('\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[G');
});
});
describe('hardWrap', () => {
it('splits long lines at the width boundary', () => {
expect(hardWrap('abcdef', 2)).toBe('ab\ncd\nef');
expect(hardWrap('abcde', 2)).toBe('ab\ncd\ne');
});
it('keeps short lines and handles multi-line input', () => {
expect(hardWrap('ab\ncdef', 2)).toBe('ab\ncd\nef');
});
it('disables wrapping for non-positive widths', () => {
expect(hardWrap('abcdef', 0)).toBe('abcdef');
});
it('measures display columns, not UTF-16 units (CJK glyphs are 2 columns)', () => {
// wrapAnsi(..., { hard: true }) parity: 3 CJK glyphs are 6 columns wide,
// so width 4 wraps after the second glyph — a UTF-16 counter would not
// wrap at all (3 code units).
expect(hardWrap('你好吗', 4)).toBe('你好\n吗');
});
it('keeps a glyph wider than the width on its own line', () => {
// wrap-ansi's exact output for a 2-column glyph at width 1 keeps a
// leading empty line — byte parity with ink's screen-reader path.
expect(hardWrap('你好', 1)).toBe('\n你\n好');
});
it('wraps at word boundaries like ink (R2-41)', () => {
expect(hardWrap('aa bb cc dd', 5)).toBe('aa bb\n cc \ndd');
});
});
describe('ScreenReaderOutputWriter (ink append-only parity)', () => {
it('writes static content exactly once with a trailing newline', () => {
const writes: string[] = [];
const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk));
writer.appendStatic('User: hello');
writer.appendStatic('Model: hi');
expect(writes).toEqual(['User: hello\n', 'Model: hi\n']);
});
it('does not rewrite an unchanged dynamic block', () => {
const writes: string[] = [];
const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk));
writer.updateDynamic('streaming…');
writer.updateDynamic('streaming…');
expect(writes).toEqual(['streaming…']);
});
it('erases the previous dynamic block before writing a new one', () => {
const writes: string[] = [];
const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk));
writer.updateDynamic('a\nb');
writer.updateDynamic('c');
expect(writes).toEqual(['a\nb', '\x1b[2K\x1b[1A\x1b[2K\x1b[Gc']);
});
it('erases the pending dynamic block when appending static output', () => {
const writes: string[] = [];
const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk));
writer.updateDynamic('spinner');
writer.appendStatic('User: next');
// The static append resets the height: the next dynamic write has no erase.
writer.updateDynamic('busy');
expect(writes).toEqual([
'spinner',
'\x1b[2K\x1b[G',
'User: next\n',
'busy',
]);
});
it('ignores empty static appends (ink hasStaticOutput guard)', () => {
const writes: string[] = [];
const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk));
writer.updateDynamic('spinner');
writer.appendStatic('');
expect(writes).toEqual(['spinner']);
});
it('hard-wraps dynamic output at the writer column width', () => {
const writes: string[] = [];
const writer = new ScreenReaderOutputWriter(
(chunk) => writes.push(chunk),
() => 4,
);
writer.updateDynamic('abcdefgh');
expect(writes).toEqual(['abcd\nefgh']);
});
it('clearDynamic erases the last block and resets state', () => {
const writes: string[] = [];
const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk));
writer.updateDynamic('x\ny');
writer.clearDynamic();
writer.updateDynamic('z');
expect(writes).toEqual(['x\ny', '\x1b[2K\x1b[1A\x1b[2K\x1b[G', 'z']);
});
it('strips ANSI and bare control bytes from written content', () => {
const writes: string[] = [];
const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk));
// A model/tool result smuggling an OSC 52 clipboard write must reach
// the terminal as plain text only.
writer.appendStatic('\x1b]52;c;AAAA\x07copied\x1b[31mred\x1b[0m\x07bell');
expect(writes).toEqual(['copiedredbell\n']);
});
it('keeps newlines (the writer appends its own) while dropping other C0 controls', () => {
const writes: string[] = [];
const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk));
writer.updateDynamic('a\x00b\x0bc\x7fd\ne');
expect(writes).toEqual(['abcd\ne']);
});
it('keeps TAB — it separates words in tool/model output (R3-6)', () => {
const writes: string[] = [];
const writer = new ScreenReaderOutputWriter((chunk) => writes.push(chunk));
writer.appendStatic('NAME\tSIZE\nfoo\t10');
expect(writes).toEqual(['NAME\tSIZE\nfoo\t10\n']);
// The dynamic path hard-wraps via wrap-ansi, which expands tabs to the
// next tab stop — word separation survives either way, which is the
// point: deleting TAB outright fuses adjacent tokens.
const dynamicWrites: string[] = [];
const dynamicWriter = new ScreenReaderOutputWriter((chunk) =>
dynamicWrites.push(chunk),
);
dynamicWriter.updateDynamic('a\tb');
expect(dynamicWrites[0]).toMatch(/^a {2,}b$/);
});
});
@@ -0,0 +1,222 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* OpenTUI parity of the ink screen-reader mode (`ui.accessibility.screenReader`,
* `Config.getScreenReader()`). When enabled, the original ink TUI:
*
* - renders plain text only (ink's `renderNodeToScreenReaderOutput` squashes
* text nodes without styles, borders or backgrounds),
* - never enters the virtual viewport / alternate screen
* (`shouldUseVirtualViewport(...)` → false, `alternateScreen: useVP`),
* - has no mouse support at all (OpenTUI boots with `useMouse: true`, so the
* parity here is disabling it),
* - writes append-only output (`<Static>` content is written exactly once,
* the dynamic block only erases its own previous lines),
* - skips the redraw optimizer and synchronized-output wrappers
* (`startInteractiveUI` gates both on `!config.getScreenReader()`).
*
* Pure logic + the append-only writer; the renderer wiring consumes these.
*/
import wrapAnsi from 'wrap-ansi';
import ansiEscapes from 'ansi-escapes';
import type { CliRendererConfig } from '@opentui/core';
import {
isInteractiveTerminal,
shouldUseVirtualViewport,
} from '../utils/terminal-buffer.js';
import { stripAnsi } from './a11y-plain-text.js';
export interface ScreenReaderPolicy {
/** Whether screen-reader mode is active. */
enabled: boolean;
/** Ink parity: squash text nodes, no styles/borders/backgrounds. */
plainText: boolean;
/** Ink parity: static output is written once, never repainted. */
appendOnly: boolean;
/** Ink parity: `shouldUseVirtualViewport` — always false in SR mode. */
virtualViewport: boolean;
/** Ink parity: no mouse when the screen reader is active. */
mouse: boolean;
/** Ink parity: `installSynchronizedOutput` is skipped in SR mode. */
synchronizedOutput: boolean;
/** Ink parity: `installTerminalRedrawOptimizer` is skipped in SR mode. */
redrawOptimizer: boolean;
}
export interface ScreenReaderPolicyOptions {
/** `Config.getScreenReader()` value (CLI flag ?? settings ?? undefined). */
screenReader: boolean | undefined;
/** `settings.merged.ui?.useTerminalBuffer` (defaults to true, as ink). */
useTerminalBuffer?: boolean | undefined;
/** Defaults to probing the real terminal, like `startInteractiveUI`. */
isTTY?: boolean | undefined;
/** Defaults to `process.env`, like `isInteractiveTerminal`. */
env?: Record<string, string | undefined>;
}
/** Parity of the config resolution: the flag defaults to false. */
export function isScreenReaderEnabled(
screenReader: boolean | undefined,
): boolean {
return screenReader ?? false;
}
/**
* Resolves the full screen-reader policy. Every flag reproduces the original
* ink startup logic for the same inputs.
*/
export function resolveScreenReaderPolicy(
options: ScreenReaderPolicyOptions,
): ScreenReaderPolicy {
const enabled = isScreenReaderEnabled(options.screenReader);
const interactive = isInteractiveTerminal(
options.isTTY,
options.env ?? process.env,
);
// Ink gates both installs on process.stdout.isTTY — an omitted isTTY probes
// the real stdout, never "false".
const stdoutIsTTY = options.isTTY ?? process.stdout.isTTY;
return {
enabled,
plainText: enabled,
appendOnly: enabled,
virtualViewport: shouldUseVirtualViewport(
options.useTerminalBuffer,
enabled,
interactive,
),
mouse: !enabled,
synchronizedOutput: Boolean(stdoutIsTTY) && !enabled,
redrawOptimizer: Boolean(stdoutIsTTY) && !enabled,
};
}
/** The subset of OpenTUI renderer options this policy controls. */
export type ScreenReaderRendererOptions = Pick<
CliRendererConfig,
'useMouse' | 'screenMode'
>;
/**
* Renderer options for `createCliRenderer`. Screen-reader mode keeps the
* main screen (ink never enters the alternate screen in SR mode); otherwise
* OpenTUI's own default (`alternate-screen`) is preserved.
*/
export function screenReaderRendererOptions(
policy: ScreenReaderPolicy,
): ScreenReaderRendererOptions {
return policy.enabled
? { useMouse: false, screenMode: 'main-screen' }
: { useMouse: true, screenMode: 'alternate-screen' };
}
/** Structural view of the OpenTUI renderer's mutable mouse switch. */
export interface MouseToggleableRenderer {
useMouse: boolean;
}
/** Live-applies the policy to a running renderer (mouse today). */
export function applyScreenReaderPolicy(
renderer: MouseToggleableRenderer,
policy: ScreenReaderPolicy,
): void {
renderer.useMouse = policy.mouse;
}
// ---------------------------------------------------------------------------
// Append-only writer — parity of ink's isScreenReaderEnabled render loop
// (ink.js): static output is appended once (erasing the pending dynamic
// block first), dynamic output replaces only its own previous lines, and an
// unchanged dynamic block is not rewritten.
// ---------------------------------------------------------------------------
/**
* Escape sequence that erases `count` lines ending at the cursor — ink
* parity via the shared ansi-escapes helper (the trailing cursorLeft is
* required because EL and CUU preserve the cursor column).
*/
export function eraseLines(count: number): string {
return count <= 0 ? '' : ansiEscapes.eraseLines(count);
}
/**
* Hard-wraps text at `width` display columns, exactly like ink's
* screen-reader path (`wrapAnsi(output, width, {trim: false, hard: true })`
* in ink.js): multi-word blocks break at word boundaries and only words
* wider than the width are severed; CJK glyphs count as 2 columns. Widths
* <= 0 disable wrapping.
*/
export function hardWrap(text: string, width: number): string {
if (width <= 0) return text;
return wrapAnsi(text, width, { trim: false, hard: true });
}
export class ScreenReaderOutputWriter {
private lastDynamic = '';
private lastDynamicHeight = 0;
constructor(
private readonly write: (chunk: string) => void,
private readonly columns: () => number = () => Number.POSITIVE_INFINITY,
) {}
/**
* Content crossing this writer is plain-text-only on the main screen, and
* the writer itself enforces that: shell/tool output legitimately carries
* captured escape bytes, and without stripping here a malicious model/tool
* result could execute OSC 52 clipboard writes or title/cursor sequences
* with no renderer buffer in between. Bare C0 controls are dropped too,
* except TAB: it separates words in tool/model output (TSV blocks) and
* deleting it fuses adjacent tokens — ink's screen-reader renderer keeps
* tabs.
*/
private sanitize(text: string): string {
// eslint-disable-next-line no-control-regex
return stripAnsi(text).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
}
/**
* Writes static (append-only) content exactly once. Ink erases the pending
* dynamic block before appending static content and resets its height.
*/
appendStatic(text: string): void {
const clean = this.sanitize(text);
// ink's hasStaticOutput guard skips the write entirely when the
// sanitized content is empty or exactly '\n': the latter would erase
// the dynamic block and emit a spurious blank line.
if (clean.length === 0 || clean === '\n') return;
if (this.lastDynamicHeight > 0) {
this.write(eraseLines(this.lastDynamicHeight));
}
this.lastDynamic = '';
this.lastDynamicHeight = 0;
this.write(clean.endsWith('\n') ? clean : `${clean}\n`);
}
/**
* Replaces the dynamic block in place (no append). The text is sanitized
* and hard-wrapped at the writer's column width; an unchanged block is
* not rewritten.
*/
updateDynamic(text: string): void {
const output = hardWrap(this.sanitize(text), this.columns());
if (output === this.lastDynamic) return;
this.write(eraseLines(this.lastDynamicHeight) + output);
this.lastDynamic = output;
this.lastDynamicHeight = output === '' ? 0 : output.split('\n').length;
}
/** Clears the current dynamic block (e.g. on unmount). */
clearDynamic(): void {
if (this.lastDynamicHeight > 0) {
this.write(eraseLines(this.lastDynamicHeight));
}
this.lastDynamic = '';
this.lastDynamicHeight = 0;
}
}
@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { osc52Sequence } from './clipboard.js';
const copyToClipboardMock = vi.hoisted(() => vi.fn());
vi.mock('../utils/commandUtils.js', () => ({
copyToClipboard: copyToClipboardMock,
}));
describe('osc52Sequence', () => {
it('emits a bare OSC 52 outside multiplexers', () => {
const b64 = Buffer.from('hello', 'utf8').toString('base64');
expect(osc52Sequence('hello', {})).toBe(`\x1b]52;c;${b64}\x07`);
});
it('emits both the bare sequence and the tmux DCS passthrough under TMUX', () => {
const b64 = Buffer.from('hello', 'utf8').toString('base64');
const bare = `\x1b]52;c;${b64}\x07`;
expect(osc52Sequence('hello', { TMUX: '/tmp/tmux-0/default' })).toBe(
bare + `\x1bPtmux;\x1b${bare}\x1b\\`,
);
});
it('wraps the sequence raw in a plain DCS passthrough under GNU screen (STY)', () => {
const b64 = Buffer.from('hi', 'utf8').toString('base64');
expect(osc52Sequence('hi', { STY: '12345.pts-0.host' })).toBe(
`\x1bP\x1b]52;c;${b64}\x07\x1b\\`,
);
});
});
describe('copyText', () => {
beforeEach(() => {
copyToClipboardMock.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('delegates the platform fallback to ink copyToClipboard', async () => {
const { copyText } = await import('./clipboard.js');
copyToClipboardMock.mockResolvedValueOnce(undefined);
await expect(copyText('snippet')).resolves.toBe(true);
expect(copyToClipboardMock).toHaveBeenCalledWith('snippet');
});
it('returns false when the platform fallback fails', async () => {
const { copyText } = await import('./clipboard.js');
copyToClipboardMock.mockRejectedValueOnce(new Error('exit 1'));
await expect(copyText('snippet')).resolves.toBe(false);
});
it('does not write OSC 52 itself (R4-16: single emission)', async () => {
// copyText must delegate OSC 52 to copyToClipboard's existing fallback
// (writeOsc52 in clipboardUtils.ts), not emit its own — the old
// self-write double-emitted on the no-xclip Linux path.
const { copyText } = await import('./clipboard.js');
const stderrWrite = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
const stdoutWrite = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
copyToClipboardMock.mockResolvedValueOnce(undefined);
await expect(copyText('snippet')).resolves.toBe(true);
// No raw OSC 52 bytes from copyText itself.
const osc = osc52Sequence('snippet');
expect(stderrWrite).not.toHaveBeenCalledWith(osc);
expect(stdoutWrite).not.toHaveBeenCalledWith(osc);
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/** Clipboard write: OSC 52 (terminal-native) + platform fallback spawn. */
import { copyToClipboard } from '../utils/commandUtils.js';
/**
* OSC 52 sequence for `text`, adapted to the surrounding multiplexer
* (opencode writeOsc52 parity). Under tmux both the bare sequence and the
* `\x1bPtmux;` DCS passthrough are emitted: tmux relays a bare app OSC 52
* only with set-clipboard=on (the default external drops it) and relays
* the passthrough only with allow-passthrough=on (default off), so each
* form covers one opt-in (verified on tmux 3.6a). GNU screen swallows a
* bare OSC 52 and its DCS passthrough forwards the payload verbatim
* (verified on screen 4.00.03), so the OSC is wrapped raw in a plain DCS —
* the tmux-only `tmux;` tag would reach the outer terminal as literal
* text.
*/
export function osc52Sequence(
text: string,
env: NodeJS.ProcessEnv = process.env,
): string {
const b64 = Buffer.from(text, 'utf8').toString('base64');
const sequence = `\x1b]52;c;${b64}\x07`;
if (env['TMUX']) {
return sequence + `\x1bPtmux;\x1b${sequence}\x1b\\`;
}
if (env['STY']) {
return `\x1bP${sequence}\x1b\\`;
}
return sequence;
}
/**
* Write text to the system clipboard.
* Never throws.
*
* copyText delegates to copyToClipboard — the existing utility already
* has the TTY gate, the OSC 52 fallback (via writeOsc52 / wrapForMultiplexer),
* and the platform command path. A self-written OSC 52 here would
* double-emit on the no-xclip Linux/SSH path exactly this feature targets.
*/
export async function copyText(text: string): Promise<boolean> {
if (!text) return false;
try {
await copyToClipboard(text);
return true;
} catch {
return false;
}
}
@@ -0,0 +1,170 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies the OpenTUI CommandContext builder against the ink processor's
* `commandContext` useMemo (ui/hooks/slashCommandProcessor.ts).
*/
import { describe, it, expect, vi } from 'vitest';
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import { ExtensionRefreshState } from '../../config/extension-refresh-state.js';
import {
createOpenTuiCommandContext,
type OpenTuiCommandHost,
} from './commands-context.js';
// clear() must NOT call clearScreen(): the OpenTUI renderer owns the screen
// (audit 01 G-20); the mock records any accidental raw-ANSI regression.
// The vi.mock factory runs at module load time, so the mock must be hoisted.
const clearScreenMock = vi.hoisted(() => vi.fn());
vi.mock('../../utils/stdioHelpers.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../utils/stdioHelpers.js')>();
return { ...actual, clearScreen: clearScreenMock };
});
function createFakeHost(): OpenTuiCommandHost & {
calls: string[];
sessionNames: Array<string | null>;
} {
const calls: string[] = [];
const sessionNames: Array<string | null> = [];
const record = (name: string) => () => {
calls.push(name);
};
return {
calls,
sessionNames,
getHistory: () => [{ id: 1, type: 'info', text: 'existing' }] as never,
addItem: record('addItem') as never,
updateItem: record('updateItem') as never,
clearItems: record('clearItems'),
loadHistory: record('loadHistory') as never,
refreshStatic: record('refreshStatic'),
clearPendingState: record('clearPendingState'),
cancelBtw: record('cancelBtw'),
btwItem: null,
setBtwItem: record('setBtwItem') as never,
btwAbortControllerRef: { current: null },
pendingItem: null,
setPendingItem: record('setPendingItem') as never,
setDebugMessage: record('setDebugMessage') as never,
toggleVimEnabled: async () => {
calls.push('toggleVimEnabled');
return true;
},
setMemoryFileCount: record('setMemoryFileCount') as never,
reloadCommands: record('reloadCommands'),
setSessionName: (name: string | null) => {
calls.push('setSessionName');
sessionNames.push(name);
},
isIdle: () => true,
extensionsUpdateState: new Map(),
dispatchExtensionStateUpdate: record(
'dispatchExtensionStateUpdate',
) as never,
addConfirmUpdateExtensionRequest: record(
'addConfirmUpdateExtensionRequest',
) as never,
sessionStats: {
sessionId: 'sess-1',
} as unknown as SessionStatsState,
sessionShellAllowlist: new Set(['ls']),
addSessionShellAllowlist: record('addSessionShellAllowlist') as never,
setIsProcessing: record('setIsProcessing') as never,
presentShellConfirmation: async () => ({
outcome: ToolConfirmationOutcome.Cancel,
}),
presentActionConfirmation: async () => false,
handleResume: record('handleResume') as never,
handleBranch: record('handleBranch') as never,
};
}
describe('createOpenTuiCommandContext (ink commandContext parity)', () => {
const services = {
config: null,
settings: {} as LoadedSettings,
logger: null,
};
it('clear() runs the ink sequence minus clearScreen, ending with setSessionName(null)', () => {
const host = createFakeHost();
const context = createOpenTuiCommandContext(host, services);
context.ui.clear();
// Ink: cancelBtw → clearPendingState → clearItems → clearScreen →
// refreshStatic → setSessionName(null). The clearScreen step is skipped:
// clearItems already clears the renderer-level transcript.
expect(host.calls).toEqual([
'cancelBtw',
'clearPendingState',
'clearItems',
'refreshStatic',
'setSessionName',
]);
expect(clearScreenMock).not.toHaveBeenCalled();
// Ink clears the session name as the final step.
expect(host.sessionNames).toEqual([null]);
});
it('exposes a live history getter backed by the host', () => {
const host = createFakeHost();
const context = createOpenTuiCommandContext(host, services);
expect(context.ui.history).toEqual([
{ id: 1, type: 'info', text: 'existing' },
]);
});
it('isIdleRef reflects the host idle state live', () => {
const host = createFakeHost();
let idle = true;
host.isIdle = () => idle;
const context = createOpenTuiCommandContext(host, services);
expect(context.ui.isIdleRef.current).toBe(true);
idle = false;
expect(context.ui.isIdleRef.current).toBe(false);
});
it('falls back to a fresh ExtensionRefreshState like the ink useRef', () => {
const host = createFakeHost();
const context = createOpenTuiCommandContext(host, services);
expect(context.services.extensionRefreshState).toBeInstanceOf(
ExtensionRefreshState,
);
const provided = new ExtensionRefreshState();
const withProvided = createOpenTuiCommandContext(host, {
...services,
extensionRefreshState: provided,
});
expect(withProvided.services.extensionRefreshState).toBe(provided);
});
it('wires services and session state like the ink context', () => {
const host = createFakeHost();
const context = createOpenTuiCommandContext(host, services);
expect(context.executionMode).toBe('interactive');
expect(context.services.config).toBeNull();
expect(context.services.settings).toBe(services.settings);
expect(context.services.logger).toBeNull();
expect(context.session.stats.sessionId).toBe('sess-1');
expect(context.session.sessionShellAllowlist).toEqual(new Set(['ls']));
});
it('routes ui primitives through the host', async () => {
const host = createFakeHost();
const context = createOpenTuiCommandContext(host, services);
expect(await context.ui.toggleVimEnabled()).toBe(true);
context.ui.setDebugMessage('dbg');
context.ui.refreshStatic();
expect(host.calls).toContain('toggleVimEnabled');
expect(host.calls).toContain('setDebugMessage');
expect(host.calls).toContain('refreshStatic');
});
});
@@ -0,0 +1,169 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Command-context parity for the OpenTUI renderer (PR1 slice 5).
*
* The ink TUI builds its `CommandContext` inside `useSlashCommandProcessor`
* (the `commandContext` useMemo). This module builds the SAME context shape
* for the OpenTUI renderer from an `OpenTuiCommandHost` — the surface the
* OpenTUI backend provides for history, session state, and the dialogs the
* original commands drive. Command actions therefore run against identical
* services regardless of renderer.
*/
import type { ReactNode } from 'react';
import type {
Config,
Logger,
ToolConfirmationOutcome,
} from '@qwen-code/qwen-code-core';
import type { CommandContext } from '../commands/types.js';
import type {
ConfirmationRequest,
HistoryItem,
HistoryItemBtw,
HistoryItemWithoutId,
} from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import type {
ExtensionUpdateAction,
ExtensionUpdateStatus,
} from '../state/extensions.js';
import { ExtensionRefreshState } from '../../config/extension-refresh-state.js';
/** Resolution of a shell-command confirmation dialog (ink ShellConfirmation). */
export interface ShellConfirmationResolution {
outcome: ToolConfirmationOutcome;
approvedCommands?: string[];
}
/**
* The UI capabilities the original `CommandContext` and the slash processor
* require, provided by the OpenTUI backend. Method names follow the ink
* history manager / processor actions so the parity mapping is 1:1.
*/
export interface OpenTuiCommandHost {
getHistory(): readonly HistoryItem[];
addItem: UseHistoryManagerReturn['addItem'];
updateItem: UseHistoryManagerReturn['updateItem'];
clearItems: UseHistoryManagerReturn['clearItems'];
loadHistory: UseHistoryManagerReturn['loadHistory'];
refreshStatic(): void;
clearPendingState(): void;
cancelBtw(): void;
btwItem: HistoryItemBtw | null;
setBtwItem(item: HistoryItemBtw | null): void;
btwAbortControllerRef: { current: AbortController | null };
pendingItem: HistoryItemWithoutId | null;
setPendingItem(item: HistoryItemWithoutId | null): void;
setDebugMessage(message: string): void;
toggleVimEnabled(): Promise<boolean>;
setMemoryFileCount(count: number): void;
reloadCommands(): void | Promise<void>;
setSessionName(name: string | null): void;
/** Parity of `isIdleRef.current` — no model turn in flight. */
isIdle(): boolean;
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
dispatchExtensionStateUpdate(action: ExtensionUpdateAction): void;
addConfirmUpdateExtensionRequest(value: ConfirmationRequest): void;
sessionStats: SessionStatsState;
sessionShellAllowlist: Set<string>;
/** Parity of the processor's `setSessionShellAllowlist` merge. */
addSessionShellAllowlist(commands: readonly string[]): void;
startNewSession?(sessionId: string): void;
/** Parity of `setIsProcessing` — gates the ESC-to-cancel keypress. */
setIsProcessing(processing: boolean): void;
/** Presents the shell-commands confirmation; resolves like ink's dialog. */
presentShellConfirmation(
commands: readonly string[],
): Promise<ShellConfirmationResolution>;
/** Presents a yes/no confirmation; resolves like ink's dialog. */
presentActionConfirmation(prompt: ReactNode): Promise<boolean>;
/** Parity of `actions.handleResume` — awaited like the ink processor. */
handleResume(sessionId: string): Promise<void>;
/** Parity of `actions.handleBranch` — awaited like the ink processor. */
handleBranch(name?: string): Promise<void>;
}
export interface OpenTuiCommandServices {
config: Config | null;
settings: LoadedSettings;
logger: Logger | null;
extensionRefreshState?: ExtensionRefreshState;
}
/**
* Builds the `CommandContext` exactly like the ink processor's
* `commandContext` useMemo (slashCommandProcessor.ts):
* - `ui.clear()` = cancelBtw → clearPendingState → clearItems →
* refreshStatic → setSessionName(null). The ink `clearScreen()` step is
* intentionally skipped: the OpenTUI renderer owns the screen, and
* `clearItems` already performs the renderer-level clear (writing raw
* ANSI here would fight the cell-diff painter — audit 01 G-20).
* - a live `history` getter backed by the host
* - a fallback `ExtensionRefreshState` when the backend has none
*/
export function createOpenTuiCommandContext(
host: OpenTuiCommandHost,
services: OpenTuiCommandServices,
): CommandContext {
const extensionRefreshState =
services.extensionRefreshState ?? new ExtensionRefreshState();
return {
executionMode: 'interactive' as const,
services: {
config: services.config,
settings: services.settings,
logger: services.logger,
extensionRefreshState,
},
ui: {
get history() {
return [...host.getHistory()];
},
addItem: (item, timestamp) => host.addItem(item, timestamp),
clear: () => {
host.cancelBtw();
host.clearPendingState();
host.clearItems();
host.refreshStatic();
host.setSessionName(null);
},
clearPendingState: () => host.clearPendingState(),
loadHistory: (history) => host.loadHistory(history),
refreshStatic: () => host.refreshStatic(),
setDebugMessage: (message) => host.setDebugMessage(message),
pendingItem: host.pendingItem,
setPendingItem: (item) => host.setPendingItem(item),
btwItem: host.btwItem,
setBtwItem: (item) => host.setBtwItem(item),
cancelBtw: () => host.cancelBtw(),
btwAbortControllerRef: host.btwAbortControllerRef,
isIdleRef: {
get current() {
return host.isIdle();
},
},
toggleVimEnabled: () => host.toggleVimEnabled(),
setMemoryFileCount: (count) => host.setMemoryFileCount(count),
reloadCommands: () => host.reloadCommands(),
setSessionName: (name) => host.setSessionName(name),
extensionsUpdateState: host.extensionsUpdateState,
dispatchExtensionStateUpdate: (action) =>
host.dispatchExtensionStateUpdate(action),
addConfirmUpdateExtensionRequest: (value) =>
host.addConfirmUpdateExtensionRequest(value),
},
session: {
stats: host.sessionStats,
sessionShellAllowlist: host.sessionShellAllowlist,
startNewSession: host.startNewSession,
},
};
}
@@ -0,0 +1,173 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies commands-output.ts against the ink `addMessage` conversion and
* the chat-recording helpers in ui/hooks/slashCommandProcessor.ts.
*/
import { describe, it, expect } from 'vitest';
import { MessageType } from '../types.js';
import {
commandMessageItem,
messageToHistoryItem,
serializeHistoryItemForRecording,
SLASH_COMMANDS_SKIP_RECORDING,
} from './commands-output.js';
const timestamp = new Date('2026-08-08T08:00:00Z');
describe('messageToHistoryItem (ink addMessage parity)', () => {
it('maps INFO/WARNING/ERROR/USER to text items', () => {
expect(
messageToHistoryItem({
type: MessageType.INFO,
content: 'hello',
timestamp,
}),
).toEqual({ type: 'info', text: 'hello' });
expect(
messageToHistoryItem({
type: MessageType.WARNING,
content: 'careful',
timestamp,
}),
).toEqual({ type: 'warning', text: 'careful' });
expect(
messageToHistoryItem({
type: MessageType.ERROR,
content: 'boom',
timestamp,
}),
).toEqual({ type: 'error', text: 'boom' });
});
it('maps ABOUT to the system-info item', () => {
const systemInfo = {
cliVersion: '0.0.0',
osPlatform: 'darwin',
osArch: 'arm64',
osRelease: '26.0.0',
nodeVersion: 'v22.0.0',
npmVersion: '11.0.0',
sandboxEnv: 'none',
modelVersion: 'test-model',
selectedAuthType: 'api-key',
ideClient: 'none',
sessionId: 'abc',
memoryUsage: '1MB',
};
expect(
messageToHistoryItem({
type: MessageType.ABOUT,
timestamp,
systemInfo,
}),
).toEqual({ type: 'about', systemInfo });
});
it('maps HELP/STATS/QUIT with their display payloads', () => {
expect(messageToHistoryItem({ type: MessageType.HELP, timestamp })).toEqual(
{ type: 'help', timestamp },
);
expect(
messageToHistoryItem({
type: MessageType.STATS,
timestamp,
duration: '1m 2s',
}),
).toEqual({ type: 'stats', duration: '1m 2s' });
expect(
messageToHistoryItem({
type: MessageType.QUIT,
timestamp,
duration: '3s',
}),
).toEqual({ type: 'quit', duration: '3s' });
});
it('maps the stats-family items without payloads', () => {
expect(
messageToHistoryItem({ type: MessageType.MODEL_STATS, timestamp }),
).toEqual({ type: 'model_stats' });
expect(
messageToHistoryItem({ type: MessageType.TOOL_STATS, timestamp }),
).toEqual({ type: 'tool_stats' });
expect(
messageToHistoryItem({ type: MessageType.SKILL_STATS, timestamp }),
).toEqual({ type: 'skill_stats' });
});
it('maps COMPRESSION, SUMMARY and INSIGHT_PROGRESS payloads', () => {
const compression = {
isPending: false,
originalTokenCount: 100,
newTokenCount: 40,
compressionStatus: null,
};
expect(
messageToHistoryItem({
type: MessageType.COMPRESSION,
compression,
timestamp,
}),
).toEqual({ type: 'compression', compression });
const summary = { isPending: false, stage: 'completed' as const };
expect(
messageToHistoryItem({
type: MessageType.SUMMARY,
summary,
timestamp,
}),
).toEqual({ type: 'summary', summary });
const progress = { stage: 'scanning', progress: 0.5 };
expect(
messageToHistoryItem({
type: MessageType.INSIGHT_PROGRESS,
progress,
timestamp,
}),
).toEqual({ type: 'insight_progress', progress });
});
it('commandMessageItem builds the plain text shapes', () => {
expect(commandMessageItem('info', 'Operation cancelled.')).toEqual({
type: 'info',
text: 'Operation cancelled.',
});
});
});
describe('chat-recording helpers (slashCommandProcessor parity)', () => {
it('keeps the original skip-recording set', () => {
expect([...SLASH_COMMANDS_SKIP_RECORDING].sort()).toEqual([
'branch',
'btw',
'clear',
'delete',
'exit',
'history',
'new',
'quit',
'reset',
'resume',
]);
});
it('serializes Date timestamps to ISO strings, keeps other fields', () => {
const item = { type: 'help', timestamp } as const;
const serialized = serializeHistoryItemForRecording(item);
expect(serialized).toEqual({
type: 'help',
timestamp: timestamp.toISOString(),
});
// Items without timestamps pass through untouched.
const plain = { type: 'info', text: 'x' } as const;
expect(serializeHistoryItemForRecording(plain)).toEqual(plain);
});
});
@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Command output parity for the OpenTUI renderer (PR1 slice 5).
*
* Mirrors the output side of the ink `useSlashCommandProcessor`:
* - `addMessage` (slashCommandProcessor.ts) — converts an internal
* `Message` into the history item shape the UI renders
* - the slash-command chat-recording helpers (`SLASH_COMMANDS_SKIP_RECORDING`
* and `serializeHistoryItemForRecording`)
*
* Everything here is renderer-neutral: the outputs are the original ink
* `HistoryItemWithoutId` shapes, which the OpenTUI backend projects into its
* neutral history model the same way the transcript adapter does.
*/
import {
MessageType,
type HistoryItemWithoutId,
type Message,
} from '../types.js';
/**
* Parity of `addMessage` in ui/hooks/slashCommandProcessor.ts: the exact
* Message → HistoryItemWithoutId conversion the ink UI applies.
*/
export function messageToHistoryItem(
message: Message,
now: Date = message.timestamp,
): HistoryItemWithoutId {
if (message.type === MessageType.ABOUT) {
return {
type: 'about',
systemInfo: message.systemInfo,
};
}
if (message.type === MessageType.HELP) {
return {
type: 'help',
timestamp: now,
};
}
if (message.type === MessageType.STATS) {
return {
type: 'stats',
duration: message.duration,
};
}
if (message.type === MessageType.MODEL_STATS) {
return {
type: 'model_stats',
};
}
if (message.type === MessageType.TOOL_STATS) {
return {
type: 'tool_stats',
};
}
if (message.type === MessageType.SKILL_STATS) {
return {
type: 'skill_stats',
};
}
if (message.type === MessageType.QUIT) {
return {
type: 'quit',
duration: message.duration,
};
}
if (message.type === MessageType.COMPRESSION) {
return {
type: 'compression',
compression: message.compression,
};
}
if (message.type === MessageType.SUMMARY) {
return {
type: 'summary',
summary: message.summary,
};
}
if (message.type === MessageType.INSIGHT_PROGRESS) {
return {
type: 'insight_progress',
progress: message.progress,
};
}
return {
type: message.type,
text: message.content,
};
}
/** Convenience builder for the INFO/WARNING/ERROR messages commands return. */
export function commandMessageItem(
messageType: 'info' | 'warning' | 'error' | 'success',
content: string,
): HistoryItemWithoutId {
return { type: messageType, text: content };
}
/**
* Re-export of the canonical `SLASH_COMMANDS_SKIP_RECORDING` set
* (ui/utils/commandUtils.ts) — primary command names that are never written
* to the chat recording service. Importing the original keeps both
* renderers recording the same commands.
*/
export { SLASH_COMMANDS_SKIP_RECORDING } from '../utils/commandUtils.js';
/**
* Parity of `serializeHistoryItemForRecording` in
* ui/hooks/slashCommandProcessor.ts: Date timestamps become ISO strings so
* the recorded items are JSON-serializable.
*/
export function serializeHistoryItemForRecording(
item: HistoryItemWithoutId,
): Record<string, unknown> {
const clone: Record<string, unknown> = { ...item };
if ('timestamp' in clone && clone['timestamp'] instanceof Date) {
clone['timestamp'] = clone['timestamp'].toISOString();
}
return clone;
}
@@ -0,0 +1,175 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies the OpenTUI dialog core reproduces the original ink selection
* machinery: wrap-around navigation that skips disabled rows
* (useSelectionList), the scroll-follow window (BaseSelectionList), the
* numeric quick-select buffer, and the shared tab-cycle/search helpers.
*/
import { describe, it, expect } from 'vitest';
import {
applyNumberSelectKey,
computeInitialActiveIndex,
cycleTab,
findNextEnabledIndex,
followScrollOffset,
getSelectionScrollOffset,
matchesSearchQuery,
selectionWindow,
type DialogListItem,
} from './dialogs-core.js';
function rows(flags: string): Array<DialogListItem<number>> {
return [...flags].map((ch, i) => ({
key: `k${i}`,
value: i,
disabled: ch === 'x',
}));
}
describe('findNextEnabledIndex', () => {
it('moves down and up one step', () => {
const items = rows('aaa');
expect(findNextEnabledIndex(items, 0, 'down')).toBe(1);
expect(findNextEnabledIndex(items, 2, 'up')).toBe(1);
});
it('wraps around at both ends — parity with useSelectionList', () => {
const items = rows('aaa');
expect(findNextEnabledIndex(items, 2, 'down')).toBe(0);
expect(findNextEnabledIndex(items, 0, 'up')).toBe(2);
});
it('skips disabled rows while wrapping', () => {
const items = rows('axa');
expect(findNextEnabledIndex(items, 0, 'down')).toBe(2);
expect(findNextEnabledIndex(items, 2, 'up')).toBe(0);
});
it('keeps the current index when every row is disabled', () => {
const items = rows('xx');
expect(findNextEnabledIndex(items, 1, 'down')).toBe(1);
expect(findNextEnabledIndex(items, 1, 'up')).toBe(1);
});
it('keeps the index for an empty list', () => {
expect(findNextEnabledIndex([], 3, 'down')).toBe(3);
});
});
describe('scroll window rules (BaseSelectionList parity)', () => {
it('getSelectionScrollOffset clamps to both ends', () => {
expect(getSelectionScrollOffset(0, 20, 10)).toBe(0);
expect(getSelectionScrollOffset(12, 20, 10)).toBe(3);
expect(getSelectionScrollOffset(19, 20, 10)).toBe(10);
});
it('followScrollOffset only moves the window when the row leaves it', () => {
// Inside the window: unchanged.
expect(followScrollOffset(3, 2, 20, 5)).toBe(2);
// Above the window: snap the window top to the row.
expect(followScrollOffset(1, 4, 20, 5)).toBe(1);
// Below the window: recompute via getSelectionScrollOffset.
expect(followScrollOffset(9, 2, 20, 5)).toBe(5);
});
it('selectionWindow reports slice bounds and arrow reachability', () => {
const top = selectionWindow(0, 20, 5);
expect(top).toEqual({ start: 0, end: 5, showUp: false, showDown: true });
const middle = selectionWindow(5, 20, 5);
expect(middle).toEqual({
start: 5,
end: 10,
showUp: true,
showDown: true,
});
const bottom = selectionWindow(15, 20, 5);
expect(bottom).toEqual({
start: 15,
end: 20,
showUp: true,
showDown: false,
});
});
it('selectionWindow never slices past the item count', () => {
expect(selectionWindow(0, 3, 10).end).toBe(3);
});
});
describe('computeInitialActiveIndex', () => {
it('clamps out-of-range initial indices to the first row', () => {
expect(computeInitialActiveIndex(99, rows('aaa'))).toBe(0);
expect(computeInitialActiveIndex(-1, rows('aaa'))).toBe(0);
});
it('skips a disabled initial row downwards', () => {
expect(computeInitialActiveIndex(0, rows('xaa'))).toBe(1);
});
it('returns 0 for an empty list', () => {
expect(computeInitialActiveIndex(5, [])).toBe(0);
});
});
describe('applyNumberSelectKey (numeric quick-select parity)', () => {
it('activates the 1-indexed row and waits when another digit could follow', () => {
// In a 12-row list '1' might extend to 10-12.
const result = applyNumberSelectKey({ buffer: '' }, '1', 12);
expect(result.activeIndex).toBe(0);
expect(result.selectNow).toBe(false);
expect(result.pendingSelect).toBe(true);
expect(result.buffer).toBe('1');
});
it('selects immediately when no digit can extend the number', () => {
// '3' cannot extend in a 12-row list ('30' > 12), so it selects at once.
const single = applyNumberSelectKey({ buffer: '' }, '3', 12);
expect(single.activeIndex).toBe(2);
expect(single.selectNow).toBe(true);
// In a 12-row list, '12' cannot extend ('120' > 12).
const result = applyNumberSelectKey({ buffer: '1' }, '2', 12);
expect(result.activeIndex).toBe(11);
expect(result.selectNow).toBe(true);
});
it('treats a lone 0 as invalid (rows are 1-indexed)', () => {
const result = applyNumberSelectKey({ buffer: '' }, '0', 12);
expect(result.buffer).toBe('');
expect(result.activeIndex).toBeUndefined();
expect(result.selectNow).toBe(false);
});
it('drops out-of-range numbers and clears the buffer', () => {
const result = applyNumberSelectKey({ buffer: '9' }, '9', 12);
expect(result.buffer).toBe('');
expect(result.activeIndex).toBeUndefined();
});
});
describe('cycleTab', () => {
const order = ['a', 'b', 'c'] as const;
it('cycles forwards and backwards with wrap', () => {
expect(cycleTab(order, 'a', 1)).toBe('b');
expect(cycleTab(order, 'c', 1)).toBe('a');
expect(cycleTab(order, 'a', -1)).toBe('c');
});
});
describe('matchesSearchQuery', () => {
it('matches any field case-insensitively and trims the query', () => {
expect(matchesSearchQuery(' VIM ', ['general.vimMode'])).toBe(true);
expect(matchesSearchQuery('vim', ['General.VimMode'])).toBe(true);
expect(matchesSearchQuery('nope', ['general.vimMode'])).toBe(false);
});
it('empty query matches everything', () => {
expect(matchesSearchQuery('', [])).toBe(true);
expect(matchesSearchQuery(' ', ['x'])).toBe(true);
});
});
+192
View File
@@ -0,0 +1,192 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Pure dialog machinery for the OpenTUI dialog family (PR1 slice 3).
*
* Renderer-neutral parity layer for the original ink selection list:
* - ui/components/shared/BaseSelectionList.tsx — `getScrollOffsetForIndex`,
* the scroll-follow effect, and the ▲/▼ visibility rules
* - ui/hooks/useSelectionList.ts — wrap-around navigation that skips
* disabled rows, plus the 1-second numeric quick-select buffer
*
* Components in dialogs-core.tsx drive keyboard input through the original
* keybinding table (key-map.ts), so these helpers only model the resulting
* state transitions.
*/
export interface DialogListItem<T = string> {
key: string;
value: T;
disabled?: boolean;
}
/**
* Parity of `findNextValidIndex` in ui/hooks/useSelectionList.ts: move one
* step at a time, wrapping around, until a non-disabled row is found. When
* every row is disabled (or the list is empty) the current index is kept.
*/
export function findNextEnabledIndex<T>(
items: ReadonlyArray<DialogListItem<T>>,
from: number,
direction: 'up' | 'down',
): number {
const len = items.length;
if (len === 0) return from;
const step = direction === 'down' ? 1 : -1;
let nextIndex = from;
for (let i = 0; i < len; i++) {
nextIndex = (nextIndex + step + len) % len;
if (!items[nextIndex]?.disabled) {
return nextIndex;
}
}
return from;
}
/** Parity of `getScrollOffsetForIndex` in shared/BaseSelectionList.tsx. */
export function getSelectionScrollOffset(
activeIndex: number,
itemCount: number,
maxItemsToShow: number,
): number {
return Math.max(
0,
Math.min(activeIndex - maxItemsToShow + 1, itemCount - maxItemsToShow),
);
}
/**
* Parity of the scroll-follow effect in shared/BaseSelectionList.tsx: the
* window only moves when the active row would leave it.
*/
export function followScrollOffset(
activeIndex: number,
scrollOffset: number,
itemCount: number,
maxItemsToShow: number,
): number {
if (activeIndex < scrollOffset) {
return activeIndex;
}
if (activeIndex >= scrollOffset + maxItemsToShow) {
return getSelectionScrollOffset(activeIndex, itemCount, maxItemsToShow);
}
return scrollOffset;
}
export interface SelectionWindow {
start: number;
end: number;
showUp: boolean;
showDown: boolean;
}
/**
* Visible row window plus the ▲/▼ affordance rules. BaseSelectionList always
* renders both arrows when enabled and colors them by reachability; dialogs
* that render the arrows conditionally (SettingsDialog) use `showUp/showDown`.
*/
export function selectionWindow(
scrollOffset: number,
itemCount: number,
maxItemsToShow: number,
): SelectionWindow {
const start = Math.max(0, scrollOffset);
return {
start,
end: Math.min(itemCount, start + maxItemsToShow),
showUp: start > 0,
showDown: start + maxItemsToShow < itemCount,
};
}
/** Parity of `computeInitialIndex` in ui/hooks/useSelectionList.ts. */
export function computeInitialActiveIndex<T>(
initialIndex: number,
items: ReadonlyArray<DialogListItem<T>>,
): number {
if (items.length === 0) return 0;
let target = initialIndex;
if (target < 0 || target >= items.length) target = 0;
if (items[target]?.disabled) {
target = findNextEnabledIndex(items, target, 'down');
}
return target;
}
export const NUMBER_SELECT_TIMEOUT_MS = 1000;
/**
* One step of the numeric quick-select state machine
* (ui/hooks/useSelectionList.ts). Pure: the caller owns the timeout
* (NUMBER_SELECT_TIMEOUT_MS) that flushes `pendingSelect`.
*/
export interface NumberSelectState {
buffer: string;
}
export interface NumberSelectResult {
buffer: string;
/** Row to highlight, when the digit moved the selection. */
activeIndex?: number;
/** Select immediately (no further digit could extend the number). */
selectNow: boolean;
/** Wait for another digit or the timeout, then select. */
pendingSelect: boolean;
}
export function applyNumberSelectKey(
state: NumberSelectState,
digit: string,
itemCount: number,
): NumberSelectResult {
const buffer = state.buffer + digit;
// Single '0' is invalid (rows are 1-indexed).
if (buffer === '0') {
return { buffer: '', selectNow: false, pendingSelect: false };
}
const targetIndex = Number.parseInt(buffer, 10) - 1;
if (targetIndex < 0 || targetIndex >= itemCount) {
return { buffer: '', selectNow: false, pendingSelect: false };
}
// If appending any digit would overshoot the list, the number is complete
// and selects immediately; otherwise buffer it and wait for more input.
const potentialNextNumber = Number.parseInt(`${buffer}0`, 10);
return {
buffer,
activeIndex: targetIndex,
selectNow: potentialNextNumber > itemCount,
pendingSelect: potentialNextNumber <= itemCount,
};
}
/** Parity of the tab-cycling helper used by the config/permissions dialogs. */
export function cycleTab<T>(
order: readonly T[],
current: T,
direction: 1 | -1,
): T {
const index = order.indexOf(current);
const next = (index + direction + order.length) % Math.max(1, order.length);
return order[next] ?? current;
}
/** Case-insensitive search match against any of the given fields. */
export function matchesSearchQuery(
query: string,
fields: ReadonlyArray<string | undefined>,
): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
return fields.some((field) =>
field ? field.toLowerCase().includes(normalized) : false,
);
}
@@ -0,0 +1,158 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Hook-level tests for useDialogSelect: the numeric quick-select timer
* lifecycle and the resyncKey cursor re-sync.
*/
import React from 'react';
import { act, renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const handlers = vi.hoisted(
() => [] as Array<(key: { name: string; sequence?: string }) => void>,
);
vi.mock('@opentui/react', () => ({
useKeyboard: (
handler: (key: { name: string; sequence?: string }) => void,
) => {
handlers.push(handler);
},
}));
// theme.ts builds a SyntaxStyle at module scope; the native FFI is
// unavailable in the test runtime.
vi.mock('@opentui/core', () => ({
SyntaxStyle: { fromStyles: () => ({}) },
MouseButton: { LEFT: 0 },
}));
import { useDialogSelect } from './dialogs-shared.js';
import { NUMBER_SELECT_TIMEOUT_MS } from './dialogs-core.js';
const items = Array.from({ length: 15 }, (_, i) => ({
key: `item-${i}`,
value: `item-${i}`,
}));
const press = (key: { name: string; sequence?: string }) => {
const handler = handlers[handlers.length - 1];
if (!handler) throw new Error('no keyboard handler registered');
act(() => handler(key));
};
describe('useDialogSelect numeric quick-select', () => {
beforeEach(() => {
handlers.length = 0;
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('flushes the pending single-digit selection on timeout', () => {
const onSelect = vi.fn();
renderHook(() => useDialogSelect({ items, numbers: true, onSelect }));
press({ name: '1', sequence: '1' });
act(() => {
vi.advanceTimersByTime(NUMBER_SELECT_TIMEOUT_MS + 10);
});
expect(onSelect).toHaveBeenCalledWith('item-0');
});
it('disarms the pending flush when a follow-up digit is invalid', () => {
const onSelect = vi.fn();
renderHook(() => useDialogSelect({ items, numbers: true, onSelect }));
press({ name: '1', sequence: '1' });
// '19' is out of range: the buffer resets and the pending timer must
// not fire a stale commit of the pre-digit highlight.
press({ name: '9', sequence: '9' });
act(() => {
vi.advanceTimersByTime(NUMBER_SELECT_TIMEOUT_MS + 10);
});
expect(onSelect).not.toHaveBeenCalled();
});
it('fires onSelect exactly once on the timeout flush (R2-1, StrictMode)', () => {
const onSelect = vi.fn();
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<React.StrictMode>{children}</React.StrictMode>
);
renderHook(() => useDialogSelect({ items, numbers: true, onSelect }), {
wrapper: Wrapper,
});
press({ name: '1', sequence: '1' });
act(() => {
vi.advanceTimersByTime(NUMBER_SELECT_TIMEOUT_MS + 10);
});
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect).toHaveBeenCalledWith('item-0');
});
});
describe('useDialogSelect setActiveIndex (ink SET_ACTIVE_INDEX parity)', () => {
beforeEach(() => {
handlers.length = 0;
});
it('setActiveIndex can land on a disabled row (R2-2)', () => {
const mixed = [
{ key: 'a', value: 'a' },
{ key: 'b', value: 'b', disabled: true },
{ key: 'c', value: 'c' },
];
const { result } = renderHook(() =>
useDialogSelect({ items: mixed, numbers: false }),
);
expect(result.current.activeIndex).toBe(0);
// A one-row step toward the disabled row must not get stuck — ink's
// SET_ACTIVE_INDEX accepts any in-range index.
act(() => result.current.setActiveIndex(1));
expect(result.current.activeIndex).toBe(1);
act(() => result.current.setActiveIndex(2));
expect(result.current.activeIndex).toBe(2);
});
it('highlightIndex still skips disabled rows (arrow-key semantics)', () => {
const mixed = [
{ key: 'a', value: 'a' },
{ key: 'b', value: 'b', disabled: true },
{ key: 'c', value: 'c' },
];
const { result } = renderHook(() =>
useDialogSelect({ items: mixed, numbers: false }),
);
act(() => result.current.highlightIndex(1));
expect(result.current.activeIndex).toBe(0);
});
});
describe('useDialogSelect resyncKey', () => {
beforeEach(() => {
handlers.length = 0;
});
it('re-applies initialIndex when the key changes, not on every render', () => {
const onSelect = vi.fn();
const { result, rerender } = renderHook(
(props: { resyncKey: string; initialIndex: number }) =>
useDialogSelect({ items, numbers: false, onSelect, ...props }),
{ initialProps: { resyncKey: 'mount', initialIndex: 0 } },
);
expect(result.current.activeIndex).toBe(0);
rerender({ resyncKey: 'scope-select', initialIndex: 1 });
expect(result.current.activeIndex).toBe(1);
// The user moves within the re-synced view; same key must not reset.
press({ name: 'down' });
expect(result.current.activeIndex).toBe(2);
rerender({ resyncKey: 'scope-select', initialIndex: 1 });
expect(result.current.activeIndex).toBe(2);
});
});
@@ -0,0 +1,449 @@
/* eslint-disable react/no-unknown-property */
/** @jsxImportSource @opentui/react */
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Shared OpenTUI dialog primitives (PR1 slice 3): the dialog frame, tab bar,
* footer hint, and the DialogSelect list. DialogSelect reproduces the ink
* `shared/BaseSelectionList.tsx` row layout (radio `›` indicator, padded row
* numbers, ▲/▼ scroll arrows) and pairs with `useDialogSelect`, which
* reproduces the `ui/hooks/useSelectionList.ts` keyboard behavior (↑/↓/j/k
* wrap-around navigation, Enter to select, numeric quick-select) by routing
* keys through the ORIGINAL keybinding table via key-map.ts. Mouse support is
* native to OpenTUI: hover highlights, left-click selects, wheel scrolls.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { MouseButton } from '@opentui/core';
import { useKeyboard } from '@opentui/react';
import { C } from './theme.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { toOriginalKey } from './key-map.js';
import {
applyNumberSelectKey,
computeInitialActiveIndex,
findNextEnabledIndex,
followScrollOffset,
getSelectionScrollOffset,
selectionWindow,
NUMBER_SELECT_TIMEOUT_MS,
type DialogListItem,
} from './dialogs-core.js';
export { type DialogListItem };
export const DEFAULT_MAX_ITEMS_TO_SHOW = 10;
/**
* Dialog-level Tab/Esc bindings shared by the slice 3 dialog family
* (ThemeDialog, SettingsDialog, and extensions all cycle views with Tab and
* dismiss with Esc; the list rows own ↑/↓/Enter/digits).
*/
export function useDialogFrameKeys(handlers: {
onTab?: (shift: boolean) => void;
onEscape?: () => void;
}): void {
useKeyboard((key) => {
const original = toOriginalKey(key);
if (original.name === 'tab') handlers.onTab?.(original.shift);
if (original.name === 'escape') handlers.onEscape?.();
});
}
/**
* Dialog frame matching the ink dialogs' chrome: `borderStyle="round"` +
* padding 1 (OpenTUI spells the rounded border style "rounded").
*/
export function DialogFrame(props: {
children?: ReactNode;
borderColor?: string;
}) {
return (
<box
flexDirection="column"
borderStyle="rounded"
borderColor={props.borderColor ?? C.dim}
padding={1}
>
{props.children}
</box>
);
}
/** Footer hint line — dim, one row of margin above. */
export function FooterHint(props: { text: string }) {
return (
<box marginTop={1}>
<text fg={C.dim}>{props.text}</text>
</box>
);
}
export interface DialogTab {
id: string;
label: string;
}
/**
* Tab bar parity (SettingsDialog ConfigTabBar / PermissionsDialog TabBar /
* extensions TabBar): the active tab is a label on the accent background,
* inactive tabs are dim, followed by a cycling hint.
*/
export function DialogTabBar(props: {
tabs: readonly DialogTab[];
activeId: string;
hint?: string;
}) {
return (
<box flexDirection="row">
{props.tabs.map((tab) => {
const active = tab.id === props.activeId;
return (
<box key={tab.id} marginRight={2}>
<text
fg={active ? '#000000' : C.dim}
bg={active ? C.accent : undefined}
attributes={active ? 1 : undefined}
>
{` ${tab.label} `}
</text>
</box>
);
})}
{props.hint ? <text fg={C.dim}> {props.hint}</text> : null}
</box>
);
}
export interface UseDialogSelectOptions<TItem extends DialogListItem<unknown>> {
items: readonly TItem[];
initialIndex?: number;
/**
* Re-apply initialIndex whenever this key changes. Dialogs that keep one
* mounted hook for several views use it to re-sync the cursor on view
* entry, matching ink's remounted selection components.
*/
resyncKey?: string | number;
/** Only react to keys while true (multiple lists share one keyboard). */
focused?: boolean;
/** Numeric quick-select (the numbered rows' "type the row number"). */
numbers?: boolean;
/** Rows kept visible at once; drives the scroll window. */
maxItemsToShow?: number;
onSelect?: (value: TItem['value']) => void;
onHighlight?: (value: TItem['value'], index: number) => void;
}
export interface UseDialogSelectResult<TItem extends DialogListItem<unknown>> {
activeIndex: number;
scrollOffset: number;
setScrollOffset: (offset: number) => void;
setActiveIndex: (index: number) => void;
/** Click-to-choose: highlight + select the row (disabled rows ignored). */
selectIndex: (index: number) => void;
highlightIndex: (index: number) => void;
items: readonly TItem[];
}
/**
* Keyboard + selection + scroll-window state for DialogSelect. Mirrors
* useSelectionList: SELECTION_UP/SELECTION_DOWN wrap around and skip
* disabled rows, Enter selects the highlighted row, digits quick-select by
* row number with a NUMBER_SELECT_TIMEOUT_MS flush. The scroll window
* follows the highlight with BaseSelectionList's rules.
*/
export function useDialogSelect<TItem extends DialogListItem<unknown>>(
options: UseDialogSelectOptions<TItem>,
): UseDialogSelectResult<TItem> {
const {
items,
initialIndex = 0,
resyncKey,
focused = true,
numbers = true,
maxItemsToShow = DEFAULT_MAX_ITEMS_TO_SHOW,
onSelect,
onHighlight,
} = options;
const [activeIndex, setActiveIndexState] = useState(() =>
computeInitialActiveIndex(initialIndex, items),
);
const [scrollOffset, setScrollOffset] = useState(() =>
getSelectionScrollOffset(
computeInitialActiveIndex(initialIndex, items),
items.length,
maxItemsToShow,
),
);
const numberBuffer = useRef('');
const numberTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Resync during render when the key changes (React's adjust-state-during-
// render pattern): consumers that swap views over one mounted hook get
// the fresh initialIndex instead of the mount-time snapshot.
const [appliedResyncKey, setAppliedResyncKey] = useState(resyncKey);
if (appliedResyncKey !== resyncKey) {
setAppliedResyncKey(resyncKey);
// A view swap resets the selection context; an armed numeric flush
// from the previous view must not commit a selection in the new one.
if (numberTimer.current) {
clearTimeout(numberTimer.current);
numberTimer.current = null;
}
numberBuffer.current = '';
const next = computeInitialActiveIndex(initialIndex, items);
setActiveIndexState(next);
setScrollOffset(
getSelectionScrollOffset(next, items.length, maxItemsToShow),
);
}
// The number-select flush reads the highlight at timeout time via a ref,
// not inside a setState updater — updaters must stay pure (StrictMode
// double-invokes them) and React re-renders keep the ref current.
const latestRef = useRef({ items, activeIndex, onSelect });
latestRef.current = { items, activeIndex, onSelect };
useEffect(
() => () => {
if (numberTimer.current) clearTimeout(numberTimer.current);
},
[],
);
// BaseSelectionList scroll-follow: the window only moves when the
// highlight would leave it.
useEffect(() => {
const next = followScrollOffset(
activeIndex,
scrollOffset,
items.length,
maxItemsToShow,
);
if (next !== scrollOffset) setScrollOffset(next);
}, [activeIndex, scrollOffset, items.length, maxItemsToShow]);
const clearNumberBuffer = () => {
if (numberTimer.current) {
clearTimeout(numberTimer.current);
numberTimer.current = null;
}
numberBuffer.current = '';
};
const highlightIndex = (index: number) => {
if (index < 0 || index >= items.length || index === activeIndex) return;
if (items[index]?.disabled) return;
setActiveIndexState(index);
const item = items[index];
if (item) onHighlight?.(item.value, index);
};
// ink's SET_ACTIVE_INDEX permits landing on any in-range index — callers
// like wheel/hover navigation step one row per gesture, and rejecting
// disabled targets would leave them permanently stuck on a disabled row.
const setActiveIndex = (index: number) => {
if (index < 0 || index >= items.length || index === activeIndex) return;
// Moving the highlight by any means (wheel, hover) invalidates a
// pending numeric flush: the flush must commit the typed row, not
// wherever the pointer happened to land.
clearNumberBuffer();
setActiveIndexState(index);
const item = items[index];
if (item) onHighlight?.(item.value, index);
};
const selectIndex = (index: number) => {
const item = items[index];
if (!item || item.disabled) return;
// A click selects this row now; an armed numeric flush would fire a
// second onSelect later.
clearNumberBuffer();
// ink dispatches SET_ACTIVE_INDEX before SELECT_CURRENT, so highlight
// consumers (theme preview, scope selection) stay synced on mouse input
// too, not just keyboard input.
setActiveIndexState(index);
onHighlight?.(item.value, index);
onSelect?.(item.value);
};
useKeyboard((key) => {
if (!focused || items.length === 0) return;
const original = toOriginalKey(key);
if (numbers && !original.ctrl && /^[0-9]$/.test(original.sequence)) {
// The original hook clears the pending flush on every digit first —
// an invalid digit (leading '0', out-of-range) must disarm it, or the
// stale timer would later commit the pre-digit highlight.
if (numberTimer.current) {
clearTimeout(numberTimer.current);
numberTimer.current = null;
}
const result = applyNumberSelectKey(
{ buffer: numberBuffer.current },
original.sequence,
items.length,
);
numberBuffer.current = result.buffer;
if (result.activeIndex !== undefined) {
setActiveIndexState(result.activeIndex);
const item = items[result.activeIndex];
if (item) onHighlight?.(item.value, result.activeIndex);
}
if (result.selectNow) {
clearNumberBuffer();
const item = items[result.activeIndex ?? activeIndex];
if (item && !item.disabled) onSelect?.(item.value);
} else if (result.pendingSelect) {
numberTimer.current = setTimeout(() => {
clearNumberBuffer();
// Flush against the highlight at timeout time, outside any setState
// updater (updaters are pure and StrictMode re-runs them).
const latest = latestRef.current;
const item = latest.items[latest.activeIndex];
if (item && !item.disabled) latest.onSelect?.(item.value);
}, NUMBER_SELECT_TIMEOUT_MS);
}
return;
}
// Any non-digit key abandons a number in progress, exactly like the
// original hook clears its buffer on a non-numeric key.
clearNumberBuffer();
if (keyMatchers[Command.SELECTION_UP](original)) {
highlightIndex(findNextEnabledIndex(items, activeIndex, 'up'));
return;
}
if (keyMatchers[Command.SELECTION_DOWN](original)) {
highlightIndex(findNextEnabledIndex(items, activeIndex, 'down'));
return;
}
if (original.name === 'return') {
const item = items[activeIndex];
if (item && !item.disabled) onSelect?.(item.value);
}
});
return {
activeIndex,
scrollOffset,
setScrollOffset,
setActiveIndex,
selectIndex,
highlightIndex,
items,
};
}
export interface DialogSelectProps<TItem extends DialogListItem<unknown>> {
items: readonly TItem[];
activeIndex: number;
scrollOffset: number;
maxItemsToShow?: number;
showNumbers?: boolean;
/** Like BaseSelectionList, always render both arrows when enabled. */
showScrollArrows?: boolean;
focused?: boolean;
onHover?: (index: number) => void;
/** Wheel: move the highlight by one row per notch. */
onWheel?: (direction: 'up' | 'down') => void;
/** Click-to-choose (highlight + select in one gesture). */
onSelectIndex?: (index: number) => void;
renderLabel?: (
item: TItem,
context: { isSelected: boolean; titleColor: string },
) => ReactNode;
}
/**
* Presentational selection list. Row anatomy is BaseSelectionList parity:
* 2-wide `›` indicator, right-aligned `N.` number column, then the label;
* selected rows use the success color, disabled rows dim.
*/
export function DialogSelect<TItem extends DialogListItem<unknown>>(
props: DialogSelectProps<TItem>,
) {
const {
items,
activeIndex,
scrollOffset,
maxItemsToShow = DEFAULT_MAX_ITEMS_TO_SHOW,
showNumbers = true,
showScrollArrows = false,
focused = true,
onHover,
onWheel,
onSelectIndex,
renderLabel,
} = props;
const window_ = selectionWindow(scrollOffset, items.length, maxItemsToShow);
const visible = items.slice(window_.start, window_.end);
const numberColumnWidth = String(items.length).length;
return (
<box
flexDirection="column"
onMouseScroll={(e) => {
const direction = e.scroll?.direction;
if (direction === 'up' || direction === 'down') onWheel?.(direction);
}}
>
{showScrollArrows && <text fg={window_.showUp ? C.text : C.dim}>▲</text>}
{visible.map((item, rowIndex) => {
const itemIndex = window_.start + rowIndex;
const isSelected = focused && activeIndex === itemIndex;
const titleColor = isSelected
? C.green
: item.disabled
? C.dim
: C.text;
const numberColor =
!showNumbers || (!focused && !item.disabled) ? C.dim : titleColor;
const numberText = `${String(itemIndex + 1).padStart(numberColumnWidth)}.`;
return (
<box
key={item.key}
flexDirection="row"
onMouseOver={() => {
if (!item.disabled) onHover?.(itemIndex);
}}
onMouseUp={(e) => {
if (e.button === MouseButton.LEFT && !item.disabled) {
onSelectIndex?.(itemIndex);
}
}}
>
<box width={2} flexShrink={0}>
<text fg={isSelected ? C.green : C.text}>
{isSelected ? '›' : ' '}
</text>
</box>
{showNumbers && (
<box width={numberText.length + 1} flexShrink={0}>
<text fg={numberColor}>{numberText}</text>
</box>
)}
<box flexGrow={1}>
{renderLabel ? (
renderLabel(item, { isSelected, titleColor })
) : (
<text fg={titleColor}>{String(item.value)}</text>
)}
</box>
</box>
);
})}
{showScrollArrows && (
<text fg={window_.showDown ? C.text : C.dim}>▼</text>
)}
</box>
);
}
@@ -0,0 +1,126 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies the OpenTUI theme dialog reproduces the original ink
* ThemeDialog content: the Auto/built-in/custom item order, capitalized
* type column, preview-pane sample content, and the height budget split.
*/
import { describe, it, expect, vi } from 'vitest';
vi.mock('@opentui/core', () => ({
SyntaxStyle: { fromStyles: () => ({}) },
MouseButton: { LEFT: 0 },
}));
import {
buildThemeItems,
capitalizeThemeType,
computeThemePreviewLayout,
THEME_DIALOG_MAX_ITEMS_TO_SHOW,
THEME_PREVIEW_CODE,
THEME_PREVIEW_DIFF,
} from './dialogs-theme.js';
describe('capitalizeThemeType', () => {
it('capitalizes the first character only', () => {
expect(capitalizeThemeType('dark')).toBe('Dark');
expect(capitalizeThemeType('light')).toBe('Light');
});
});
describe('buildThemeItems', () => {
const builtIn = [
{ name: 'Default', type: 'dark' },
{ name: 'DefaultLight', type: 'light' },
];
it('puts Auto first with the original labels', () => {
const items = buildThemeItems(builtIn, []);
expect(items[0]).toEqual({
label: 'Auto (detect terminal theme)',
value: 'auto',
themeNameDisplay: 'Auto',
themeTypeDisplay: 'Auto',
key: 'auto',
});
});
it('lists built-in themes with a capitalized type column', () => {
const items = buildThemeItems(builtIn, []);
expect(items[1]).toMatchObject({
label: 'Default',
value: 'Default',
themeNameDisplay: 'Default',
themeTypeDisplay: 'Dark',
key: 'Default',
});
});
it('appends custom themes last, typed Custom', () => {
const items = buildThemeItems(builtIn, ['my-theme']);
expect(items.at(-1)).toEqual({
label: 'my-theme',
value: 'my-theme',
themeNameDisplay: 'my-theme',
themeTypeDisplay: 'Custom',
key: 'my-theme',
});
expect(items).toHaveLength(4);
});
});
describe('preview pane content parity', () => {
it('keeps the original python sample byte-for-byte', () => {
expect(THEME_PREVIEW_CODE).toBe(
[
'# function',
'def fibonacci(n):',
' a, b = 0, 1',
' for _ in range(n):',
' a, b = b, a + b',
' return a',
].join('\n'),
);
});
it('keeps the original diff sample byte-for-byte', () => {
expect(THEME_PREVIEW_DIFF).toBe(
[
'--- a/util.py',
'+++ b/util.py',
'@@ -1,2 +1,2 @@',
'- print("Hello, " + name)',
'+ print(f"Hello, {name}!")',
'',
].join('\n'),
);
});
it('uses the original 12-row window', () => {
expect(THEME_DIALOG_MAX_ITEMS_TO_SHOW).toBe(12);
});
});
describe('computeThemePreviewLayout', () => {
it('keeps padding when the left column fits', () => {
const layout = computeThemePreviewLayout(40, 5);
expect(layout.includePadding).toBe(true);
expect(layout.codeBlockHeight).toBeGreaterThan(0);
expect(layout.diffHeight).toBeGreaterThan(0);
});
it('drops padding when the theme list no longer fits', () => {
const layout = computeThemePreviewLayout(10, 20);
expect(layout.includePadding).toBe(false);
});
it('splits the remaining space 60/40 between code and diff', () => {
const layout = computeThemePreviewLayout(60, 5);
expect(layout.codeBlockHeight).toBeGreaterThanOrEqual(layout.diffHeight);
});
});
@@ -0,0 +1,326 @@
/* eslint-disable react/no-unknown-property */
/** @jsxImportSource @opentui/react */
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* OpenTUI parity of the ink `/theme` dialog
* (ui/components/ThemeDialog.tsx): Auto entry first, built-in themes with a
* capitalized type column, scope-local custom themes, live preview pane
* (python code sample + unified diff sample), `Tab` scope mode with the
* shared "Apply To" selector, and the original footer hints. Keyboard runs
* through the original keybinding table; hover/click/wheel are native.
*/
import { useState } from 'react';
import { C, SYNTAX } from './theme.js';
import { t } from '../../i18n/index.js';
import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import {
getScopeMessageForSetting,
getScopeItems,
} from '../../config/dialogScopeUtils.js';
import { themeManager, AUTO_THEME_NAME } from '../themes/theme-manager.js';
import {
DialogFrame,
DialogSelect,
FooterHint,
useDialogFrameKeys,
useDialogSelect,
} from './dialogs-shared.js';
import type { DialogListItem } from './dialogs-core.js';
export const THEME_DIALOG_MAX_ITEMS_TO_SHOW = 12;
/** The preview pane's sample sources — byte-for-byte the ink originals. */
export const THEME_PREVIEW_CODE = `# function
def fibonacci(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a`;
export const THEME_PREVIEW_DIFF = `--- a/util.py
+++ b/util.py
@@ -1,2 +1,2 @@
- print("Hello, " + name)
+ print(f"Hello, {name}!")
`;
/** Parity of the inline `capitalize` helper in ThemeDialog. */
export function capitalizeThemeType(type: string): string {
return type.charAt(0).toUpperCase() + type.slice(1);
}
export interface OpenTuiThemeItem extends DialogListItem<string> {
label: string;
themeNameDisplay: string;
themeTypeDisplay: string;
}
/**
* Parity of ThemeDialog's `themeItems`: Auto first, then built-in themes
* (type !== 'custom'), then the scope-local custom theme names.
*/
export function buildThemeItems(
builtInThemes: ReadonlyArray<{ name: string; type: string }>,
customThemeNames: readonly string[],
): OpenTuiThemeItem[] {
return [
{
label: t('Auto (detect terminal theme)'),
value: AUTO_THEME_NAME,
themeNameDisplay: t('Auto'),
themeTypeDisplay: t('Auto'),
key: AUTO_THEME_NAME,
},
...builtInThemes.map((theme) => ({
label: theme.name,
value: theme.name,
themeNameDisplay: theme.name,
themeTypeDisplay: capitalizeThemeType(theme.type),
key: theme.name,
})),
...customThemeNames.map((name) => ({
label: name,
value: name,
themeNameDisplay: name,
themeTypeDisplay: t('Custom'),
key: name,
})),
];
}
export interface ThemePreviewLayout {
includePadding: boolean;
codeBlockHeight: number;
diffHeight: number;
}
/**
* Parity of ThemeDialog's preview height budget: the left column's height
* sets the pane, padding is dropped when it does not fit, and the remaining
* rows split 60/40 between the code block and the diff.
*/
export function computeThemePreviewLayout(
availableTerminalHeight: number | undefined,
themeItemCount: number,
): ThemePreviewLayout {
const DIALOG_PADDING = 2;
const TAB_TO_SELECT_HEIGHT = 2;
const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8;
let budget = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
budget -= 2; // Top and bottom borders.
budget -= TAB_TO_SELECT_HEIGHT;
let totalLeftHandSideHeight = DIALOG_PADDING + themeItemCount + 1;
let includePadding = true;
if (totalLeftHandSideHeight > budget) {
includePadding = false;
totalLeftHandSideHeight -= DIALOG_PADDING;
}
budget = Math.max(budget, totalLeftHandSideHeight);
const availableForCodeBlock =
budget - PREVIEW_PANE_FIXED_VERTICAL_SPACE - (includePadding ? 2 : 0) * 2;
const availableHeightForPanes = Math.max(0, availableForCodeBlock - 1);
return {
includePadding,
codeBlockHeight: Math.max(1, Math.ceil(availableHeightForPanes * 0.6)),
diffHeight: Math.max(1, Math.floor(availableHeightForPanes * 0.4)),
};
}
export interface OpenTuiThemeDialogProps {
onSelect: (themeName: string | undefined, scope: SettingScope) => void;
onHighlight: (themeName: string | undefined) => void;
settings: LoadedSettings;
availableTerminalHeight?: number;
}
export function OpenTuiThemeDialog(props: OpenTuiThemeDialogProps) {
const { onSelect, onHighlight, settings, availableTerminalHeight } = props;
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
);
// An unset theme means auto-detection is in effect — highlight Auto.
const [highlightedThemeName, setHighlightedThemeName] = useState<
string | undefined
>(settings.merged.ui?.theme || AUTO_THEME_NAME);
const [mode, setMode] = useState<'theme' | 'scope'>('theme');
const customThemes =
selectedScope === SettingScope.User
? settings.user.settings.ui?.customThemes || {}
: settings.merged.ui?.customThemes || {};
const builtInThemes = themeManager
.getAvailableThemes()
.filter((theme) => theme.type !== 'custom');
const themeItems = buildThemeItems(builtInThemes, Object.keys(customThemes));
const initialThemeIndex = themeItems.findIndex(
(item) => item.value === highlightedThemeName,
);
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
const themeList = useDialogSelect({
items: themeItems,
initialIndex: safeInitialThemeIndex,
focused: mode === 'theme',
maxItemsToShow: THEME_DIALOG_MAX_ITEMS_TO_SHOW,
// The item list grows/shrinks with the scope's custom themes; re-sync
// the cursor on scope change like ink's useSelectionList re-clamps.
resyncKey: selectedScope,
onSelect: (themeName) => onSelect(themeName, selectedScope),
onHighlight: (themeName) => {
setHighlightedThemeName(themeName);
onHighlight(themeName);
},
});
const scopeItems = getScopeItems().map((item) => ({
label: t(item.label),
key: item.value,
value: item.value,
}));
const initialScopeIndex = scopeItems.findIndex(
(item) => item.value === selectedScope,
);
const scopeList = useDialogSelect({
items: scopeItems,
initialIndex: initialScopeIndex >= 0 ? initialScopeIndex : 0,
focused: mode === 'scope',
numbers: mode === 'scope',
onSelect: (scope) => onSelect(highlightedThemeName, scope),
onHighlight: (scope) => setSelectedScope(scope),
});
// Tab toggles views, Esc cancels — the exact ThemeDialog bindings. The
// list keys (↑/↓/j/k/Enter/digits) live in useDialogSelect.
useDialogFrameKeys({
onTab: () => setMode((prev) => (prev === 'theme' ? 'scope' : 'theme')),
onEscape: () => onSelect(undefined, selectedScope),
});
const otherScopeModifiedMessage = getScopeMessageForSetting(
'ui.theme',
selectedScope,
settings,
);
const layout = computeThemePreviewLayout(
availableTerminalHeight,
themeItems.length,
);
return (
<DialogFrame>
{mode === 'theme' ? (
<box flexDirection="row">
<box flexDirection="column" width="45%" paddingRight={2}>
<box flexDirection="row" marginBottom={1}>
<text fg={C.text} attributes={1}>
{'> '}
{t('Select Theme')}{' '}
</text>
<text fg={C.dim}>{otherScopeModifiedMessage}</text>
</box>
<DialogSelect
items={themeItems}
activeIndex={themeList.activeIndex}
scrollOffset={themeList.scrollOffset}
maxItemsToShow={THEME_DIALOG_MAX_ITEMS_TO_SHOW}
showScrollArrows={true}
showNumbers={mode === 'theme'}
focused={mode === 'theme'}
onHover={themeList.setActiveIndex}
onSelectIndex={themeList.selectIndex}
onWheel={(direction) =>
themeList.setActiveIndex(
themeList.activeIndex + (direction === 'down' ? 1 : -1),
)
}
renderLabel={(item, { titleColor }) => (
<text fg={titleColor}>
{item.themeNameDisplay}{' '}
<text fg={C.dim}>{item.themeTypeDisplay}</text>
</text>
)}
/>
</box>
<box flexDirection="column" width="55%" paddingLeft={2}>
<text fg={C.text} attributes={1}>
{t('Preview')}
</text>
<box
flexDirection="column"
borderStyle="single"
borderColor={C.dim}
paddingX={1}
paddingY={layout.includePadding ? 1 : 0}
marginTop={1}
>
<code
content={THEME_PREVIEW_CODE}
filetype="python"
syntaxStyle={SYNTAX}
fg={C.text}
height={layout.codeBlockHeight}
/>
<box marginTop={1}>
<diff
diff={THEME_PREVIEW_DIFF}
view="unified"
filetype="python"
syntaxStyle={SYNTAX}
fg={C.text}
height={layout.diffHeight}
/>
</box>
</box>
</box>
</box>
) : (
<box flexDirection="column">
<box flexDirection="row" marginBottom={1}>
<text fg={C.text} attributes={1}>
{'> '}
{t('Apply To')}
</text>
</box>
<DialogSelect
items={scopeItems}
activeIndex={scopeList.activeIndex}
scrollOffset={scopeList.scrollOffset}
showNumbers={mode === 'scope'}
focused={mode === 'scope'}
onHover={scopeList.setActiveIndex}
onSelectIndex={scopeList.selectIndex}
onWheel={(direction) =>
scopeList.setActiveIndex(
scopeList.activeIndex + (direction === 'down' ? 1 : -1),
)
}
renderLabel={(item, { titleColor }) => (
<text fg={titleColor}>{item.label}</text>
)}
/>
</box>
)}
<FooterHint
text={
mode === 'theme'
? t('(Use Enter to select, Tab to configure scope)')
: t('(Use Enter to apply scope, Tab to go back)')
}
/>
</DialogFrame>
);
}
@@ -0,0 +1,128 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import {
decodeCapturedInput,
drainCapturedInputAsText,
injectCapturedInput,
} from './early-input.js';
import { resetCaptureState } from '../../utils/earlyInputCapture.js';
describe('decodeCapturedInput', () => {
it('returns empty for an empty buffer', () => {
expect(decodeCapturedInput(Buffer.alloc(0))).toBe('');
});
it('keeps printable text and spaces', () => {
expect(decodeCapturedInput(Buffer.from('hello world'))).toBe('hello world');
});
it('keeps newlines but strips carriage returns', () => {
expect(decodeCapturedInput(Buffer.from('line1\nline2\r'))).toBe(
'line1\nline2',
);
});
it('strips Ctrl+C and other C0 control bytes', () => {
expect(decodeCapturedInput(Buffer.from('ab\x03cd\x7f'))).toBe('abcd');
});
it('strips CSI escape sequences (arrow keys, etc.)', () => {
expect(decodeCapturedInput(Buffer.from('x\u001B[Ay\u001B[1;5Cz'))).toBe(
'xyz',
);
});
it('strips SS3 function-key sequences whole (F1-F4) instead of leaking the payload', () => {
// The capture filter preserves ESC O P/Q/R/S as user input, so decoding
// must remove the whole sequence: the C0 pass would otherwise drop only
// the bare ESC and leave the O+letter in the composer (R1-74).
expect(
decodeCapturedInput(Buffer.from('a\u001BOPb\u001BOQc\u001BORd\u001BOSe')),
).toBe('abcde');
});
it('preserves multibyte (CJK) input', () => {
expect(decodeCapturedInput(Buffer.from('你好,世界'))).toBe('你好,世界');
});
it('strips CSI colon params, intermediates and private flags (R1-5)', () => {
// kitty CSI-u colon parameters, space intermediate + private `?` flag:
// the full ECMA-48 production must be consumed as one sequence.
expect(
decodeCapturedInput(
Buffer.from('\u001B[38:5:208mA\u001B[ qB\u001B[?25lC'),
),
).toBe('ABC');
});
it('drops a truncated trailing CSI sequence (R1-5)', () => {
// A replay cut mid-sequence must not leak '[12;3' into the composer.
expect(decodeCapturedInput(Buffer.from('AB\u001B[12;3'))).toBe('AB');
});
});
describe('drainCapturedInputAsText', () => {
afterEach(() => resetCaptureState());
it('returns empty string when nothing was captured', () => {
resetCaptureState();
expect(drainCapturedInputAsText()).toBe('');
});
});
describe('injectCapturedInput', () => {
afterEach(() => vi.useRealTimers());
it('injects text once the composer handle appears', () => {
vi.useFakeTimers();
const setTimeoutFn = (fn: () => void, ms: number) => setTimeout(fn, ms);
const clearTimeoutFn = (h: unknown) =>
clearTimeout(h as ReturnType<typeof setTimeout>);
let handle: { setText: (t: string) => void } | null = null;
const setText = vi.fn();
injectCapturedInput(() => handle, 'hello', {
intervalMs: 10,
maxAttempts: 5,
setTimeoutFn,
clearTimeoutFn,
});
// Not attached yet: nothing written.
vi.advanceTimersByTime(10);
expect(setText).not.toHaveBeenCalled();
handle = { setText };
vi.advanceTimersByTime(10);
expect(setText).toHaveBeenCalledWith('hello');
});
it('does nothing for empty text', () => {
const setTimeoutFn = vi.fn();
const dispose = injectCapturedInput(() => null, '', { setTimeoutFn });
expect(setTimeoutFn).not.toHaveBeenCalled();
dispose();
});
it('stops retrying after maxAttempts without a handle', () => {
vi.useFakeTimers();
const setTimeoutFn = (fn: () => void, ms: number) => setTimeout(fn, ms);
const clearTimeoutFn = (h: unknown) =>
clearTimeout(h as ReturnType<typeof setTimeout>);
const dispose = injectCapturedInput(() => null, 'x', {
intervalMs: 1,
maxAttempts: 2,
setTimeoutFn,
clearTimeoutFn,
});
// Should not throw or loop forever; disposing is a no-op afterwards.
vi.advanceTimersByTime(100);
dispose();
});
});
+115
View File
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Early-input injection for the OpenTUI entry (ink parity).
*
* `gemini.tsx` starts `startEarlyInputCapture()` during startup so keystrokes
* typed while the CLI boots are not lost. The ink branch drains the buffer
* with `stopAndGetCapturedInput()` and feeds it to the keypress provider as
* `initialCapturedInput` (`startInteractiveUI.tsx:172-176`). The OpenTUI
* branch never drained it, so captured input sat in the buffer forever and
* was never injected. This module drains the buffer and hands the text to
* the composer.
*/
import { stopAndGetCapturedInput } from '../../utils/earlyInputCapture.js';
/**
* Decodes a captured startup-input buffer into composer text. The capture
* filter already drops terminal response sequences; what remains is user
* input. Control bytes that are not meaningful as composer text (e.g. raw
* `\r`, `\x03`, arrow-key escapes) are stripped — the goal is to recover
* typed characters, not to replay editing keys. Newlines (`\n`) are kept.
*/
export function decodeCapturedInput(buffer: Buffer): string {
if (buffer.length === 0) return '';
const text = buffer.toString('utf8');
// Keep printable characters, spaces and newlines; drop other control
// characters (and the ESC sequences they may lead) so stray Ctrl+C /
// carriage-return / escape bytes don't corrupt the composer.
/* eslint-disable no-control-regex -- stripping C0 control bytes is the point. */
return (
text
// Full ECMA-48 CSI production (parameter bytes incl. ':' for kitty
// CSI-u, intermediate bytes, any final @-~): arrows, editing keys
// like Delete/Home/PgDn ('~' final), function keys, modifier forms.
.replace(/\u001B\[[0-9:;<=>?]*[ -/]*[@-~]/g, '')
// SS3 function-key sequences (F1-F4 etc., ESC O + final) survive the
// capture filter as user input (classifyEscapeSequence preserves them);
// strip the whole sequence here or the C0 pass below removes the bare
// ESC and leaks the O+letter payload into the composer.
.replace(/\u001BO[A-Za-z]/g, '')
// A replayed partial tail (ESC [ parameters without a final byte,
// or ESC O without the SS3 final byte) would leak '[…' or a bare
// 'O' into the composer once the C0 pass drops the ESC.
.replace(/\u001B\[[0-9:;<=>?]*[ -/]*$/, '')
.replace(/\u001BO$/, '')
.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '')
);
/* eslint-enable no-control-regex */
}
/**
* Drains the early-capture buffer exactly once and decodes it to text.
* Returns an empty string when nothing was captured.
*/
export function drainCapturedInputAsText(): string {
return decodeCapturedInput(stopAndGetCapturedInput());
}
/**
* Injects captured startup input into a composer handle once it is attached.
* The composer may not exist on the very first effect tick (the input prompt
* attaches its handle in an effect), so this polls briefly until the handle
* is present or the attempt budget is exhausted. Returns a disposer.
*/
export function injectCapturedInput(
getText: () => { setText: (t: string) => void } | null,
text: string,
opts: {
intervalMs?: number;
maxAttempts?: number;
setTimeoutFn?: (fn: () => void, ms: number) => unknown;
clearTimeoutFn?: (handle: unknown) => void;
} = {},
): () => void {
if (text.length === 0) return () => {};
const intervalMs = opts.intervalMs ?? 25;
const maxAttempts = opts.maxAttempts ?? 40;
const setTimeoutFn =
opts.setTimeoutFn ??
((fn: () => void, ms: number): unknown => setTimeout(fn, ms));
const clearTimeoutFn =
opts.clearTimeoutFn ??
((handle: unknown): void =>
clearTimeout(handle as ReturnType<typeof setTimeout>));
let attempts = 0;
let timer: unknown = null;
let disposed = false;
const attempt = () => {
if (disposed) return;
const handle = getText();
if (handle) {
handle.setText(text);
return;
}
attempts += 1;
if (attempts >= maxAttempts) return;
timer = setTimeoutFn(attempt, intervalMs);
};
// Run the first attempt asynchronously so the composer's own mount effect
// (which attaches the handle) gets a chance to run first.
timer = setTimeoutFn(attempt, intervalMs);
return () => {
disposed = true;
if (timer !== null) clearTimeoutFn(timer);
};
}
@@ -0,0 +1,753 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { createEventMapper, renderResultDisplay } from './event-adapter.js';
type AnyEv = Parameters<ReturnType<typeof createEventMapper>>[0];
describe('event-adapter (ServerGeminiStreamEvent -> neutral)', () => {
it('maps content to text delta', () => {
const map = createEventMapper();
expect(
map({ type: 'content', value: 'hello' } as unknown as AnyEv),
).toEqual([{ type: 'text', delta: 'hello' }]);
});
it('maps content inlineData parts to image events', () => {
const map = createEventMapper();
expect(
map({
type: 'content',
value: '',
parts: [
{ text: 'look:' },
{ inlineData: { mimeType: 'image/png', data: 'aW1hZ2U=' } },
],
} as unknown as AnyEv),
).toEqual([
{ type: 'text', delta: 'look:' },
{ type: 'image', mimeType: 'image/png', data: 'aW1hZ2U=' },
]);
});
it('closes thought before first content', () => {
const map = createEventMapper();
expect(
map({
type: 'thought',
value: { description: 'planning' },
} as unknown as AnyEv),
).toEqual([{ type: 'thinking', delta: 'planning' }]);
expect(
map({ type: 'content', value: 'answer' } as unknown as AnyEv),
).toEqual([{ type: 'thinking-end' }, { type: 'text', delta: 'answer' }]);
});
it('maps tool request/response', () => {
const map = createEventMapper();
const s = map({
type: 'tool_call_request',
value: { callId: 'c1', name: 'shell' },
} as unknown as AnyEv);
expect(s[0].type).toBe('tool-start');
expect(
map({
type: 'tool_call_response',
value: { callId: 'c1', error: undefined },
} as unknown as AnyEv),
).toEqual([{ type: 'tool-end', id: 'c1', success: true, summary: 'ok' }]);
});
it('carries FileDiff resultDisplay as a structured diff payload', () => {
const map = createEventMapper();
const fileDiff = '@@ -1,1 +1,1 @@\n-old\n+new';
const out = map({
type: 'tool_call_response',
value: {
callId: 'c1',
resultDisplay: { fileDiff, fileName: 'a.txt' },
},
} as unknown as AnyEv);
expect(out).toEqual([
{
type: 'tool-result',
id: 'c1',
display: '',
diff: { fileDiff, fileName: 'a.txt' },
},
{ type: 'tool-end', id: 'c1', success: true, summary: 'ok' },
]);
});
it('carries TodoWrite resultDisplay as a structured todos payload', () => {
const map = createEventMapper();
const out = map({
type: 'tool_call_response',
value: {
callId: 'c1',
resultDisplay: {
type: 'todo_list',
todos: [
{ id: 'a', content: 'A', status: 'in_progress' },
{ id: 'b', content: 'B', status: 'pending' },
{ id: 'c', content: 'C', status: 'completed' },
],
},
},
} as unknown as AnyEv);
expect(out[0]).toEqual({
type: 'tool-result',
id: 'c1',
display: '',
todos: [
{ id: 'a', content: 'A', status: 'in_progress' },
{ id: 'b', content: 'B', status: 'pending' },
{ id: 'c', content: 'C', status: 'completed' },
],
});
});
it('drops malformed todo entries but keeps valid ones', () => {
const map = createEventMapper();
const out = map({
type: 'tool_call_response',
value: {
callId: 'c1',
resultDisplay: {
type: 'todo_list',
todos: [
{ id: 'a', content: 'A', status: 'pending' },
{ content: 'no id' },
'garbage',
],
},
},
} as unknown as AnyEv);
expect(out[0]).toEqual({
type: 'tool-result',
id: 'c1',
display: '',
todos: [{ id: 'a', content: 'A', status: 'pending' }],
});
});
it('carries AnsiOutputDisplay as a structured token grid', () => {
const map = createEventMapper();
const grid = [
[
{
text: 'ok',
bold: true,
italic: false,
underline: false,
dim: false,
inverse: false,
fg: '#00FF00',
bg: '',
},
],
[],
];
const out = map({
type: 'tool_call_response',
value: {
callId: 'c1',
resultDisplay: {
ansiOutput: [...grid, ['nope' as unknown as object]],
totalLines: 30,
totalBytes: 4096,
},
},
} as unknown as AnyEv);
expect(out[0]).toEqual({
type: 'tool-result',
id: 'c1',
display: '',
ansi: {
grid: [grid[0], grid[1], []],
totalLines: 30,
totalBytes: 4096,
},
});
});
describe('finished (premature-done fix)', () => {
it('maps finished(STOP) to a segment marker, not done', () => {
const map = createEventMapper();
expect(
map({
type: 'finished',
value: { reason: 'STOP' },
} as unknown as AnyEv),
).toEqual([{ type: 'retry-countdown-clear' }, { type: 'segment-end' }]);
});
it('maps finished without reason to a bare segment marker', () => {
const map = createEventMapper();
expect(map({ type: 'finished', value: {} } as unknown as AnyEv)).toEqual([
{ type: 'retry-countdown-clear' },
{ type: 'segment-end' },
]);
});
it('warns on non-STOP finish reasons (ink truncation copy)', () => {
const map = createEventMapper();
const out = map({
type: 'finished',
value: { reason: 'MAX_TOKENS' },
} as unknown as AnyEv);
expect(out).toContainEqual({ type: 'segment-end' });
expect(out).toContainEqual({
type: 'info',
text: '⚠ Response truncated due to token limits.',
});
});
it('warns on safety finish reasons', () => {
const map = createEventMapper();
const out = map({
type: 'finished',
value: { reason: 'IMAGE_SAFETY' },
} as unknown as AnyEv);
expect(out).toContainEqual({
type: 'info',
text: '⚠ Response stopped due to image safety violations.',
});
});
});
describe('error events', () => {
it('falls back to the raw message without a formatter', () => {
const map = createEventMapper();
const out = map({
type: 'error',
value: { error: { message: 'boom' } },
} as unknown as AnyEv);
expect(out).toEqual([
{ type: 'retry-countdown-clear' },
{
type: 'error',
text: 'boom',
hint: 'Press Ctrl+Y to retry',
},
]);
});
it('uses the context formatError (parseAndFormatApiError seam)', () => {
const map = createEventMapper({
formatError: () => '[API Error: 429]',
});
const out = map({
type: 'error',
value: { error: { message: 'quota', status: 429 } },
} as unknown as AnyEv);
expect(out).toEqual([
{ type: 'retry-countdown-clear' },
{
type: 'error',
text: '[API Error: 429]',
hint: 'Press Ctrl+Y to retry',
},
]);
});
});
describe('previously dropped core events', () => {
// ink parity: useGeminiStream's handleChatCompressionEvent adds a
// `type: 'info'` history item (InfoMessage row) for auto-compact.
it('maps chat_compressed to an info notice with token counts', () => {
const map = createEventMapper({ getModelName: () => 'qwen3-max' });
const out = map({
type: 'chat_compressed',
value: { originalTokenCount: 1200, newTokenCount: 300 },
} as unknown as AnyEv);
expect(out).toEqual([
{ type: 'retry-countdown-clear' },
{
type: 'info',
text:
'IMPORTANT: This conversation approached the input token limit for qwen3-max. ' +
'A compressed context will be sent for future messages (compressed from: ' +
'1200 to 300 tokens).',
},
]);
});
it('labels image-overflow compaction triggers', () => {
const map = createEventMapper({ getModelName: () => 'm' });
const out = map({
type: 'chat_compressed',
value: {
originalTokenCount: 10,
newTokenCount: 5,
triggerReason: 'image_overflow',
warning: 'screenshots dropped',
},
} as unknown as AnyEv);
expect(out).toEqual([
{ type: 'retry-countdown-clear' },
{
type: 'info',
text: expect.stringContaining(
'accumulated enough tool screenshots to trigger compaction for m',
),
},
]);
expect((out[1] as { text: string }).text).toContain(
'\n⚠️ screenshots dropped',
);
});
it('maps retry with retryInfo to a structured countdown event', () => {
const map = createEventMapper();
const skipDelay = () => {};
const out = map({
type: 'retry',
retryInfo: {
attempt: 2,
maxRetries: 3,
delayMs: 4200,
message: 'rate limited',
skipDelay,
},
} as unknown as AnyEv);
expect(out).toEqual([
{
type: 'retry-countdown',
attempt: 2,
maxRetries: 3,
delayMs: 4200,
message: 'rate limited',
skipDelay,
isContinuation: undefined,
},
]);
});
it('maps a continuation retry, passing isContinuation through', () => {
const map = createEventMapper();
const out = map({
type: 'retry',
retryInfo: { attempt: 1, maxRetries: 3, delayMs: 5000 },
isContinuation: true,
} as unknown as AnyEv);
expect(out).toEqual([
{
type: 'retry-countdown',
attempt: 1,
maxRetries: 3,
delayMs: 5000,
message: undefined,
skipDelay: undefined,
isContinuation: true,
},
]);
});
it('maps retry without retryInfo to a countdown clear (ink parity)', () => {
const map = createEventMapper();
expect(map({ type: 'retry' } as unknown as AnyEv)).toEqual([
{ type: 'retry-countdown-clear' },
]);
});
it('forwards isContinuation on the countdown clear (R2-50)', () => {
// Core's continuation/recovery retries carry isContinuation without
// retryInfo; the backend keys keep-vs-discard on it like ink does.
const map = createEventMapper();
expect(
map({ type: 'retry', isContinuation: true } as unknown as AnyEv),
).toEqual([{ type: 'retry-countdown-clear', isContinuation: true }]);
});
it('marks estimated token counts with ~ (R2-3, ink formatCount parity)', () => {
const map = createEventMapper({ getModelName: () => 'm' });
const out = map({
type: 'chat_compressed',
value: {
originalTokenCount: 1200,
newTokenCount: 300,
newTokenCountIsEstimated: true,
},
} as unknown as AnyEv);
expect((out[1] as { text: string }).text).toContain('~300');
expect((out[1] as { text: string }).text).not.toContain('~1200');
});
it('maps model_fallback to a retry clear + info notice', () => {
const map = createEventMapper();
const out = map({
type: 'model_fallback',
fromModel: 'qwen3-coder',
toModel: 'qwen3-max',
} as unknown as AnyEv);
expect(out).toEqual([
{ type: 'retry-countdown-clear' },
{
type: 'info',
text: 'Model qwen3-coder unavailable, falling back to qwen3-max',
},
]);
});
it('maps session_token_limit_exceeded to error + solutions', () => {
const map = createEventMapper();
const out = map({
type: 'session_token_limit_exceeded',
value: { currentTokens: 130000, limit: 128000, message: '' },
} as unknown as AnyEv);
expect(out).toHaveLength(1);
const item = out[0] as { type: string; text: string };
expect(item.type).toBe('error');
expect(item.text).toContain('✗ Session token limit exceeded:');
expect(item.text).toContain('Use /clear command');
expect(item.text).toContain('"sessionTokenLimit"');
expect(item.text).toContain('Use /compress command');
});
it('maps max_session_turns with the configured limit', () => {
const map = createEventMapper({ getMaxSessionTurns: () => 42 });
const out = map({ type: 'max_session_turns' } as unknown as AnyEv);
expect(out).toEqual([
{
type: 'info',
text:
'The session has reached the maximum number of turns: 42. ' +
'Please update this limit in your setting.json file.',
},
]);
});
it('maps loop_detected to the halt warning text', () => {
const map = createEventMapper();
const out = map({ type: 'loop_detected' } as unknown as AnyEv);
expect(out).toEqual([
{
type: 'info',
text:
'A potential loop was detected. This can happen due to repetitive ' +
'tool calls or other model behavior. The request has been halted.',
},
]);
});
it('maps user_cancelled to a retry clear + info notice', () => {
const map = createEventMapper();
const out = map({ type: 'user_cancelled' } as unknown as AnyEv);
expect(out).toEqual([
{ type: 'retry-countdown-clear' },
{ type: 'info', text: 'User cancelled the request.' },
]);
});
it('maps citation to an info text (no citation surface yet)', () => {
const map = createEventMapper();
const out = map({
type: 'citation',
value: 'Sources: [1] https://example.com',
} as unknown as AnyEv);
expect(out).toEqual([
{ type: 'info', text: 'Sources: [1] https://example.com' },
]);
});
it('maps hook_system_message to a stop-hook markdown message', () => {
const map = createEventMapper();
const out = map({
type: 'hook_system_message',
value: 'run the tests',
} as unknown as AnyEv);
expect(out).toEqual([
{ type: 'stop-hook-message', message: 'run the tests' },
]);
});
it('maps user_prompt_submit_blocked to reason + original prompt', () => {
const map = createEventMapper();
const out = map({
type: 'user_prompt_submit_blocked',
value: { reason: 'blocked by policy', originalPrompt: 'do it' },
} as unknown as AnyEv);
expect(out).toEqual([
{
type: 'warning',
text:
'✕ UserPromptSubmit operation blocked by hook:\nblocked by policy\n\n' +
'Original prompt: do it',
},
]);
});
it('redacts the echoed prompt through sanitizeSensitiveText (R1-79)', () => {
const map = createEventMapper();
const out = map({
type: 'user_prompt_submit_blocked',
value: {
reason: 'blocked by policy',
originalPrompt: 'sk-abcdefghijklmnopqrstuvw run the deploy',
},
} as unknown as AnyEv);
expect(out[0]).toMatchObject({ type: 'warning' });
const text = (out[0] as { text: string }).text;
expect(text).not.toContain('sk-abcdefghijklmnopqrstuvw');
});
it('truncates the echoed prompt at 200 chars (R1-79)', () => {
const map = createEventMapper();
const long = 'x'.repeat(300);
const out = map({
type: 'user_prompt_submit_blocked',
value: { reason: 'blocked', originalPrompt: long },
} as unknown as AnyEv);
const text = (out[0] as { text: string }).text;
const echoed = text.slice(
text.lastIndexOf('Original prompt: ') + 'Original prompt: '.length,
);
expect(echoed.length).toBe(200);
expect(echoed.endsWith('...')).toBe(true);
});
it('maps stop_hook_loop to the hook error text', () => {
const map = createEventMapper();
const out = map({
type: 'stop_hook_loop',
value: {
iterationCount: 3,
reasons: ['first', 'last failure'],
stopHookCount: 2,
},
} as unknown as AnyEv);
expect(out).toEqual([
{
type: 'info',
text: 'Ran 2 stop hooks\n ⎿ Stop hook error: last failure',
},
]);
});
});
describe('goal events', () => {
// ink parity: addItem({type: 'goal_state', snapshot, cause}) renders via
// GoalStatusMessage (GoalStateCard) — the adapter passes the snapshot
// through untouched.
it('maps goal_state with a displayable cause to a goal event', () => {
const map = createEventMapper();
const snapshot = {
goal: {
objective: 'ship it',
status: 'active',
turnCount: 2,
},
activity: 'running',
};
const out = map({
type: 'goal_state',
value: snapshot,
cause: 'create',
} as unknown as AnyEv);
expect(out).toEqual([{ type: 'goal', snapshot, cause: 'create' }]);
});
it('stays silent for non-displayable causes', () => {
const map = createEventMapper();
expect(
map({
type: 'goal_state',
value: {
goal: { objective: 'ship it', status: 'active', turnCount: 3 },
activity: 'running',
},
cause: 'turn_finished',
} as unknown as AnyEv),
).toEqual([]);
});
it('stays silent when cause is missing (ink parity)', () => {
const map = createEventMapper();
expect(
map({
type: 'goal_state',
value: { goal: { objective: 'ship it', status: 'active' } },
} as unknown as AnyEv),
).toEqual([]);
});
it('does not dedupe consecutive displayable snapshots (ink parity)', () => {
const map = createEventMapper();
const value = { goal: { objective: 'ship it', status: 'active' } };
const first = map({
type: 'goal_state',
value,
cause: 'create',
} as unknown as AnyEv);
const again = map({
type: 'goal_state',
value,
cause: 'resume',
} as unknown as AnyEv);
expect(first).toHaveLength(1);
expect(again).toHaveLength(1);
});
it('maps goal cleared (null goal + clear cause)', () => {
const map = createEventMapper();
const out = map({
type: 'goal_state',
value: { goal: null },
cause: 'clear',
} as unknown as AnyEv);
expect(out).toEqual([
{ type: 'goal', snapshot: { goal: null }, cause: 'clear' },
]);
});
it('ignores the legacy active_goal projection (ink parity)', () => {
const map = createEventMapper();
expect(
map({
type: 'active_goal',
value: { condition: 'all tests green', iterations: 1 },
} as unknown as AnyEv),
).toEqual([]);
});
});
it('stringifies AnsiOutputDisplay live shell output', () => {
expect(
renderResultDisplay({
ansiOutput: [
[{ text: 'hello ' }, { text: 'world' }],
[{ text: 'line2' }],
],
}),
).toBe('hello world\nline2');
});
describe('citation visibility gate (R1-20)', () => {
it('suppresses citations when showCitations() is false', () => {
const map = createEventMapper({ showCitations: () => false });
expect(
map({
type: 'citation',
value: 'Sources: [1] https://example.com',
} as unknown as AnyEv),
).toEqual([]);
});
it('still shows citations when showCitations() is true', () => {
const map = createEventMapper({ showCitations: () => true });
expect(
map({
type: 'citation',
value: 'Sources: [1] https://example.com',
} as unknown as AnyEv),
).toEqual([{ type: 'info', text: 'Sources: [1] https://example.com' }]);
});
});
describe('renderResultDisplay structured displays (R1-66/68)', () => {
it('renders plan_summary as message + plan', () => {
expect(
renderResultDisplay({
type: 'plan_summary',
message: 'User approved.',
plan: 'step 1',
}),
).toBe('User approved.\nstep 1');
});
it('renders nothing for team_result and task_list', () => {
expect(renderResultDisplay({ type: 'team_result', summary: 'x' })).toBe(
'',
);
expect(renderResultDisplay({ type: 'task_list', message: 'y' })).toBe('');
});
it('renders vision_bridge_notice as summary + notice (R2-39)', () => {
expect(
renderResultDisplay({
type: 'vision_bridge_notice',
summary: 'S',
notice: 'N',
}),
).toBe('S\nN');
});
it('renders task_execution without dumping toolCalls payloads (R1-68)', () => {
expect(
renderResultDisplay({
type: 'task_execution',
subagentName: 'reviewer',
status: 'completed',
terminateReason: 'done',
result: 'all good',
toolCalls: [
{
callId: 'c1',
name: 'read-file',
status: 'success',
responseParts: [{ inlineData: { data: 'a'.repeat(100) } }],
},
],
}),
).toBe('reviewer: completed\ndone\nall good');
});
it('renders findings_list as a count summary (R1-68)', () => {
expect(
renderResultDisplay({
type: 'findings_list',
level: 'high',
findings: [{}, {}, {}],
}),
).toBe('3 finding(s) (high)');
expect(
renderResultDisplay({
type: 'findings_list',
findings: [{}],
omittedFindings: 2,
}),
).toBe('1 finding(s)\n2 additional finding(s) were omitted.');
});
it('renders terminal_image as a file-path note (R1-68)', () => {
expect(
renderResultDisplay({
type: 'terminal_image',
filePath: '/tmp/chart.png',
mimeType: 'image/png',
}),
).toBe('[terminal image] /tmp/chart.png');
});
it('renders mcp_tool_progress with the ink spinner line', () => {
expect(
renderResultDisplay({
type: 'mcp_tool_progress',
progress: 5,
total: 10,
}),
).toBe('◌ [5/10] Progress: 5');
expect(
renderResultDisplay({
type: 'mcp_tool_progress',
progress: 2,
message: 'working',
}),
).toBe('◌ [2] working');
});
it('renders mcp_app with its fallbackText only', () => {
expect(
renderResultDisplay({
type: 'mcp_app',
html: '<b>must not leak</b>',
fallbackText: 'app fallback',
}),
).toBe('app fallback');
});
});
});
@@ -0,0 +1,748 @@
/** @jsxImportSource @opentui/react */
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* P1d integration seam: maps qwen-code's real agent-loop stream events
* (`ServerGeminiStreamEvent`, packages/core) onto the framework-neutral
* `StreamEvent` consumed by the OpenTUI backend / `ui/model/streaming-model`.
*
* Pure + framework-agnostic (no UI-framework imports); unit-testable without
* a renderer. The OpenTUI backend drains these into the neutral model; the
* ink path keeps using `useGeminiStream` unchanged.
*
* Lossless tool mapping: tool args, result content (resultDisplay) and
* confirmation requests are carried through as the `tool-args` / `tool-result`
* / `confirm` events (the neutral model's union is extended locally because
* this slice may only touch opentui/**).
*/
import type {
AnsiToken,
ChatCompressionInfo,
GoalStateCause,
RetryInfo,
ServerGeminiStreamEvent,
} from '@qwen-code/qwen-code-core';
import type { StreamEvent } from '../model/streaming-model.js';
import type { TodoItem } from '../components/TodoDisplay.js';
import type { CompressionProps } from '../types.js';
import { sanitizeSensitiveText } from '../utils/textUtils.js';
import { sanitizeDisplayText } from '../../utils/extension-mention.js';
import { shouldDisplayGoalStateCause } from '../utils/goal-runtime.js';
/**
* Neutral-model union extension: tool detail events the backend folds into
* tool cards (args preview, result content, approval state), plus turn
* segmentation and inline images.
*/
export type OpenTuiStreamEvent =
| StreamEvent
| { type: 'tool-args'; id: string; args: string }
/** Real invocation description (live sessions only): the scheduler's
* tracked call carries the invocation object, so the card title is the
* tool's own `getDescription()` (ink mapToDisplay parity) instead of a
* hand-rolled args guess. Yields after `tool-start` once the scheduler
* builds the invocation. */
| { type: 'tool-description'; id: string; description: string }
| {
type: 'tool-result';
id: string;
display: string;
/** Structured FileDiff payload: rendered as colored diff lines in the
* tool card instead of the flattened `display` text (ink
* DiffResultRenderer parity). */
diff?: { fileDiff: string; fileName: string };
/** Structured TodoWrite payload: rendered as a status-icon list in the
* tool card (ink TodoDisplay parity) instead of the flattened text. */
todos?: TodoItem[];
/** Structured AnsiOutputDisplay payload: rendered as a styled token
* grid in the tool card (ink AnsiOutputText parity) instead of the
* flattened, color-stripped text. */
ansi?: {
grid: AnsiToken[][];
totalLines?: number;
totalBytes?: number;
};
/** Vision-bridge egress disclosure (ink ToolMessage renders the notice
* under the result): tells the user their image/prompt left the
* machine via the vision model. */
visionBridgeNotice?: string;
}
| { type: 'confirm'; id: string; tool: string; title: string }
/** Structured compression item (/compress command): rendered as the ink
* CompressionMessage row (spinner/diamond + token counts) instead of the
* flattened text projection. */
| { type: 'compaction'; compression: CompressionProps }
/** Info notice row (ink `addItem({type: INFO})` → InfoMessage): `●` prefix
* + primary-colored text, e.g. the auto-compact `chat_compressed` notice. */
| { type: 'info'; text: string }
/** Error notice row (ink `type: 'error'` → ErrorMessage): `✕` prefix +
* error-colored text with an optional inline hint. */
| { type: 'error'; text: string; hint?: string }
/** Warning block (ink user_prompt_submit_blocked): no prefix, whole block
* in the warning color. */
| { type: 'warning'; text: string }
/** Retry countdown (ink startRetryCountdown): drives the two pending
* rows — the retry error line and the `↻` countdown — updated every
* second until the delay elapses. `message` mirrors RetryInfo.message;
* `skipDelay` resolves the core delay promise early (Ctrl+Y, ink
* skipRetryDelayRef); `isContinuation` keeps the failed attempt's
* streamed content instead of discarding it (ink continuation retries). */
| {
type: 'retry-countdown';
attempt: number;
maxRetries: number;
delayMs: number;
message?: string;
skipDelay?: () => void;
isContinuation?: boolean;
}
/** Retry without retryInfo: the attempt is starting now, so any prior
* retry UI is stale (ink clearRetryCountdown). `isContinuation` carries
* the keep/discard signal core's continuation retries set without a
* retryInfo, so the backend keeps already-streamed text like ink does. */
| { type: 'retry-countdown-clear'; isContinuation?: boolean }
/** Stop-hook system message (ink stop_hook_system_message):
* `⎿ Stop says:` header + indented markdown body. */
| { type: 'stop-hook-message'; message: string }
/** Goal lifecycle card (ink goal_state → GoalStatusMessage/GoalStateCard):
* carries the v2 snapshot + display cause. */
| { type: 'goal'; snapshot: GoalSnapshotLike; cause?: string }
/** Legacy goal card (ink goal_status → GoalStatusMessage kind form, the
* /goal command path): carried structurally instead of the text
* projection so the renderer can apply lifecycle colors. */
| {
type: 'goal-legacy';
kind: string;
condition: string;
iterations?: number;
durationMs?: number;
lastReason?: string;
}
/**
* Turn segmentation marker (core `finished` / one-shot notices): closes
* the streaming assistant block WITHOUT settling tool cards or dropping
* the streaming state. `done` remains the only turn-end event.
*/
| { type: 'segment-end' }
/** Inline image from model content (`inlineData` part). */
| { type: 'image'; mimeType: string; data: string };
/**
* Optional runtime context for notices that need config-derived values.
* All fields are optional so the mapper stays usable without a Config
* (scripted streams, tests).
*/
export interface EventMapperContext {
/**
* Formats an `error` event payload for display (ink parity:
* parseAndFormatApiError + auth-type hints). Falls back to the raw
* error message when absent.
*/
formatError?: (error: unknown) => string;
/** Active model name for the chat-compression notice (ink parity:
* `modelOverrideRef.current ?? config.getModel()`). */
getModelName?: () => string;
/** Configured max session turns for the MaxSessionTurns notice. */
getMaxSessionTurns?: () => number;
/**
* ink parity of the `showCitations(settings)` gate in
* handleCitationEvent; absent means citations are shown.
*/
showCitations?: () => boolean;
}
/** One-line compact JSON for tool-call args (empty object → undefined). */
export function formatToolArgs(
args: Record<string, unknown> | undefined,
): string | undefined {
if (!args || Object.keys(args).length === 0) return undefined;
return JSON.stringify(args);
}
/** Narrows a ToolResultDisplay to its FileDiff shape, if it is one. */
export function extractFileDiff(
display: unknown,
): { fileDiff: string; fileName: string } | null {
if (typeof display !== 'object' || display === null) return null;
const o = display as Record<string, unknown>;
if (typeof o['fileDiff'] !== 'string') return null;
return {
fileDiff: o['fileDiff'],
fileName: typeof o['fileName'] === 'string' ? o['fileName'] : '',
};
}
/** Extracts the structured TodoWrite payload (`type: 'todo_list'`). */
export function extractTodos(display: unknown): TodoItem[] | null {
if (typeof display !== 'object' || display === null) return null;
const o = display as Record<string, unknown>;
if (o['type'] !== 'todo_list' || !Array.isArray(o['todos'])) return null;
return (o['todos'] as unknown[]).filter(
(t): t is TodoItem =>
typeof t === 'object' &&
t !== null &&
typeof (t as TodoItem).id === 'string' &&
typeof (t as TodoItem).content === 'string' &&
typeof (t as TodoItem).status === 'string',
);
}
/** Extracts the AnsiOutputDisplay token grid (live shell output). */
export function extractAnsiOutput(
display: unknown,
): { grid: AnsiToken[][]; totalLines?: number; totalBytes?: number } | null {
if (typeof display !== 'object' || display === null) return null;
const o = display as Record<string, unknown>;
if (!Array.isArray(o['ansiOutput'])) return null;
const grid = (o['ansiOutput'] as unknown[])
.filter((line): line is unknown[] => Array.isArray(line))
.map((line) =>
line.filter(
(t): t is AnsiToken =>
typeof t === 'object' &&
t !== null &&
typeof (t as AnsiToken).text === 'string',
),
);
const totalLines =
typeof o['totalLines'] === 'number' ? o['totalLines'] : undefined;
const totalBytes =
typeof o['totalBytes'] === 'number' ? o['totalBytes'] : undefined;
return { grid, totalLines, totalBytes };
}
/** Stringifies a ToolResultDisplay (string | FileDiff | structured) losslessly. */
export function renderResultDisplay(display: unknown): string {
if (display == null) return '';
if (typeof display === 'string') return display;
if (typeof display === 'object') {
const o = display as Record<string, unknown>;
if (typeof o['fileDiff'] === 'string') {
const name =
typeof o['fileName'] === 'string' && o['fileName']
? `${o['fileName']}\n`
: '';
return name + o['fileDiff'];
}
// AnsiOutputDisplay (live shell output): flatten the token grid to text.
if (Array.isArray(o['ansiOutput'])) {
return (o['ansiOutput'] as Array<Array<{ text?: string }>>)
.map((line) => line.map((t) => t.text ?? '').join(''))
.join('\n');
}
// Structured displays ink's classifyDisplay handles individually.
if (o['type'] === 'plan_summary') {
const message = typeof o['message'] === 'string' ? o['message'] : '';
const plan = typeof o['plan'] === 'string' ? o['plan'] : '';
return [message, plan].filter(Boolean).join('\n');
}
// team_result/task_list are covered by their tools' returnDisplay text;
// ink renders nothing for the structured object (classifyDisplay none).
if (o['type'] === 'team_result' || o['type'] === 'task_list') {
return '';
}
if (o['type'] === 'mcp_tool_progress') {
const msg =
typeof o['message'] === 'string'
? o['message']
: `Progress: ${o['progress']}`;
const totalStr = o['total'] != null ? `/${o['total']}` : '';
return `◌ [${o['progress']}${totalStr}] ${msg}`;
}
// mcp_app renders only its fallbackText in ink — the embedded HTML must
// never reach output, including the (currently unreachable) case where
// the field is absent: the JSON dump would expose the raw HTML.
if (o['type'] === 'mcp_app') {
return typeof o['fallbackText'] === 'string' ? o['fallbackText'] : '';
}
// vision_bridge_notice renders summary\nnotice (ink's
// formatVisionBridgeNoticeDisplay); the generic summary branch below
// would drop the notice body.
if (o['type'] === 'vision_bridge_notice') {
const summary = typeof o['summary'] === 'string' ? o['summary'] : '';
const notice = typeof o['notice'] === 'string' ? o['notice'] : '';
return [summary, notice].filter(Boolean).join('\n');
}
// task_execution: status line + termination reason and result — the raw
// JSON fallback would dump toolCalls[].responseParts (multi-MB base64);
// core strips those exact fields in toolResultDisplayCompaction.
if (o['type'] === 'task_execution') {
const subagent =
typeof o['subagentName'] === 'string' ? o['subagentName'] : '';
const status = typeof o['status'] === 'string' ? o['status'] : '';
const reason =
typeof o['terminateReason'] === 'string' ? o['terminateReason'] : '';
const result = typeof o['result'] === 'string' ? o['result'] : '';
const header = [subagent, status].filter(Boolean).join(': ');
return [header, reason, result].filter(Boolean).join('\n');
}
// findings_list: count + optional severity summary; the raw fallback
// dumps every finding object.
if (o['type'] === 'findings_list') {
const findings = Array.isArray(o['findings'])
? (o['findings'] as unknown[])
: [];
const level = typeof o['level'] === 'string' ? ` (${o['level']})` : '';
const omitted =
typeof o['omittedFindings'] === 'number' && o['omittedFindings'] > 0
? `\n${o['omittedFindings']} additional finding(s) were omitted.`
: '';
return `${findings.length} finding(s)${level}${omitted}`;
}
// terminal_image: file-path note instead of the multi-MB binary payload
// (ink renders the image inline; transcripts keep the path reference).
if (o['type'] === 'terminal_image') {
const filePath = typeof o['filePath'] === 'string' ? o['filePath'] : '';
return filePath ? `[terminal image] ${filePath}` : '';
}
if (typeof o['summary'] === 'string') return o['summary'];
if (typeof o['message'] === 'string') return o['message'];
}
return JSON.stringify(display, null, 2);
}
/**
* Non-STOP finish reasons → user-facing notice (ink useGeminiStream
* handleFinishedEvent parity; FINISH_REASON_UNSPECIFIED and STOP are
* silent).
*/
const FINISH_REASON_NOTICES: Record<string, string | undefined> = {
MAX_TOKENS: 'Response truncated due to token limits.',
SAFETY: 'Response stopped due to safety reasons.',
RECITATION: 'Response stopped due to recitation policy.',
LANGUAGE: 'Response stopped due to unsupported language.',
BLOCKLIST: 'Response stopped due to forbidden terms.',
PROHIBITED_CONTENT: 'Response stopped due to prohibited content.',
SPII: 'Response stopped due to sensitive personally identifiable information.',
OTHER: 'Response stopped for other reasons.',
MALFORMED_FUNCTION_CALL: 'Response stopped due to malformed function call.',
IMAGE_SAFETY: 'Response stopped due to image safety violations.',
IMAGE_PROHIBITED_CONTENT: 'Response stopped due to image prohibited content.',
IMAGE_RECITATION: 'Response stopped due to image recitation policy.',
IMAGE_OTHER: 'Response stopped due to other image-related reasons.',
NO_IMAGE: 'Response stopped due to no image.',
UNEXPECTED_TOOL_CALL: 'Response stopped due to unexpected tool call.',
};
/**
* Stateful mapper: one server event may yield 0..n neutral events. Tracks the
* thinking→content transition so the model collapses the thought block before
* the answer starts streaming.
*/
export function createEventMapper(
context?: EventMapperContext,
): (ev: ServerGeminiStreamEvent) => OpenTuiStreamEvent[] {
let sawThought = false;
let thoughtClosed = false;
let toolSeq = 0;
return (ev: ServerGeminiStreamEvent): OpenTuiStreamEvent[] => {
const out: OpenTuiStreamEvent[] = [];
const closeThought = () => {
if (sawThought && !thoughtClosed) {
out.push({ type: 'thinking-end' });
thoughtClosed = true;
}
};
switch (ev.type) {
case 'thought': {
const v = ev.value as { subject?: string; description?: string };
const delta = v.description ?? '';
if (delta) {
sawThought = true;
thoughtClosed = false;
out.push({ type: 'thinking', delta });
}
break;
}
case 'content': {
closeThought();
const parts = (
ev as {
parts?: Array<{
text?: string;
inlineData?: { data?: string; mimeType?: string };
}>;
}
).parts;
if (parts) {
for (const p of parts) {
if (p.text && p.text.length > 0) {
out.push({ type: 'text', delta: p.text });
} else if (p.inlineData?.data) {
out.push({
type: 'image',
mimeType: p.inlineData.mimeType ?? 'image/png',
data: p.inlineData.data,
});
}
}
} else {
const value = ev.value as string;
if (value) out.push({ type: 'text', delta: value });
}
break;
}
case 'tool_call_request': {
closeThought();
const v = ev.value as {
callId: string;
name: string;
args?: Record<string, unknown>;
};
const id = v.callId ?? `tool-${++toolSeq}`;
out.push({ type: 'tool-start', id, tool: v.name, title: v.name });
const args = formatToolArgs(v.args);
if (args) out.push({ type: 'tool-args', id, args });
break;
}
case 'tool_call_confirmation': {
closeThought();
const v = ev.value as {
request: {
callId: string;
name: string;
args?: Record<string, unknown>;
};
details: { title?: string };
};
const id = v.request.callId ?? `tool-${++toolSeq}`;
out.push({
type: 'confirm',
id,
tool: v.request.name,
title: v.details.title ?? v.request.name,
});
const args = formatToolArgs(v.request.args);
if (args) out.push({ type: 'tool-args', id, args });
break;
}
case 'tool_call_response': {
const v = ev.value as {
callId: string;
error?: unknown;
resultDisplay?: unknown;
executionStatus?: string;
visionBridgeNotice?: string;
};
// ink parity: the egress disclosure rides the tool card whenever a
// response bridged images (ToolMessage renders it under the result).
const visionBridgeNotice =
typeof v.visionBridgeNotice === 'string' && v.visionBridgeNotice
? v.visionBridgeNotice
: undefined;
const diff = extractFileDiff(v.resultDisplay);
if (diff) {
out.push({
type: 'tool-result',
id: v.callId,
display: '',
diff,
...(visionBridgeNotice ? { visionBridgeNotice } : {}),
});
} else {
const todos = extractTodos(v.resultDisplay);
if (todos) {
out.push({
type: 'tool-result',
id: v.callId,
display: '',
todos,
...(visionBridgeNotice ? { visionBridgeNotice } : {}),
});
} else {
const ansi = extractAnsiOutput(v.resultDisplay);
if (ansi) {
out.push({
type: 'tool-result',
id: v.callId,
display: '',
ansi,
...(visionBridgeNotice ? { visionBridgeNotice } : {}),
});
} else {
const display = renderResultDisplay(v.resultDisplay);
if (display)
out.push({
type: 'tool-result',
id: v.callId,
display,
...(visionBridgeNotice ? { visionBridgeNotice } : {}),
});
}
}
}
const cancelled = v.executionStatus === 'cancelled';
const failed = v.error !== undefined || v.executionStatus === 'error';
out.push({
type: 'tool-end',
id: v.callId,
success: !failed && !cancelled,
summary: failed ? 'error' : cancelled ? 'cancelled' : 'ok',
});
break;
}
case 'user_cancelled': {
closeThought();
// ink parity: handleUserCancelledEvent clears the retry countdown
// (stale after the cancel) before adding the info notice.
out.push({ type: 'retry-countdown-clear' });
out.push({ type: 'info', text: 'User cancelled the request.' });
break;
}
case 'error': {
closeThought();
// ink parity: handleErrorEvent clears the retry countdown
// unconditionally before adding the pending error item.
out.push({ type: 'retry-countdown-clear' });
// ink parity: handleErrorEvent sets a pending error item rendered by
// ErrorMessage (`✕` + error color) with the retry hint inline.
const v = ev.value as { error?: unknown };
const message = context?.formatError
? context.formatError(v.error)
: String(
(v.error as { message?: string } | undefined)?.message ?? '',
);
if (message)
out.push({
type: 'error',
text: message,
hint: 'Press Ctrl+Y to retry',
});
break;
}
case 'chat_compressed': {
// ink parity: useGeminiStream's handleChatCompressionEvent adds a
// `type: 'info'` history item (InfoMessage row) with this text; a
// pending retry countdown is stale once the context is swapped.
closeThought();
out.push({ type: 'retry-countdown-clear' });
const v = ev.value as ChatCompressionInfo | null;
const model = context?.getModelName?.() ?? 'the model';
const reasonClause =
v?.triggerReason === 'image_overflow'
? `accumulated enough tool screenshots to trigger compaction for ${model}`
: `approached the input token limit for ${model}`;
// ink's formatCount (useGeminiStream): estimated counts carry a '~'
// prefix so locally-measured figures don't read as API-reported ones.
const formatCount = (count?: number, isEstimated?: boolean) =>
count === undefined
? 'unknown'
: isEstimated
? `~${count}`
: String(count);
const warningSuffix = v?.warning ? `\n⚠️ ${v.warning}` : '';
out.push({
type: 'info',
text:
`IMPORTANT: This conversation ${reasonClause}. ` +
`A compressed context will be sent for future messages (compressed from: ` +
`${formatCount(v?.originalTokenCount, v?.originalTokenCountIsEstimated)} to ` +
`${formatCount(v?.newTokenCount, v?.newTokenCountIsEstimated)} tokens).` +
warningSuffix,
});
break;
}
case 'max_session_turns': {
closeThought();
// ink parity: handleMaxSessionTurnsEvent adds `{type: 'info'}`.
const turns = context?.getMaxSessionTurns?.();
out.push({
type: 'info',
text:
`The session has reached the maximum number of turns: ` +
`${turns ?? 'the configured limit'}. ` +
`Please update this limit in your setting.json file.`,
});
break;
}
case 'session_token_limit_exceeded': {
closeThought();
// ink parity: handleSessionTokenLimitExceededEvent adds `{type:
// 'error'}` with a `✗` glyph in the text.
const v = ev.value as { currentTokens: number; limit: number };
out.push({
type: 'error',
text:
`✗ Session token limit exceeded: ` +
`${v.currentTokens.toLocaleString()} tokens > ` +
`${v.limit.toLocaleString()} limit.\n\n` +
`★ Solutions:\n` +
` • Start a new session: Use /clear command\n` +
` • Increase limit: Add "sessionTokenLimit": (e.g., 128000) to your settings.json\n` +
` • Compress history: Use /compress command to compress history`,
});
break;
}
case 'loop_detected': {
closeThought();
// ink shows a disable/keep confirmation dialog; until that dialog
// exists here, surface the halt itself (the dialog's "keep" outcome,
// which ink adds as `{type: 'info'}`).
out.push({
type: 'info',
text:
'A potential loop was detected. This can happen due to repetitive ' +
'tool calls or other model behavior. The request has been halted.',
});
break;
}
case 'citation': {
closeThought();
// ink parity: handleCitationEvent adds `{type: 'info'}` (the core
// already builds the display string) but early-returns when the
// user disabled `ui.showCitations`.
if (context?.showCitations && !context.showCitations()) break;
const text = ev.value as string;
if (text) out.push({ type: 'info', text });
break;
}
case 'retry': {
closeThought();
// ink parity: retryInfo → startRetryCountdown (restarts the two
// pending rows every second); no retryInfo → clearRetryCountdown
// (the attempt is starting now, so any prior retry UI is stale).
const info = (ev as { retryInfo?: RetryInfo }).retryInfo;
if (info) {
out.push({
type: 'retry-countdown',
attempt: info.attempt,
maxRetries: info.maxRetries,
delayMs: info.delayMs,
message: info.message,
skipDelay: info.skipDelay,
isContinuation: (ev as { isContinuation?: boolean }).isContinuation,
});
} else {
out.push({
type: 'retry-countdown-clear',
isContinuation: (ev as { isContinuation?: boolean }).isContinuation,
});
}
break;
}
case 'model_fallback': {
closeThought();
// ink parity: the model_fallback branch clears the retry countdown
// (the retry chain died with the primary model) before the notice.
out.push({ type: 'retry-countdown-clear' });
const v = ev as { fromModel?: string; toModel?: string };
// ink parity: model names pass through sanitizeDisplayText before
// reaching the notice (useGeminiStream).
const fromModel = sanitizeDisplayText(v.fromModel ?? '') ?? '(unknown)';
const toModel = sanitizeDisplayText(v.toModel ?? '') ?? '(unknown)';
out.push({
type: 'info',
text: `Model ${fromModel} unavailable, falling back to ${toModel}`,
});
break;
}
case 'hook_system_message': {
closeThought();
// ink parity: stop_hook_system_message renders `⎿ Stop says:` +
// an indented markdown body.
out.push({ type: 'stop-hook-message', message: ev.value as string });
break;
}
case 'user_prompt_submit_blocked': {
closeThought();
const v = ev.value as { reason: string; originalPrompt: string };
out.push({
type: 'warning',
text:
`✕ UserPromptSubmit operation blocked by hook:\n${v.reason}\n\n` +
// ink redacts the echoed prompt (HistoryItemDisplay): sensitive
// patterns masked and the text capped at 200 chars.
`Original prompt: ${sanitizeSensitiveText(v.originalPrompt)}`,
});
break;
}
case 'stop_hook_loop': {
closeThought();
// ink parity: the stop_hook_loop item renders via InfoMessage.
const v = ev.value as {
reasons: string[];
stopHookCount: number;
};
out.push({
type: 'info',
text:
`Ran ${v.stopHookCount} stop hooks\n` +
` ⎿ Stop hook error: ${v.reasons[v.reasons.length - 1] ?? ''}`,
});
break;
}
case 'active_goal':
// ink parity: useGeminiStream ignores this legacy projection event.
break;
case 'goal_state': {
closeThought();
const v = ev as {
value: GoalSnapshotLike;
cause?: string;
};
// ink gates on `event.cause && shouldDisplayGoalStateCause(cause)`;
// the shared predicate keeps the exhaustive-switch guard.
const cause = v.cause;
if (!cause || !shouldDisplayGoalStateCause(cause as GoalStateCause)) {
break;
}
// ink parity: addItem({type: 'goal_state', snapshot, cause}) renders
// via GoalStatusMessage (GoalStateCard).
out.push({ type: 'goal', snapshot: v.value, cause });
break;
}
case 'finished': {
closeThought();
// ink parity: handleFinishedEvent clears an active auto-retry
// countdown BEFORE adding the finish-reason notice — the fold
// only pops when the last item is the retry row, so clearing
// first (like every other terminal case) is required.
out.push({ type: 'retry-countdown-clear' });
// ink parity: handleFinishedEvent adds `{type: 'info'}` for
// non-STOP finish reasons.
const reason = (ev.value as { reason?: string } | undefined)?.reason;
const message = reason ? FINISH_REASON_NOTICES[reason] : undefined;
if (message) out.push({ type: 'info', text: `⚠ ${message}` });
// Segment marker only — the turn settles when the live generator
// returns (backend emits `done`), NOT here: `finished` arrives
// before tool execution, so mapping it to `done` flashed a fake
// "✗ skipped" on every running tool card.
out.push({ type: 'segment-end' });
break;
}
default:
break;
}
return out;
};
}
/** Loose GoalSnapshotV2 shape (goal-protocol.ts) for display purposes. */
export type GoalSnapshotLike = {
goal?: {
objective?: string;
status?: string;
turnCount?: number;
activeTimeMs?: number;
lastReason?: string;
} | null;
activity?: string;
};
/** Drains a real agent stream into a neutral-event sink. */
export async function pumpServerStream(
stream: AsyncIterable<ServerGeminiStreamEvent>,
sink: (ev: OpenTuiStreamEvent) => void,
): Promise<void> {
const map = createEventMapper();
for await (const ev of stream) {
for (const neutral of map(ev)) sink(neutral);
}
}
@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { createExitGuard, exitGuardHint } from './exit-guard.js';
describe('createExitGuard', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('first press arms, second press inside the window exits', () => {
const guard = createExitGuard();
expect(guard.press('ctrl-c')).toBe('armed');
expect(guard.armedKey()).toBe('ctrl-c');
vi.advanceTimersByTime(500);
expect(guard.press('ctrl-c')).toBe('exit');
expect(guard.armedKey()).toBeNull();
});
it('a press after the window expired arms again instead of exiting', () => {
const onWindowExpired = vi.fn();
const guard = createExitGuard({ onWindowExpired });
expect(guard.press('ctrl-c')).toBe('armed');
vi.advanceTimersByTime(1000);
expect(onWindowExpired).toHaveBeenCalledWith('ctrl-c');
expect(guard.armedKey()).toBeNull();
expect(guard.press('ctrl-c')).toBe('armed');
});
it('ctrl-d arms with its own hint key and confirms on the same key', () => {
const guard = createExitGuard();
expect(guard.press('ctrl-d')).toBe('armed');
expect(guard.armedKey()).toBe('ctrl-d');
expect(guard.press('ctrl-d')).toBe('exit');
});
it('keeps independent per-key windows (ink ctrlCPressedOnce vs ctrlDPressedOnce)', () => {
const guard = createExitGuard();
// A different key arms its own window without cancelling the first
// key's pending confirmation.
expect(guard.press('ctrl-c')).toBe('armed');
expect(guard.press('ctrl-d')).toBe('armed');
expect(guard.armedKey()).toBe('ctrl-d');
// The original key still confirms inside its own window.
expect(guard.press('ctrl-c')).toBe('exit');
expect(guard.armedKey()).toBe('ctrl-d');
// The other key's window is untouched until its own second press.
expect(guard.press('ctrl-d')).toBe('exit');
expect(guard.armedKey()).toBeNull();
});
it('expires each per-key window independently', () => {
const onWindowExpired = vi.fn();
const guard = createExitGuard({ onWindowExpired });
guard.press('ctrl-c');
guard.press('ctrl-d');
vi.advanceTimersByTime(1000);
expect(onWindowExpired).toHaveBeenCalledWith('ctrl-c');
expect(onWindowExpired).toHaveBeenCalledWith('ctrl-d');
expect(guard.press('ctrl-c')).toBe('armed');
});
it('disarm cancels a pending confirmation', () => {
const onWindowExpired = vi.fn();
const guard = createExitGuard({ onWindowExpired });
expect(guard.press('ctrl-c')).toBe('armed');
guard.disarm();
vi.advanceTimersByTime(5000);
expect(onWindowExpired).not.toHaveBeenCalled();
expect(guard.press('ctrl-c')).toBe('armed');
});
it('dispose stops the pending timer', () => {
const onWindowExpired = vi.fn();
const guard = createExitGuard({ onWindowExpired });
guard.press('ctrl-c');
guard.dispose();
vi.advanceTimersByTime(5000);
expect(onWindowExpired).not.toHaveBeenCalled();
});
it('honours a custom window length', () => {
const guard = createExitGuard({ windowMs: 250 });
guard.press('ctrl-c');
vi.advanceTimersByTime(249);
expect(guard.press('ctrl-c')).toBe('exit');
guard.press('ctrl-c');
vi.advanceTimersByTime(251);
expect(guard.press('ctrl-c')).toBe('armed');
});
});
describe('exitGuardHint', () => {
it('matches the ink footer wording per key', () => {
expect(exitGuardHint('ctrl-c')).toBe('Press Ctrl+C again to exit.');
expect(exitGuardHint('ctrl-d')).toBe('Press Ctrl+D again to exit.');
});
});
+115
View File
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Two-press exit confirmation for the OpenTUI backend (ink parity).
*
* The ink tree never exits on a single Ctrl+C / Ctrl+D: the first press only
* arms a confirmation window (`useDoublePress` + Footer "Press Ctrl+C again
* to exit." hint, `CTRL_EXIT_PROMPT_DURATION_MS` in
* `ui/utils/platformConstants.ts`), and only a second press inside that
* window actually quits. The original OpenTUI backend exited on the first
* press, losing unsent input and skipping the cleanup chain.
*
* This module is a framework-free state machine so the guard semantics can
* be unit tested without the native renderer; `backend.tsx` drives it from
* its keyboard handler and renders the hint in the footer.
*/
import { CTRL_EXIT_PROMPT_DURATION_MS } from '../utils/platformConstants.js';
export type ExitGuardKey = 'ctrl-c' | 'ctrl-d';
export interface ExitGuardOptions {
/** Confirmation window in ms (ink: CTRL_EXIT_PROMPT_DURATION_MS). */
windowMs?: number;
/**
* Fired when an armed window lapses without a confirming second press.
* The backend uses it to hide the footer hint.
*/
onWindowExpired?: (key: ExitGuardKey) => void;
/** Injectable timer for tests. */
setTimeoutFn?: (fn: () => void, ms: number) => unknown;
clearTimeoutFn?: (handle: unknown) => void;
}
export interface ExitGuard {
/**
* Register a press. Returns `'exit'` when this press confirms a pending
* armed exit (second press of the SAME guard key inside its own window —
* ink keeps per-key windows, `ctrlCPressedOnce` vs `ctrlDPressedOnce`), or
* `'armed'` when it starts a confirmation window for that key. A press of
* the other key arms its own independent window.
*/
press(key: ExitGuardKey): 'exit' | 'armed';
/** Most recently armed key, or null when no confirmation is pending. */
armedKey(): ExitGuardKey | null;
/** Cancel all pending confirmations (e.g. the user took another action). */
disarm(): void;
/** Clear pending timers; call on unmount. */
dispose(): void;
}
export function createExitGuard(options: ExitGuardOptions = {}): ExitGuard {
const windowMs = options.windowMs ?? CTRL_EXIT_PROMPT_DURATION_MS;
const setTimeoutFn =
options.setTimeoutFn ??
((fn: () => void, ms: number): unknown => setTimeout(fn, ms));
const clearTimeoutFn =
options.clearTimeoutFn ??
((handle: unknown): void =>
clearTimeout(handle as ReturnType<typeof setTimeout>));
// One armed window per key, exactly like ink's ctrlCPressedOnce/ctrlD
// pair — a different-key press must not drop the first key's window.
const windows = new Map<ExitGuardKey, { timer: unknown }>();
let lastArmed: ExitGuardKey | null = null;
const disarmKey = (key: ExitGuardKey) => {
const window = windows.get(key);
if (window) {
clearTimeoutFn(window.timer);
windows.delete(key);
}
if (lastArmed === key) {
lastArmed = [...windows.keys()].at(-1) ?? null;
}
};
return {
press(key: ExitGuardKey): 'exit' | 'armed' {
if (windows.has(key)) {
// Second press of the same key inside its own window exits.
disarmKey(key);
return 'exit';
}
windows.set(key, {
timer: setTimeoutFn(() => {
windows.delete(key);
if (lastArmed === key) {
lastArmed = [...windows.keys()].at(-1) ?? null;
}
options.onWindowExpired?.(key);
}, windowMs),
});
lastArmed = key;
return 'armed';
},
armedKey: () => lastArmed,
disarm: () => {
for (const key of [...windows.keys()]) disarmKey(key);
},
dispose: () => {
for (const key of [...windows.keys()]) disarmKey(key);
},
};
}
/** Footer hint text for an armed exit (ink Footer.tsx / ExitWarning parity). */
export function exitGuardHint(key: ExitGuardKey): string {
return key === 'ctrl-d'
? 'Press Ctrl+D again to exit.'
: 'Press Ctrl+C again to exit.';
}
@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
expect,
it,
vi,
beforeEach,
afterEach,
type MockInstance,
} from 'vitest';
import {
registerCleanup,
_resetCleanupFunctionsForTest,
} from '../../utils/cleanup.js';
import {
exitSession,
isExitInProgress,
EXIT_CODE_INTERRUPT,
EXIT_CODE_TERMINATED,
_resetExitLifecycleForTest,
} from './exit-lifecycle.js';
class ExitCalled extends Error {
readonly code: string | number | null | undefined;
constructor(code: string | number | null | undefined) {
super(`process.exit(${code})`);
this.code = code;
}
}
describe('exitSession', () => {
let exitSpy: MockInstance<
(code?: string | number | null | undefined) => never
>;
beforeEach(() => {
_resetExitLifecycleForTest();
_resetCleanupFunctionsForTest();
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new ExitCalled(code);
});
});
afterEach(() => {
exitSpy.mockRestore();
_resetCleanupFunctionsForTest();
_resetExitLifecycleForTest();
});
it('drains registered cleanups before exiting with the given code', async () => {
const order: string[] = [];
registerCleanup(() => {
order.push('first');
});
registerCleanup(async () => {
order.push('second');
});
await expect(exitSession(EXIT_CODE_INTERRUPT)).rejects.toThrow(ExitCalled);
expect(order).toEqual(['first', 'second']);
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODE_INTERRUPT);
});
it('uses signal-style exit codes', () => {
expect(EXIT_CODE_INTERRUPT).toBe(130);
expect(EXIT_CODE_TERMINATED).toBe(143);
});
it('is idempotent: a second call never re-runs the drain', async () => {
const cleanup = vi.fn();
registerCleanup(cleanup);
await expect(exitSession(0)).rejects.toThrow(ExitCalled);
expect(cleanup).toHaveBeenCalledTimes(1);
expect(isExitInProgress()).toBe(true);
// The second call returns a pending promise and must not re-run cleanup
// or exit again.
const second = exitSession(0);
await Promise.race([
second,
new Promise((resolve) => setTimeout(resolve, 20)),
]);
expect(cleanup).toHaveBeenCalledTimes(1);
expect(exitSpy).toHaveBeenCalledTimes(1);
});
it('still exits when a cleanup throws', async () => {
registerCleanup(() => {
throw new Error('boom');
});
await expect(exitSession(1)).rejects.toThrow(ExitCalled);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
@@ -0,0 +1,63 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Exit drain for the OpenTUI renderer (ink parity).
*
* The ink tree routes every exit through `runExitCleanup()` (utils/cleanup):
* chat-recording flush, `config.shutdown()` (MCP subprocess stop + telemetry
* shutdown), session-usage persisting, Kitty flag pop and the resume hint
* echo are all registered cleanup steps (gemini.tsx + startInteractiveUI).
* The original OpenTUI backend called `renderer.destroy()` +
* `process.exit(0)` directly, so none of those ever ran: session jsonl
* write queues were not flushed (hurting `--resume` recoverability), MCP
* children leaked, and usage was never persisted.
*
* Every OpenTUI exit path (Ctrl+C/Ctrl+D double press, /quit, render-error
* bailout) must go through `exitSession()`, which drains the shared cleanup
* chain first and only then exits — with signal-style exit codes (130/143)
* for interrupt-like exits instead of a bare 0.
*/
import { runExitCleanup } from '../../utils/cleanup.js';
/** Exit code for interrupt-style exits (Ctrl+C / Ctrl+D double press). */
export const EXIT_CODE_INTERRUPT = 130;
/** Exit code for termination-style exits (SIGTERM semantics). */
export const EXIT_CODE_TERMINATED = 143;
let exitInProgress = false;
/** True once an `exitSession` drain has started (guards re-entrancy). */
export function isExitInProgress(): boolean {
return exitInProgress;
}
/**
* Drain the registered exit-cleanup chain, then `process.exit(code)`.
*
* Idempotent: a second call while a drain is in flight hangs (returns a
* promise that never resolves) instead of racing the first drain — the
* process is going down either way.
*/
export async function exitSession(code: number): Promise<never> {
if (exitInProgress) {
// The first drain owns the exit; never run the chain twice.
return new Promise<never>(() => {});
}
exitInProgress = true;
try {
await runExitCleanup();
} catch {
// runExitCleanup swallows per-cleanup errors already; belt and braces.
}
process.exit(code);
}
/** TEST ONLY: reset the module-level exit latch between cases. */
export function _resetExitLifecycleForTest(): void {
exitInProgress = false;
}
@@ -0,0 +1,221 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies the OpenTUI `/help` content builder reproduces the original ink
* Help dialog: shortcut list, grouping/sorting, signature + description +
* subcommand lines, truncation widths, and the docs footer.
*/
import { describe, it, expect } from 'vitest';
import type { SlashCommand } from '../commands/types.js';
import { CommandKind } from '../commands/types.js';
import {
HELP_COMMAND_LIST_VISIBLE_LINES,
HELP_DOCS_URL,
HELP_KEY_COL_WIDTH,
HELP_LAYOUT_FIXED_ROWS,
HELP_LAYOUT_RESERVED_ROWS,
buildHelpCommandsLines,
computeHelpBodyRows,
computeHelpWidthLayout,
formatHelpText,
getHelpShortcuts,
groupHelpCommands,
truncateHelpText,
} from './help-content.js';
function cmd(
overrides: Partial<SlashCommand> & { name: string },
): SlashCommand {
return {
description: `${overrides.name} description`,
kind: CommandKind.BUILT_IN,
source: 'builtin-command',
...overrides,
};
}
const commands: SlashCommand[] = [
cmd({ name: 'zeta' }),
cmd({ name: 'alpha', argumentHint: '<arg>' }),
cmd({
name: 'memory',
subCommands: [
cmd({ name: 'add', description: 'add sub' }),
cmd({ name: 'hidden-sub', description: 'x', hidden: true }),
],
}),
cmd({ name: 'secret', hidden: true }),
cmd({ name: 'nodesc', description: '' }),
cmd({
name: 'mycommand',
source: 'skill-dir-command',
sourceDetail: 'user',
}),
];
describe('help shortcuts (General tab)', () => {
it('matches the original shortcut list', () => {
const keys = getHelpShortcuts().map((s) => s.key);
expect(keys).toContain('@');
expect(keys).toContain('!');
expect(keys).toContain('/');
expect(keys).toContain('Tab');
expect(keys).toContain('Esc Esc');
expect(keys).toContain('Ctrl+L');
expect(keys).toContain('Ctrl+Q');
expect(keys).toContain('Alt+←/→');
expect(keys).toContain('↑/↓');
expect(keys).toContain(
process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J',
);
});
});
describe('help command grouping (original Help dialog rules)', () => {
it('filters hidden and description-less commands; sorts groups by order and names', () => {
// commands tab (customOnly=false): built-in groups only, like the dialog
const groups = groupHelpCommands(commands, false);
expect(groups.map((g) => g.key)).toEqual(['built-in']);
const builtin = groups.find((g) => g.key === 'built-in');
expect(builtin?.commands.map((c) => c.name)).toEqual([
'alpha',
'memory',
'zeta',
]);
});
it('customOnly keeps only non-built-in groups', () => {
const groups = groupHelpCommands(commands, true);
expect(groups.map((g) => g.key)).toEqual(['custom']);
});
});
describe('help command lines (signature/meta/description/subcommands)', () => {
it('emits group, signature, description and subcommand lines', () => {
const lines = buildHelpCommandsLines(commands);
const group = lines.find((l) => l.type === 'group');
expect(group).toEqual({
type: 'group',
text: 'Built-in Commands',
count: 3,
});
const alpha = lines.find(
(l) => l.type === 'signature' && l.text.includes('/alpha'),
);
expect(alpha).toBeDefined();
if (alpha?.type === 'signature') {
expect(alpha.text).toBe('/alpha <arg>');
expect(alpha.meta).toContain('[interactive]');
}
const memorySubs = lines.find((l) => l.type === 'subcommands');
expect(memorySubs).toBeDefined();
if (memorySubs?.type === 'subcommands') {
expect(memorySubs.text).toContain('add');
expect(memorySubs.text).not.toContain('hidden-sub');
}
});
it('truncates long signatures like the dialog (42% of body width)', () => {
const long = cmd({
name: 'x'.repeat(200),
argumentHint: '<very-long-hint>',
});
const lines = buildHelpCommandsLines([long], 100);
const signature = lines.find((l) => l.type === 'signature');
expect(signature).toBeDefined();
if (signature?.type === 'signature') {
// body width = max(72, 100) - 6 = 94; 42% → 39 chars + ellipsis
expect(signature.text.length).toBeLessThanOrEqual(39);
expect(signature.text.endsWith('…')).toBe(true);
}
});
it('caps the command listing window at 18 visible lines', () => {
expect(HELP_COMMAND_LIST_VISIBLE_LINES).toBe(18);
});
});
describe('overlay row budget (80x24 bounded rows, footer kept visible)', () => {
it('leaves body rows so header+footer+hints fit at 24 rows', () => {
// banner (3) + mount margin (1) + status (1) + composer chrome (5) +
// overlay borders/padding/header/footer/hints/margins (10) = 20, so a
// 24-row terminal keeps 4 rows for the tab body.
expect(computeHelpBodyRows(24)).toBe(4);
});
it('never goes negative on tiny terminals', () => {
expect(computeHelpBodyRows(0)).toBe(0);
expect(computeHelpBodyRows(12)).toBe(0);
expect(computeHelpBodyRows(19)).toBe(0);
});
it('body + fixed overlay rows + reserved chrome never exceeds the screen', () => {
for (const height of [24, 25, 30, 40, 60]) {
const total =
computeHelpBodyRows(height) +
HELP_LAYOUT_FIXED_ROWS +
HELP_LAYOUT_RESERVED_ROWS;
expect(total).toBeLessThanOrEqual(height);
}
});
});
describe('formatHelpText (full /help output)', () => {
it('renders tabs, shortcuts, commands and the docs footer', () => {
const text = formatHelpText(commands);
expect(text).toContain('Qwen Code');
expect(text).toContain('Built-in Commands (3)');
expect(text).toContain('/alpha <arg>');
expect(text).toContain('/zeta');
expect(text).not.toContain('/secret');
expect(text).toContain('Browse custom, skill, plugin, and MCP commands:');
expect(text).toContain('/mycommand [User]');
expect(text).toContain(`For more help: ${HELP_DOCS_URL}`);
expect(text).toContain('Tab/Shift+Tab to switch tabs · Esc to cancel');
});
});
describe('truncateHelpText', () => {
it('shortens long text with an ellipsis', () => {
expect(truncateHelpText('Clear the screen', 6)).toBe('Clear…');
});
it('leaves short text and degenerate widths untouched', () => {
expect(truncateHelpText('Short', 20)).toBe('Short');
expect(truncateHelpText('Anything', 1)).toBe('Anything');
expect(truncateHelpText('Anything', 0)).toBe('Anything');
});
});
describe('computeHelpWidthLayout (narrow-width /help parity)', () => {
it('derives fixed shortcut columns and a truncation budget', () => {
// At 80 cols the overlay previously overlapped its two shortcut columns
// ("Cleartthe screen"); the layout now sizes fixed columns like the ink
// dialog (colWidth = floor((safeWidth - 6 - 2) / 2)).
const layout = computeHelpWidthLayout(80);
expect(layout.safeWidth).toBe(80);
expect(layout.bodyWidth).toBe(74);
expect(layout.colWidth).toBe(36);
expect(layout.descWidth).toBe(36 - HELP_KEY_COL_WIDTH - 1);
});
it('grows the columns with the terminal width', () => {
const narrow = computeHelpWidthLayout(80);
const wide = computeHelpWidthLayout(140);
expect(wide.colWidth).toBeGreaterThan(narrow.colWidth);
expect(wide.descWidth).toBeGreaterThan(narrow.descWidth);
});
it('clamps to the ink minimum width of 72', () => {
const layout = computeHelpWidthLayout(40);
expect(layout.safeWidth).toBe(72);
expect(layout.colWidth).toBe(Math.floor((72 - 6 - 2) / 2));
});
});
+320
View File
@@ -0,0 +1,320 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Help content for the OpenTUI renderer (PR1 slice 1), mirroring the original
* ink `Help` dialog (packages/cli/src/ui/components/Help.tsx): same tabs,
* same shortcut list, same command grouping/signature/truncation rules — so
* `/help` output matches the original. The ink dialog renders these with Box
* widgets; here the identical data is produced as plain lines the OpenTUI
* backend draws in its help overlay, plus a text formatter for tests.
*
* Pure + unit-testable; no renderer imports.
*/
import type { SlashCommand } from '../commands/types.js';
import { t } from '../../i18n/index.js';
import {
formatSupportedModes,
getCommandDisplayName,
getCommandSourceBadge,
getCommandSourceGroup,
getCommandSubcommandNames,
} from '../../services/commandMetadata.js';
export const HELP_DEFAULT_WIDTH = 100;
export const HELP_KEY_COL_WIDTH = 20;
export const HELP_COMMAND_LIST_VISIBLE_LINES = 18;
export const HELP_DOCS_URL = 'https://qwenlm.github.io/qwen-code-docs/';
/**
* Row budget that keeps the whole overlay on screen at small terminals
* (e.g. 80x24). Reserved rows are owned by the surrounding app chrome:
* banner (3), dialog-mount top margin (1), status bar (1), composer chrome
* (5). Fixed rows are the overlay's own chrome plus the always-rendered
* docs footer and key hints: borders (2), vertical padding (2), header (1),
* footer (1), hints (1) and the three separator margins (3). The tab body
* receives the remainder, so the footer/hints stay visible at 80x24.
*/
export const HELP_LAYOUT_RESERVED_ROWS = 10;
export const HELP_LAYOUT_FIXED_ROWS = 10;
/** Tab-body rows that fit below the header and above the footer/hints. */
export function computeHelpBodyRows(terminalHeight: number): number {
return Math.max(
0,
terminalHeight - HELP_LAYOUT_RESERVED_ROWS - HELP_LAYOUT_FIXED_ROWS,
);
}
export type HelpTab = 'general' | 'commands' | 'custom-commands';
export const HELP_TABS: ReadonlyArray<{ tab: HelpTab; label: string }> = [
{ tab: 'general', label: 'general' },
{ tab: 'commands', label: 'commands' },
{ tab: 'custom-commands', label: 'custom-commands' },
];
export interface HelpShortcut {
key: string;
description: string;
}
/** General tab shortcuts — identical list to the original GeneralHelp. */
export function getHelpShortcuts(): HelpShortcut[] {
return [
{ key: '@', description: t('Add files or folders as context') },
{ key: '!', description: t('Run shell commands') },
{ key: '/', description: t('Open command menu') },
{ key: 'Tab', description: t('Accept ghost text or completion') },
{ key: 'Esc Esc', description: t('Clear input or cancel operation') },
{ key: 'Ctrl+L', description: t('Clear the screen') },
{ key: 'Ctrl+Q', description: t('Queue message for the next turn') },
{
key: process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J',
description: t('Insert a newline'),
},
{
key: process.platform === 'win32' ? 'Tab' : 'Shift+Tab',
description: t('Cycle approval modes'),
},
{ key: 'Alt+←/→', description: t('Jump through words') },
{ key: '↑/↓', description: t('Cycle prompt history') },
];
}
export type HelpLine =
| { type: 'group'; text: string; count: number }
| { type: 'signature'; text: string; meta: string }
| { type: 'description'; text: string }
| { type: 'subcommands'; text: string }
| { type: 'blank' };
interface CommandGroup {
key: string;
title: string;
order: number;
commands: SlashCommand[];
}
/** Identical grouping logic to the original Help dialog. */
export function groupHelpCommands(
commands: readonly SlashCommand[],
customOnly: boolean,
): CommandGroup[] {
const groups = new Map<string, CommandGroup>();
commands
.filter((cmd) => cmd.description && !cmd.hidden)
.forEach((cmd) => {
const group = getCommandSourceGroup(cmd);
if (customOnly ? group.key === 'built-in' : group.key !== 'built-in') {
return;
}
const existing = groups.get(group.key);
if (existing) {
existing.commands.push(cmd);
} else {
groups.set(group.key, {
key: group.key,
title: group.title,
order: group.order,
commands: [cmd],
});
}
});
return Array.from(groups.values())
.sort((a, b) => a.order - b.order)
.map((group) => ({
...group,
commands: group.commands.sort((a, b) => a.name.localeCompare(b.name)),
}));
}
/**
* Ellipsis truncation shared by the help overlay and its plain-text
* formatter (identical semantics to the ink Help dialog's `truncateText`).
*/
export function truncateHelpText(text: string, maxLength: number): string {
if (maxLength <= 1 || text.length <= maxLength) return text;
return `${text.slice(0, maxLength - 1)}…`;
}
/**
* Width layout for the help overlay (parity of the ink Help dialog, which
* receives the live main-area width and clamps it to at least 72):
*
* - `safeWidth` is the outer border-box width;
* - the General tab's shortcut grid draws two FIXED-width columns of
* `colWidth` (ink: `Math.floor((bodyWidth - 2) / 2)`, bodyWidth =
* safeWidth - 6 for borders + horizontal padding) instead of flex-grow
* columns, which overlapped each other below ~100 columns;
* - descriptions truncate with an ellipsis to `descWidth` exactly like
* ink's ShortcutRow (`width - KEY_COL_WIDTH - 1`), so narrow terminals
* stay clean instead of wrapping rows out of the capped body window.
*/
export interface HelpWidthLayout {
safeWidth: number;
bodyWidth: number;
colWidth: number;
descWidth: number;
}
export function computeHelpWidthLayout(
availableWidth: number,
): HelpWidthLayout {
const safeWidth = Math.max(72, availableWidth);
const bodyWidth = safeWidth - 6;
const colWidth = Math.floor((bodyWidth - 2) / 2);
const descWidth = colWidth - HELP_KEY_COL_WIDTH - 1;
return {
safeWidth,
bodyWidth,
colWidth,
descWidth,
};
}
/** Same line model as the original CommandsHelp (signature/meta/desc/subs). */
function buildCommandLines(groups: CommandGroup[], width: number): HelpLine[] {
const lines: HelpLine[] = [];
groups.forEach((group, groupIndex) => {
lines.push({
type: 'group',
text: group.title,
count: group.commands.length,
});
group.commands.forEach((cmd) => {
const badge = getCommandSourceBadge(cmd);
const name = getCommandDisplayName(cmd, {
prefix: '/',
includeAliases: false,
});
const signature = [name, cmd.argumentHint].filter(Boolean).join(' ');
const meta = [
badge,
formatSupportedModes(cmd),
cmd.modelInvocable ? '[model]' : undefined,
]
.filter(Boolean)
.join(' ');
lines.push({
type: 'signature',
text: truncateHelpText(signature, Math.floor(width * 0.42)),
meta,
});
if (cmd.description) {
lines.push({
type: 'description',
text: truncateHelpText(cmd.description, Math.max(20, width - 4)),
});
}
const subcommands = getCommandSubcommandNames(cmd);
if (subcommands.length > 0) {
const descWidth = Math.max(20, width - 4);
lines.push({
type: 'subcommands',
text: `${t('subcommands:')} ${truncateHelpText(subcommands.join(', '), descWidth - 13)}`,
});
}
});
if (groupIndex < groups.length - 1) {
lines.push({ type: 'blank' });
}
});
return lines;
}
/** Commands tab lines (built-in commands), widths mirroring the dialog. */
export function buildHelpCommandsLines(
commands: readonly SlashCommand[],
width: number = HELP_DEFAULT_WIDTH,
): HelpLine[] {
const safeWidth = Math.max(72, width);
const bodyWidth = safeWidth - 6;
return buildCommandLines(groupHelpCommands(commands, false), bodyWidth);
}
/** Custom-commands tab lines (everything except built-ins). */
export function buildHelpCustomCommandLines(
commands: readonly SlashCommand[],
width: number = HELP_DEFAULT_WIDTH,
): HelpLine[] {
const safeWidth = Math.max(72, width);
const bodyWidth = safeWidth - 6;
return buildCommandLines(groupHelpCommands(commands, true), bodyWidth);
}
function shortcutLine(shortcut: HelpShortcut): string {
const key = shortcut.key.padEnd(HELP_KEY_COL_WIDTH);
return `${key}${shortcut.description}`;
}
/**
* Full `/help` output as plain text — all three tabs plus the dialog footer,
* matching the original Help dialog's content.
*/
export function formatHelpText(
commands: readonly SlashCommand[],
width: number = HELP_DEFAULT_WIDTH,
): string {
const out: string[] = [];
const tabLabels = HELP_TABS.map(({ tab, label }) =>
tab === 'general' ? `[${t(label)}]` : ` ${t(label)} `,
);
out.push(`Qwen Code ${tabLabels.join('')}`);
out.push('');
out.push(
t(
'Qwen Code understands your codebase, makes edits with your permission, and executes commands right from your terminal.',
),
);
out.push('');
out.push(t('Shortcuts'));
for (const shortcut of getHelpShortcuts()) {
out.push(` ${shortcutLine(shortcut)}`);
}
out.push('');
out.push(t('Browse built-in commands:'));
for (const line of buildHelpCommandsLines(commands, width)) {
out.push(renderHelpLine(line));
}
out.push('');
const customLines = buildHelpCustomCommandLines(commands, width);
if (customLines.length > 0) {
out.push(t('Browse custom, skill, plugin, and MCP commands:'));
for (const line of customLines) {
out.push(renderHelpLine(line));
}
out.push('');
}
out.push(`${t('For more help:')} ${HELP_DOCS_URL}`);
out.push(t('Tab/Shift+Tab to switch tabs · Esc to cancel'));
return out.join('\n');
}
/** Renders one command-list line as plain text (for overlay + text output). */
export function renderHelpLine(line: HelpLine): string {
switch (line.type) {
case 'group':
return `${line.text} (${line.count})`;
case 'signature':
return ` ${line.text}${line.meta ? ` ${line.meta}` : ''}`;
case 'description':
return ` ${line.text}`;
case 'subcommands':
return ` ${line.text}`;
case 'blank':
return ' ';
default:
return '';
}
}
@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies the OpenTUI composer history navigation mirrors the original
* useInputHistory hook semantics (newest-first traversal, draft stash and
* restore, reset after submit).
*/
import { describe, it, expect } from 'vitest';
import { InputHistory } from './input-history.js';
function makeHistory(messages: string[]): InputHistory {
return new InputHistory(() => messages);
}
describe('opentui InputHistory (useInputHistory parity)', () => {
it('returns null when there is no history', () => {
const history = makeHistory([]);
expect(history.navigateUp('draft')).toBeNull();
expect(history.navigateDown()).toBeNull();
});
it('navigateUp walks from newest to oldest', () => {
const history = makeHistory(['first', 'second', 'third']);
expect(history.navigateUp('draft')).toBe('third');
expect(history.navigateUp('third')).toBe('second');
expect(history.navigateUp('second')).toBe('first');
// already at the oldest entry — no further navigation
expect(history.navigateUp('first')).toBeNull();
expect(history.navigateUp('first')).toBeNull();
});
it('navigateDown walks back to newest and restores the draft', () => {
const history = makeHistory(['first', 'second']);
expect(history.navigateUp('my draft')).toBe('second');
expect(history.navigateUp('second')).toBe('first');
expect(history.navigateDown()).toBe('second');
// past the newest entry → the original in-progress query comes back
expect(history.navigateDown()).toBe('my draft');
// no longer navigating
expect(history.navigateDown()).toBeNull();
});
it('navigateDown is a no-op when not navigating', () => {
const history = makeHistory(['a', 'b']);
expect(history.navigateDown()).toBeNull();
});
it('reset restarts navigation at the newest entry', () => {
const history = makeHistory(['a', 'b']);
expect(history.navigateUp('')).toBe('b');
expect(history.navigateUp('b')).toBe('a');
history.reset();
expect(history.isNavigating).toBe(false);
expect(history.navigateUp('')).toBe('b');
});
it('tracks live message updates (e.g. after a submit)', () => {
const messages: string[] = ['a'];
const history = new InputHistory(() => messages);
expect(history.navigateUp('')).toBe('a');
history.reset();
messages.push('b');
expect(history.navigateUp('')).toBe('b');
});
});
@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Prompt-history navigation for the OpenTUI composer (PR1 slice 1).
*
* Framework-neutral port of the original `useInputHistory` hook
* (packages/cli/src/ui/hooks/useInputHistory.ts) with identical semantics:
* - ↑ (HISTORY_UP / NAVIGATION_UP at the buffer edge) walks from newest to
* oldest submitted prompt, stashing the in-progress query on the first
* navigation;
* - ↓ walks back toward the newest entry and restores the stashed query
* once past it;
* - the position resets after every submit so the next ↑ starts at the
* newest entry.
*/
export class InputHistory {
private index = -1;
private originalQuery = '';
/** `getMessages` returns submitted prompts in chronological order. */
constructor(private readonly getMessages: () => readonly string[]) {}
/** Mirrors `useInputHistory.navigateUp`; returns the new buffer text. */
navigateUp(currentQuery: string): string | null {
const messages = this.getMessages();
if (messages.length === 0) return null;
let nextIndex = this.index;
if (this.index === -1) {
this.originalQuery = currentQuery;
nextIndex = 0;
} else if (this.index < messages.length - 1) {
nextIndex = this.index + 1;
} else {
return null; // already at the oldest message
}
if (nextIndex === this.index) return null;
this.index = nextIndex;
return messages[messages.length - 1 - nextIndex] ?? null;
}
/** Mirrors `useInputHistory.navigateDown`; returns the new buffer text. */
navigateDown(): string | null {
if (this.index === -1) return null; // not navigating history
const messages = this.getMessages();
const nextIndex = this.index - 1;
this.index = nextIndex;
if (nextIndex === -1) {
return this.originalQuery; // back past the newest entry → restore draft
}
return messages[messages.length - 1 - nextIndex] ?? null;
}
/** Mirrors `resetHistoryNav` — called after each submit. */
reset(): void {
this.index = -1;
this.originalQuery = '';
}
/** Whether a history entry is currently shown in the buffer. */
get isNavigating(): boolean {
return this.index !== -1;
}
}
@@ -0,0 +1,693 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Text-projection parity tests for the special ink history items (audit 01
* G-1/2/3/12/14/17): each builder must print what the ink component prints.
*/
import { describe, it, expect, vi } from 'vitest';
import {
CompressionStatus,
type Config,
type SessionMetrics,
} from '@qwen-code/qwen-code-core';
import {
extractPromptText,
projectAbout,
projectCompression,
projectContextUsage,
projectDoctor,
projectExtensionsList,
projectMcpStatus,
projectModelStats,
projectQuit,
projectSkillStats,
projectSpecialItemText,
projectStats,
projectSummary,
projectToolStats,
projectToolsList,
} from './item-projection.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import type { LoadedSettings } from '../../config/settings.js';
// R1-93 tests the cached-items upgrade from the DISCONNECTED base state;
// the real registry reports unknown servers as disconnected anyway, but the
// mock makes that independent of core's global state.
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
getMCPServerStatus: () => actual.MCPServerStatus.DISCONNECTED,
};
});
function makeMetrics(): SessionMetrics {
return {
models: {
'qwen3-max': {
api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 4000 },
tokens: {
prompt: 1000,
candidates: 500,
total: 1600,
cached: 100,
thoughts: 100,
},
bySource: Object.create(null),
},
},
tools: {
totalCalls: 3,
totalSuccess: 2,
totalFail: 1,
totalDurationMs: 3000,
totalDecisions: { accept: 1, reject: 1, modify: 0, auto_accept: 0 },
byName: {
read_file: {
count: 2,
success: 2,
fail: 0,
durationMs: 2000,
decisions: { accept: 1, reject: 0, modify: 0, auto_accept: 0 },
},
write_file: {
count: 1,
success: 0,
fail: 1,
durationMs: 1000,
decisions: { accept: 0, reject: 1, modify: 0, auto_accept: 0 },
},
},
},
files: { totalLinesAdded: 10, totalLinesRemoved: 2 },
skills: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
byName: {
review: { count: 1, success: 1, fail: 0 },
},
},
} as unknown as SessionMetrics;
}
describe('projectModelStats', () => {
it('reports no calls when the session is empty', () => {
const metrics = makeMetrics();
metrics.models = {};
expect(projectModelStats(metrics)).toBe(
'No API calls have been made in this session.',
);
});
it('prints requests/errors/tokens for active models', () => {
const text = projectModelStats(makeMetrics());
expect(text).toContain('Model Stats For Nerds');
expect(text).toContain('Requests 2');
expect(text).toContain('Errors 0 (0.0%)');
expect(text).toContain('Total 1,600');
expect(text).toContain(' ↳ Prompt 1,000');
expect(text).toContain(' ↳ Cached 100 (10.0%)');
expect(text).toContain('qwen3-max');
});
});
describe('projectToolStats', () => {
it('reports no calls when nothing ran', () => {
const metrics = makeMetrics();
metrics.tools.byName = {};
expect(projectToolStats(metrics)).toBe(
'No tool calls have been made in this session.',
);
});
it('prints per-tool rows and the decision summary', () => {
const text = projectToolStats(makeMetrics());
expect(text).toContain('Tool Stats For Nerds');
expect(text).toContain('read_file 2 100.0% 1.0s');
expect(text).toContain('write_file 1 0.0% 1.0s');
expect(text).toContain('Total Reviewed Suggestions: 2');
expect(text).toContain(' » Accepted: 1');
expect(text).toContain(' » Rejected: 1');
expect(text).toContain(' Overall Agreement Rate: 50.0%');
});
});
describe('projectSkillStats', () => {
it('prints skill rows sorted by count', () => {
const text = projectSkillStats(makeMetrics());
expect(text).toContain('Skill Stats For Nerds');
expect(text).toContain('review 1 1 0 100.0%');
});
});
describe('projectSummary', () => {
it('shows stage-specific pending lines and the saved path', () => {
expect(projectSummary({ isPending: true, stage: 'generating' })).toBe(
'Generating project summary...',
);
expect(projectSummary({ isPending: true, stage: 'saving' })).toBe(
'Saving project summary...',
);
expect(projectSummary({ isPending: false, stage: 'completed' })).toContain(
'Project summary generated and saved successfully!',
);
expect(
projectSummary({
isPending: false,
stage: 'completed',
filePath: '/tmp/QWEN.md',
}),
).toContain('Saved to: /tmp/QWEN.md');
});
});
describe('projectContextUsage', () => {
it('prints the usage table with categories', () => {
const text = projectContextUsage({
modelName: 'qwen3-max',
totalTokens: 5000,
contextWindowSize: 100000,
breakdown: {
systemPrompt: 1000,
builtinTools: 800,
mcpTools: 0,
memoryFiles: 200,
skills: 0,
messages: 3000,
freeSpace: 94000,
autocompactBuffer: 1000,
},
isEstimated: false,
showDetails: false,
});
expect(text).toContain('Context Usage');
expect(text).toContain('Model: qwen3-max Context window: 100.0k tokens');
expect(text).toContain('█ Used 5.0k tokens (5.0%)');
expect(text).toContain('█ Messages 3.0k tokens (3.0%)');
expect(text).toContain('Run /context detail for per-item breakdown.');
// MCP tools row is skipped at zero.
expect(text).not.toContain('MCP tools');
});
it('shows the no-API-response notice before the first turn', () => {
const text = projectContextUsage({
modelName: 'qwen3-max',
totalTokens: 0,
contextWindowSize: 100000,
breakdown: {},
});
expect(text).toContain('No API response yet.');
});
it('renders the compaction-threshold ladder (ytahdn-3)', () => {
const text = projectContextUsage({
modelName: 'm',
totalTokens: 5000,
contextWindowSize: 100000,
breakdown: {
thresholds: {
effectiveWindow: 92000,
warn: 60000,
auto: 80000,
hard: 90000,
},
currentTier: 'warn',
},
isEstimated: false,
showDetails: false,
});
expect(text).toContain('Compaction thresholds');
expect(text).toContain('Effective window 92.0k tokens');
expect(text).toContain('▶ Warn threshold 60.0k tokens');
expect(text).toContain(' Auto threshold 80.0k tokens');
expect(text).toContain('Current tier warn');
});
it('renders per-item detail sections when showDetails is on (ytahdn-3)', () => {
const text = projectContextUsage({
modelName: 'm',
totalTokens: 5000,
contextWindowSize: 100000,
breakdown: {
thresholds: {
effectiveWindow: 92000,
warn: 60000,
auto: 80000,
hard: 90000,
},
currentTier: 'safe',
},
isEstimated: false,
showDetails: true,
builtinTools: [
{ name: 'read-file', tokens: 300 },
{ name: 'shell', tokens: 500 },
],
mcpTools: [{ name: 'search', tokens: 100 }],
memoryFiles: [{ path: 'GEMINI.md', tokens: 200 }],
skills: [
{ name: 'feat-dev', tokens: 10, loaded: false },
{
name: 'e2e-testing',
tokens: 20,
loaded: true,
bodyTokens: 400,
},
],
});
// Sections appear, sorted by token count descending.
const shellIdx = text.indexOf('shell');
const readIdx = text.indexOf('read-file');
expect(shellIdx).toBeGreaterThan(-1);
expect(readIdx).toBeGreaterThan(shellIdx);
expect(text).toContain('MCP tools');
expect(text).toContain('Memory files');
expect(text).toContain('GEMINI.md');
// Loaded skill (with body cost) precedes the unloaded one.
const loadedIdx = text.indexOf('* e2e-testing');
const unloadedIdx = text.indexOf('feat-dev');
expect(loadedIdx).toBeGreaterThan(-1);
expect(unloadedIdx).toBeGreaterThan(loadedIdx);
expect(text).toContain('+400 body');
expect(text).not.toContain('Run /context detail');
});
});
describe('projectDoctor', () => {
it('groups checks by category and prints the summary', () => {
const text = projectDoctor(
[
{
category: 'Auth',
name: 'credentials',
status: 'pass',
message: 'ok',
},
{
category: 'Auth',
name: 'expiry',
status: 'warn',
message: 'soon',
detail: 'renew it',
},
],
{ pass: 1, warn: 1, fail: 0 },
);
expect(text).toContain('Doctor Report');
expect(text).toContain('Auth');
expect(text).toContain('✓ credentials: ok');
expect(text).toContain('⚠ expiry: soon');
expect(text).toContain('-> renew it');
expect(text).toContain('-- 1 passed, 1 warnings, 0 failures');
});
});
describe('projectMcpStatus', () => {
it('reports no servers when none are configured', () => {
expect(projectMcpStatus({ servers: {}, tools: [], prompts: [] })).toBe(
'No MCP servers configured.',
);
});
it('lists servers with cached tools as connected', () => {
const text = projectMcpStatus({
servers: { docs: {} },
tools: [{ serverName: 'docs', name: 'search' }],
prompts: [],
authStatus: {},
blockedServers: [],
discoveryInProgress: false,
connectingServers: [],
showDescriptions: false,
});
expect(text).toContain('Configured MCP servers:');
expect(text).toContain('● docs - Ready (1 tool)');
expect(text).toContain('Tools:');
expect(text).toContain('- search');
});
it('prints parameter schemas and tips when requested (ytahdn-4)', () => {
const text = projectMcpStatus({
servers: { docs: {} },
tools: [
{
serverName: 'docs',
name: 'search',
schema: {
parametersJsonSchema: {
type: 'object',
properties: { query: { type: 'string' } },
},
},
},
],
prompts: [],
authStatus: {},
blockedServers: [],
discoveryInProgress: false,
connectingServers: [],
showDescriptions: false,
showSchema: true,
showTips: true,
});
expect(text).toContain('Parameters:');
expect(text).toContain('"query"');
expect(text).toContain('★ Tips:');
expect(text).toContain(
'Use /mcp desc to show server and tool descriptions',
);
const plain = projectMcpStatus({
servers: { docs: {} },
tools: [{ serverName: 'docs', name: 'search' }],
prompts: [],
authStatus: {},
blockedServers: [],
discoveryInProgress: false,
connectingServers: [],
showDescriptions: false,
showSchema: false,
showTips: false,
});
expect(plain).not.toContain('Parameters:');
expect(plain).not.toContain('★ Tips:');
});
});
describe('projectQuit', () => {
it('prints the session summary with the resume hint', () => {
const stats = {
sessionId: 'abc-123',
sessionStartTime: new Date(),
metrics: makeMetrics(),
lastPromptTokenCount: 0,
promptCount: 2,
} as unknown as SessionStatsState;
const config = {
getChatRecordingService: () => ({}),
} as never;
const text = projectQuit('5m 0s', stats, config);
expect(text).toContain('Agent powering down. Goodbye!');
expect(text).toContain('Session ID: abc-123');
expect(text).toContain('Wall Time: 5m 0s');
expect(text).toContain('qwen --resume abc-123');
});
it('falls back to the bare duration without stats', () => {
expect(projectQuit('1m', undefined, null)).toContain(
'Session duration: 1m',
);
});
});
describe('extractPromptText', () => {
it('passes strings through and walks React element children', () => {
expect(extractPromptText('plain')).toBe('plain');
// React.createElement(Text, null, '...') shape.
const element = {
$$typeof: Symbol.for('react.element'),
props: { children: 'Overwrite QWEN.md?' },
};
expect(extractPromptText(element)).toBe('Overwrite QWEN.md?');
const nested = {
props: { children: ['A ', { props: { children: 'B' } }] },
};
expect(extractPromptText(nested)).toBe('A B');
expect(extractPromptText(42)).toBe('42');
});
});
describe('projectToolsList (R1-90: tool descriptions)', () => {
it('renders each tool description under its name when showDescriptions', () => {
const text = projectToolsList(
[
{
name: 'read_file',
displayName: 'ReadFile',
description: 'Reads a file. ',
},
{ name: 'run_shell', description: ' Runs a shell command.' },
],
true,
);
expect(text).toContain('- ReadFile (read_file)');
expect(text).toContain(' Reads a file.');
expect(text).toContain('- run_shell (run_shell)');
expect(text).toContain(' Runs a shell command.');
});
it('omits descriptions when showDescriptions is off', () => {
const text = projectToolsList(
[{ name: 'read_file', description: 'Reads a file.' }],
false,
);
expect(text).toContain('- read_file');
expect(text).not.toContain('Reads a file.');
});
});
describe('model pricing (R1-91/R1-92)', () => {
const pricingMetrics = (): SessionMetrics =>
({
models: {
'qwen3-max-001': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 1000,
candidates: 500,
total: 1500,
cached: 0,
thoughts: 0,
},
bySource: Object.create(null),
},
},
}) as unknown as SessionMetrics;
it('looks pricing up under the raw model name, not the normalized label', () => {
// The display label renders as "qwen3-max" (normalizeModelName strips
// -001) but the pricing table is keyed by the raw name, exactly like
// ink's getModelName(key) — a label-based lookup would miss.
const text = projectModelStats(pricingMetrics(), {
'qwen3-max-001': {
inputPerMillionTokens: 1,
outputPerMillionTokens: 2,
},
});
expect(text).toContain('Cost');
expect(text).toContain('Estimated $0.0020');
});
it('resolves pricing from settings.merged.modelPricing (R1-92)', () => {
const settings = {
merged: {
modelPricing: {
'qwen3-max-001': {
inputPerMillionTokens: 1,
outputPerMillionTokens: 2,
},
},
},
} as unknown as LoadedSettings;
const stats = {
sessionId: 's',
sessionStartTime: new Date(),
metrics: pricingMetrics(),
lastPromptTokenCount: 0,
promptCount: 1,
} as unknown as SessionStatsState;
const text = projectSpecialItemText(
{ type: 'model_stats' },
{
stats,
settings,
// Decoy: the old code probed this nonexistent config method; the
// pricing entry only exists in settings, so a Cost row proves the
// settings channel is the one being read.
config: {
getModelPricing: () => ({ decoy: {} }),
} as unknown as Config,
},
);
expect(text).toContain('Cost');
expect(text).toContain('Estimated $0.0020');
});
});
describe('projectMcpStatus cached-items upgrade (R1-93)', () => {
it('upgrades DISCONNECTED servers with cached prompts, not just tools', () => {
const text = projectMcpStatus({
servers: { ghost: {}, 'tools-only': {}, 'prompts-only': {} },
tools: [{ serverName: 'tools-only', name: 't1' }],
prompts: [{ serverName: 'prompts-only', name: 'p1' }],
});
// cached tools OR cached prompts upgrade the row to Ready (ink
// hasCachedItems); a server with neither stays Disconnected.
expect(text).toMatch(/tools-only[^\n]*Ready/);
expect(text).toMatch(/prompts-only[^\n]*Ready/);
expect(text).toMatch(/ghost[^\n]*Disconnected/);
});
});
describe('projectMcpStatus line spellings (R1-86)', () => {
it('prints the disconnected line exactly like ink — no dots after the name', () => {
const text = projectMcpStatus({ servers: { off: {} } });
expect(text).toContain('● off - Disconnected');
});
});
describe('projectAbout proxy redaction (R1-6)', () => {
it('masks credentials in parseable proxy URLs', () => {
const text = projectAbout({
proxy: 'http://user:[email protected]:3128',
});
expect(text).toContain('Proxy: http://***:***@example.com:3128/');
expect(text).not.toContain('user');
expect(text).not.toContain('pass');
});
it('falls back to regex redaction when URL parsing fails', () => {
// Realistic proxy-env typos (a space in the host) must not leak the
// raw credentials into the shareable transcript.
const text = projectAbout({ proxy: 'http://user:pass@inv alid' });
expect(text).toContain('Proxy: http://***@inv alid');
expect(text).not.toContain('user');
});
});
describe('projectExtensionsList resolved settings (R1-8)', () => {
it('lists resolved setting names and values from the array', () => {
const config = {
getExtensions: () => [
{
name: 'ext-a',
version: '1.0.0',
isActive: true,
resolvedSettings: [
{
name: 'API_KEY',
envVar: 'API_KEY',
value: 'v1',
sensitive: false,
},
],
},
],
} as unknown as Config;
const text = projectExtensionsList(config, new Map());
expect(text).toContain(' settings:');
expect(text).toContain(' - API_KEY: v1');
});
});
describe('projectCompression (CompressionMessage parity, R1-7/76)', () => {
it('covers pending/compressed/estimated/failed/error/noop states', () => {
expect(projectCompression({ isPending: true })).toBe(
'Compressing chat history',
);
expect(
projectCompression({
compressionStatus: CompressionStatus.COMPRESSED,
originalTokenCount: 100,
newTokenCount: 40,
}),
).toBe('Chat history compressed from 100 to 40 tokens.');
expect(
projectCompression({
compressionStatus: CompressionStatus.COMPRESSED,
originalTokenCount: 100,
newTokenCount: 40,
originalTokenCountIsEstimated: true,
newTokenCountIsEstimated: true,
}),
).toBe('Chat history compressed from ~100 to ~40 tokens.');
expect(
projectCompression({
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
originalTokenCount: 1000,
}),
).toBe('Compression was not beneficial for this history size.');
expect(
projectCompression({
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
originalTokenCount: 60000,
}),
).toContain('compression prompt');
expect(
projectCompression({
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR,
}),
).toBe('Could not compress chat history due to a token counting error.');
expect(
projectCompression({ compressionStatus: CompressionStatus.NOOP }),
).toBe('Nothing to compress.');
});
});
describe('projectStats and the savings-tip placement (R1-86)', () => {
const stats = {
sessionId: 's1',
sessionStartTime: new Date(),
metrics: makeMetrics(),
lastPromptTokenCount: 0,
promptCount: 1,
} as unknown as SessionStatsState;
it('titles the /stats projection and renders the shared sections', () => {
const text = projectStats('9m', stats);
expect(text.startsWith('Session Stats')).toBe(true);
expect(text).toContain('Interaction Summary');
expect(text).toContain('Performance');
expect(text).toContain('Model Usage');
});
it('shows the /stats-model tip only inside the savings block', () => {
// cached=100/prompt=1000 in makeMetrics → 10% cache efficiency.
expect(projectStats('9m', stats)).toContain(
'» Tip: For a full token breakdown, run `/stats model`.',
);
const metrics = makeMetrics();
for (const model of Object.values(metrics.models)) {
model.tokens.cached = 0;
}
const zeroCache = { ...stats, metrics } as unknown as SessionStatsState;
const text = projectStats('9m', zeroCache);
expect(text).toContain('Model Usage');
expect(text).not.toContain('Tip:');
});
});
describe('dispatcher coverage for compression/stats items (R1-7)', () => {
it('projects compression and stats history items', () => {
expect(
projectSpecialItemText(
{
type: 'compression',
compression: {
isPending: false,
originalTokenCount: 100,
newTokenCount: 40,
compressionStatus: CompressionStatus.COMPRESSED,
},
},
{},
),
).toBe('Chat history compressed from 100 to 40 tokens.');
const statsText = projectSpecialItemText(
{ type: 'stats', duration: '9m' },
{},
);
expect(statsText).toContain('Session Stats');
expect(statsText).toContain('Session duration: 9m');
});
});
File diff suppressed because it is too large. Load diff
+201
View File
@@ -0,0 +1,201 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies the OpenTUI keyboard layer resolves the ORIGINAL keybinding table
* (packages/cli/src/config/keyBindings.ts via ui/keyMatchers) — same
* shortcuts, same semantics as the ink TUI.
*/
import { describe, it, expect } from 'vitest';
import {
Command,
OPENTUI_COMMAND_PRIORITY,
matchesCommand,
resolveCommand,
resolveCommands,
toOriginalKey,
type OpenTuiKeyInput,
} from './key-map.js';
const key = (input: Partial<OpenTuiKeyInput> & { name: string }) => input;
describe('opentui key-map: translation', () => {
it('maps OpenTUI key events onto the original Key shape', () => {
expect(toOriginalKey({ name: 'return', sequence: '\r' })).toEqual({
name: 'return',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\r',
});
});
it('folds the macOS Option flag into meta like the original', () => {
expect(toOriginalKey({ name: 't', option: true }).meta).toBe(true);
expect(toOriginalKey({ name: 't', meta: true }).meta).toBe(true);
});
it("normalizes opentui's kitty 'kpenter' onto the original 'return' (R2-44)", () => {
expect(
toOriginalKey({ name: 'kpenter', sequence: '\x1b[57414u' }).name,
).toBe('return');
expect(
resolveCommand(key({ name: 'kpenter', sequence: '\x1b[57414u' })),
).toBe(Command.SUBMIT);
});
});
describe('opentui key-map: submit / newline parity (InputPrompt)', () => {
it('bare Enter is SUBMIT and not NEWLINE', () => {
expect(matchesCommand(Command.SUBMIT, key({ name: 'return' }))).toBe(true);
expect(matchesCommand(Command.NEWLINE, key({ name: 'return' }))).toBe(
false,
);
});
it('Shift/Ctrl/Meta+Enter and Ctrl+J are NEWLINE, not SUBMIT', () => {
for (const newline of [
key({ name: 'return', shift: true }),
key({ name: 'return', ctrl: true }),
key({ name: 'return', meta: true }),
key({ name: 'j', ctrl: true }),
]) {
expect(matchesCommand(Command.NEWLINE, newline)).toBe(true);
expect(matchesCommand(Command.SUBMIT, newline)).toBe(false);
}
});
});
describe('opentui key-map: history navigation parity', () => {
it('Ctrl+P / Ctrl+N are HISTORY_UP / HISTORY_DOWN', () => {
expect(
matchesCommand(Command.HISTORY_UP, key({ name: 'p', ctrl: true })),
).toBe(true);
expect(
matchesCommand(Command.HISTORY_DOWN, key({ name: 'n', ctrl: true })),
).toBe(true);
});
it('bare arrows are NAVIGATION_UP / NAVIGATION_DOWN', () => {
expect(matchesCommand(Command.NAVIGATION_UP, key({ name: 'up' }))).toBe(
true,
);
expect(matchesCommand(Command.NAVIGATION_DOWN, key({ name: 'down' }))).toBe(
true,
);
expect(
matchesCommand(Command.NAVIGATION_UP, key({ name: 'up', shift: true })),
).toBe(false);
});
});
describe('opentui key-map: global shortcuts (AppContainer)', () => {
it('Ctrl+O and Alt+T toggle thinking expansion', () => {
expect(
matchesCommand(
Command.TOGGLE_THINKING_EXPANDED,
key({ name: 'o', ctrl: true }),
),
).toBe(true);
expect(
matchesCommand(
Command.TOGGLE_THINKING_EXPANDED,
key({ name: 't', option: true }),
),
).toBe(true);
});
it('Ctrl+T toggles tool descriptions', () => {
expect(
matchesCommand(
Command.TOGGLE_TOOL_DESCRIPTIONS,
key({ name: 't', ctrl: true }),
),
).toBe(true);
});
it('Ctrl+S shows more lines', () => {
expect(
matchesCommand(Command.SHOW_MORE_LINES, key({ name: 's', ctrl: true })),
).toBe(true);
});
it('Escape, Ctrl+C and Ctrl+D resolve to ESCAPE / QUIT / EXIT', () => {
expect(matchesCommand(Command.ESCAPE, key({ name: 'escape' }))).toBe(true);
expect(matchesCommand(Command.QUIT, key({ name: 'c', ctrl: true }))).toBe(
true,
);
expect(matchesCommand(Command.EXIT, key({ name: 'd', ctrl: true }))).toBe(
true,
);
});
it('Ctrl+Q queues and Ctrl+L clears the screen', () => {
expect(
matchesCommand(Command.QUEUE_MESSAGE, key({ name: 'q', ctrl: true })),
).toBe(true);
expect(
matchesCommand(Command.CLEAR_SCREEN, key({ name: 'l', ctrl: true })),
).toBe(true);
});
});
describe('opentui key-map: priority resolution', () => {
it('Ctrl+C resolves to QUIT ahead of CLEAR_INPUT', () => {
expect(resolveCommand(key({ name: 'c', ctrl: true }))).toBe(Command.QUIT);
expect(OPENTUI_COMMAND_PRIORITY).toContain(Command.QUIT);
expect(OPENTUI_COMMAND_PRIORITY.indexOf(Command.QUIT)).toBeLessThan(
OPENTUI_COMMAND_PRIORITY.indexOf(Command.CLEAR_INPUT),
);
});
it('Escape resolves to ESCAPE', () => {
expect(resolveCommand(key({ name: 'escape' }))).toBe(Command.ESCAPE);
});
it('bare Enter resolves to SUBMIT', () => {
expect(resolveCommand(key({ name: 'return' }))).toBe(Command.SUBMIT);
});
it('Shift+Enter resolves to NEWLINE', () => {
expect(resolveCommand(key({ name: 'return', shift: true }))).toBe(
Command.NEWLINE,
);
});
it('Ctrl+O / Ctrl+T / Ctrl+S resolve to their toggles', () => {
expect(resolveCommand(key({ name: 'o', ctrl: true }))).toBe(
Command.TOGGLE_THINKING_EXPANDED,
);
expect(resolveCommand(key({ name: 't', ctrl: true }))).toBe(
Command.TOGGLE_TOOL_DESCRIPTIONS,
);
expect(resolveCommand(key({ name: 's', ctrl: true }))).toBe(
Command.SHOW_MORE_LINES,
);
});
it('plain printable characters resolve to nothing', () => {
expect(resolveCommand(key({ name: 'x', sequence: 'x' }))).toBeUndefined();
expect(resolveCommand(key({ name: '/', sequence: '/' }))).toBeUndefined();
});
it('resolveCommands exposes ink’s Ctrl+C fan-out: QUIT and CLEAR_INPUT (R2-45)', () => {
// ink broadcasts every keypress to all subscribers, so Ctrl+C fires
// both AppContainer’s QUIT handler and BaseTextInput’s CLEAR_INPUT.
const commands = resolveCommands(key({ name: 'c', ctrl: true }));
expect(commands).toContain(Command.QUIT);
expect(commands).toContain(Command.CLEAR_INPUT);
expect(commands[0]).toBe(Command.QUIT);
});
it('resolveCommands returns all matches in priority order for plain keys', () => {
expect(resolveCommands(key({ name: 'return' }))).toEqual([Command.SUBMIT]);
expect(resolveCommands(key({ name: 'x', sequence: 'x' }))).toEqual([]);
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Keyboard parity layer for the OpenTUI renderer (PR1 slice 1).
*
* The original ink TUI drives every shortcut from the data-driven
* `defaultKeyBindings` table (packages/cli/src/config/keyBindings.ts) via the
* matchers in `ui/keyMatchers.ts`. Instead of re-implementing those tables,
* this module translates an OpenTUI `KeyEvent` into the original `Key` shape
* and runs it through the ORIGINAL matchers, so the OpenTUI TUI registers the
* exact same key behavior (Enter/Shift+Enter/Ctrl+J, history ↑↓/Ctrl+P/N,
* Ctrl+O/Ctrl+T/Ctrl+S, Ctrl+C/D, Esc, Ctrl+L, ...) as the ink TUI — any
* future keybinding change in the original table is picked up automatically.
*
* Pure + unit-testable; no renderer imports.
*/
import { Command, keyMatchers } from '../keyMatchers.js';
import type { KeyMatchers } from '../keyMatchers.js';
import type { Key } from '../contexts/KeypressContext.js';
export { Command };
/** The subset of an OpenTUI `KeyEvent` that key matching needs. */
export interface OpenTuiKeyInput {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
/** macOS Option flag reported by the kitty protocol parser. */
option?: boolean;
/** kitty protocol Super (Cmd) flag — the original KeypressContext folds
* ALT|SUPER into its single `meta` flag. */
super?: boolean;
sequence?: string;
/** Bracketed-paste delivery (OpenTUI routes pastes via a separate event). */
paste?: boolean;
}
/**
* Key names @opentui/core emits differently from the original readline
* parser. Under the kitty protocol opentui prefixes the keypad cluster with
* 'kp' (kittyKeyMap 57414-57426) while the original parser maps those
* codepoints to the plain names (KeypressContext); without the aliases the
* whole keypad navigation cluster matches no binding in kitty mode.
*/
const OPENTUI_KEY_NAME_ALIASES: Readonly<Record<string, string>> = {
kpenter: 'return',
kpleft: 'left',
kpright: 'right',
kpup: 'up',
kpdown: 'down',
kppageup: 'pageup',
kppagedown: 'pagedown',
kphome: 'home',
kpend: 'end',
kpinsert: 'insert',
kpdelete: 'delete',
};
/**
* Maps an OpenTUI key event onto the original qwen-code `Key` shape consumed
* by `ui/keyMatchers.ts`. OpenTUI already emits the same key names the
* original readline parser uses ('return', 'escape', 'up', 'backspace', ...)
* modulo the known aliases above, so only modifier normalization is needed:
* the original single `meta` flag covers Alt/Option/Super (and the
* `command` binding column), so Option and Super fold into `meta` exactly
* like the original KeypressContext folds ALT|SUPER for terminals.
*/
export function toOriginalKey(input: OpenTuiKeyInput): Key {
return {
name: OPENTUI_KEY_NAME_ALIASES[input.name] ?? input.name,
ctrl: !!input.ctrl,
meta: !!(input.meta || input.option || input.super),
shift: !!input.shift,
paste: !!input.paste,
sequence: input.sequence ?? '',
};
}
/** Whether the key matches one specific original command binding. */
export function matchesCommand(
command: Command,
input: OpenTuiKeyInput,
matchers: KeyMatchers = keyMatchers,
): boolean {
return matchers[command](toOriginalKey(input));
}
/**
* Commands the OpenTUI TUI handles in slice 1, in the priority the original
* app evaluates them (AppContainer global keys first, then InputPrompt).
* The first match wins, mirroring the original short-circuit order.
*/
export const OPENTUI_COMMAND_PRIORITY: readonly Command[] = [
Command.QUIT,
Command.EXIT,
Command.ESCAPE,
Command.TOGGLE_THINKING_EXPANDED,
Command.TOGGLE_TOOL_DESCRIPTIONS,
Command.TOGGLE_IDE_CONTEXT_DETAIL,
Command.SHOW_MORE_LINES,
Command.CLEAR_SCREEN,
Command.CLEAR_INPUT,
Command.QUEUE_MESSAGE,
Command.RETRY_LAST,
Command.HISTORY_UP,
Command.HISTORY_DOWN,
Command.NAVIGATION_UP,
Command.NAVIGATION_DOWN,
Command.SUBMIT,
Command.NEWLINE,
];
/**
* All original commands a key triggers, in priority order. Ink broadcasts
* every keypress to all subscribers (KeypressContext), so one key can fan
* out to several consumers — Ctrl+C binds both QUIT (keyBindings.ts) and
* CLEAR_INPUT; consumers acting on the `resolveCommand` winner must also
* apply the co-triggered commands (e.g. clear the input buffer on QUIT)
* to match ink's net behavior.
*/
export function resolveCommands(
input: OpenTuiKeyInput,
matchers: KeyMatchers = keyMatchers,
): Command[] {
const key = toOriginalKey(input);
const matches: Command[] = [];
for (const command of OPENTUI_COMMAND_PRIORITY) {
if (matchers[command](key)) {
matches.push(command);
}
}
return matches;
}
/**
* The highest-priority original command a key triggers, or `undefined` when
* the key is plain text input (or unbound in slice 1). See `resolveCommands`
* for keys that fan out to several consumers.
*/
export function resolveCommand(
input: OpenTuiKeyInput,
matchers: KeyMatchers = keyMatchers,
): Command | undefined {
return resolveCommands(input, matchers)[0];
}
@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Tests for the headless kitty-keyboard negotiation probe: terminals that
* answer `\x1b[?u` get kitty mode; terminals that only answer the device
* attributes query, never answer, or are not TTYs fall back to legacy input
* (probe resolves false → `useKittyKeyboard: null`).
*/
import { EventEmitter } from 'node:events';
import { describe, expect, it } from 'vitest';
import { probeKittyKeyboardSupport } from './kitty-negotiation.js';
interface FakeStdin extends EventEmitter {
isTTY: boolean;
isRaw: boolean;
setRawMode(raw: boolean): void;
}
function makeStdin(isTTY = true): FakeStdin {
const stdin = new EventEmitter() as FakeStdin;
stdin.isTTY = isTTY;
stdin.isRaw = false;
stdin.setRawMode = (raw: boolean) => {
stdin.isRaw = raw;
};
return stdin;
}
function makeStdout(isTTY = true): {
isTTY: boolean;
writes: string[];
write(chunk: string): boolean;
} {
const writes: string[] = [];
return {
isTTY,
writes,
write(chunk: string) {
writes.push(chunk);
return true;
},
};
}
describe('probeKittyKeyboardSupport', () => {
it('resolves true when the terminal answers the kitty query', async () => {
const stdin = makeStdin();
const stdout = makeStdout();
const probe = probeKittyKeyboardSupport({
stdin,
stdout,
timeoutMs: 500,
});
stdin.emit('data', Buffer.from('\x1b[?1u'));
await expect(probe).resolves.toBe(true);
expect(stdout.writes).toContain('\x1b[?u');
});
it('resolves false when only a device-attributes reply arrives', async () => {
const stdin = makeStdin();
const stdout = makeStdout();
const probe = probeKittyKeyboardSupport({
stdin,
stdout,
timeoutMs: 500,
});
stdin.emit('data', Buffer.from('\x1b[?62;22c'));
await expect(probe).resolves.toBe(false);
});
it('resolves false on timeout when the terminal never answers', async () => {
const stdin = makeStdin();
const stdout = makeStdout();
await expect(
probeKittyKeyboardSupport({
stdin,
stdout,
timeoutMs: 20,
}),
).resolves.toBe(false);
expect(stdout.writes).toContain('\x1b[?u');
expect(stdout.writes).toContain('\x1b[c');
});
it('resolves false without querying when stdin is not a TTY', async () => {
const stdin = makeStdin(false);
const stdout = makeStdout();
await expect(probeKittyKeyboardSupport({ stdin, stdout })).resolves.toBe(
false,
);
expect(stdout.writes).toEqual([]);
});
it('resolves false without querying when stdout is not a TTY', async () => {
const stdin = makeStdin();
const stdout = makeStdout(false);
await expect(probeKittyKeyboardSupport({ stdin, stdout })).resolves.toBe(
false,
);
expect(stdout.writes).toEqual([]);
});
it('restores raw mode after the probe', async () => {
const stdin = makeStdin();
const stdout = makeStdout();
const probe = probeKittyKeyboardSupport({
stdin,
stdout,
timeoutMs: 500,
});
expect(stdin.isRaw).toBe(true); // enabled while probing
stdin.emit('data', Buffer.from('\x1b[?1u'));
await probe;
expect(stdin.isRaw).toBe(false);
});
it('keeps the probe detection-only (never pushes kitty flags)', async () => {
const stdin = makeStdin();
const stdout = makeStdout();
const probe = probeKittyKeyboardSupport({
stdin,
stdout,
timeoutMs: 500,
});
stdin.emit('data', Buffer.from('\x1b[?1u'));
await probe;
expect(stdout.writes).not.toContain('\x1b[>1u');
expect(stdout.writes).not.toContain('\x1b[<u');
});
it('does not resolve on an echo of the probe query itself (no flags)', async () => {
// Echo environments (PTY harnesses, canonical-mode CI) replay stdout
// into stdin; the bare query \x1b[?u has no flags parameter and must
// not count as a kitty reply.
const stdin = makeStdin();
const stdout = makeStdout();
const probe = probeKittyKeyboardSupport({
stdin,
stdout,
timeoutMs: 20,
});
stdin.emit('data', Buffer.from('\x1b[?u'));
await expect(probe).resolves.toBe(false);
});
it('bounds the accumulation window under byte floods', async () => {
// A PTY streaming non-matching bytes must not grow the buffer or slow
// the probe; the timeout still settles the probe.
const stdin = makeStdin();
const stdout = makeStdout();
const probe = probeKittyKeyboardSupport({
stdin,
stdout,
timeoutMs: 20,
});
for (let i = 0; i < 100; i++) {
stdin.emit('data', Buffer.from('x'.repeat(1024)));
}
await expect(probe).resolves.toBe(false);
});
it('still resolves true when the reply is split across chunks', async () => {
const stdin = makeStdin();
const stdout = makeStdout();
const probe = probeKittyKeyboardSupport({
stdin,
stdout,
timeoutMs: 500,
});
stdin.emit('data', Buffer.from('\x1b'));
stdin.emit('data', Buffer.from('[?1'));
stdin.emit('data', Buffer.from('u'));
await expect(probe).resolves.toBe(true);
});
});
@@ -0,0 +1,148 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Headless-safe kitty keyboard negotiation for the OpenTUI renderer.
*
* The @opentui framework enables the kitty keyboard protocol itself when
* `useKittyKeyboard` is set, emitting `\x1b[?u` capability queries at
* startup. Terminals (and PTY harnesses/CI) that never answer those queries
* left the renderer's input negotiation in a state where legacy keystrokes
* were not accepted at all (audit G-02). The CLI layer cannot reach into the
* framework's negotiation, but it CAN decide whether kitty mode is switched
* on in the first place: this probe mirrors the ink side's
* `kittyProtocolDetector` approach — query once with a hard 200ms timeout —
* and `opentui-entry` passes `useKittyKeyboard: null` when the terminal
* does not answer, so legacy key input always works in headless/no-reply
* environments (at the cost of kitty-only distinctions like Shift+Enter
* reporting there — the same trade-off ink makes).
*
* The probe is detection-only: it never pushes kitty flags itself, so the
* framework (or ink) remains the sole owner of the protocol flag stack.
*/
/** Minimal structural stdin surface the probe needs (EventEmitter-compatible). */
export interface ProbeStdin {
isTTY?: boolean;
isRaw?: boolean;
setRawMode?(mode: boolean): void;
on(event: 'data', listener: (data: Buffer | string) => void): unknown;
removeListener(
event: 'data',
listener: (data: Buffer | string) => void,
): unknown;
}
/** Minimal structural stdout surface the probe needs. */
export interface ProbeStdout {
isTTY?: boolean;
write(chunk: string): unknown;
}
export interface KittyProbeOptions {
/** How long to wait for a terminal reply before giving up (default 200ms,
* matching ink's kittyProtocolDetector threshold). */
timeoutMs?: number;
/** Injectable for tests. */
stdin?: ProbeStdin;
stdout?: ProbeStdout;
}
const KITTY_QUERY = '\x1b[?u'; // progressive-enhancement query
const DEVICE_ATTRIBUTES_QUERY = '\x1b[c'; // primary DA liveness query
// Kitty answers CSI ? u with CSI ? <flags> u. Real replies always carry a
// flags parameter (\x1b[?0u even with no flags), so \d+ excludes the probe's
// own query — in echo environments (PTY harnesses, CI) a canonical echo of
// KITTY_QUERY would otherwise match and lock the renderer into kitty mode
// on a terminal that never answers queries.
// eslint-disable-next-line no-control-regex
const KITTY_REPLY_RE = /\x1b\[\?\d+u/;
// Any primary-device-attributes reply (CSI ? … c) means a terminal answered
// but does not speak kitty. DA_REPLY_RE needs no echo guard: its query
// (\x1b[c) has no `?`, so the echoed query cannot match.
// eslint-disable-next-line no-control-regex
const DA_REPLY_RE = /\x1b\[\?[0-9;]*c/;
// Genuine kitty/DA replies are tens of bytes. The probe keeps a small tail
// window instead of the full stream so a PTY pushing non-matching bytes
// during the probe window costs bounded memory and O(window) rescans, not
// unbounded growth plus O(n) regex over the whole accumulation.
const BUFFER_TAIL_BYTES = 256;
/**
* Resolves true when the terminal answers the kitty keyboard query within
* the timeout. Resolves false on timeout, on a DA-only reply, or when
* stdin/stdout is not a TTY (piped/headless runs never get kitty mode).
*/
export async function probeKittyKeyboardSupport(
options?: KittyProbeOptions,
): Promise<boolean> {
const timeoutMs = options?.timeoutMs ?? 200;
const stdin = options?.stdin ?? process.stdin;
const stdout = options?.stdout ?? process.stdout;
if (!stdin.isTTY || !stdout.isTTY) {
return false;
}
const originalRawMode = stdin.isRaw ?? false;
if (!originalRawMode && typeof stdin.setRawMode === 'function') {
stdin.setRawMode(true);
}
return new Promise<boolean>((resolve) => {
let buffer = '';
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const finish = (supported: boolean): void => {
if (settled) return;
settled = true;
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
stdin.removeListener('data', onData);
// Late replies are NOT consumed by this probe: an EventEmitter data
// listener cannot "eat" chunks from other listeners, so an empty drain
// would only discard bytes while the renderer has no listener yet.
// Instead, replies arriving after settlement flow to the renderer's
// input parser like any other terminal noise; genuine kitty/DA reply
// shapes are filtered by the key parser, not quarantined here.
if (!originalRawMode && typeof stdin.setRawMode === 'function') {
stdin.setRawMode(false);
}
resolve(supported);
};
const onData = (data: Buffer | string): void => {
buffer = (buffer + data.toString()).slice(-BUFFER_TAIL_BYTES);
if (KITTY_REPLY_RE.test(buffer)) {
finish(true);
return;
}
if (DA_REPLY_RE.test(buffer)) {
// A terminal answered, but it does not speak kitty.
finish(false);
}
};
stdin.on('data', onData);
// A synchronous write throw (e.g. ERR_STREAM_DESTROYED while isTTY
// still reports true) must still settle the probe: finish(false)
// restores raw mode and removes the data listener instead of leaking.
try {
stdout.write(KITTY_QUERY);
stdout.write(DEVICE_ATTRIBUTES_QUERY);
} catch {
finish(false);
return;
}
timeoutId = setTimeout(() => finish(false), timeoutMs);
timeoutId.unref?.();
});
}
@@ -0,0 +1,239 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import {
decodeRowCells,
extractUrlHits,
findUrlAtRow,
readBufferRow,
type CellGrid,
} from './link-click.js';
function gridFromRows(rows: string[]): CellGrid {
const width = Math.max(...rows.map((r) => r.length), 1);
const char = new Uint32Array(width * rows.length);
rows.forEach((row, y) => {
for (let x = 0; x < row.length; x++) {
char[y * width + x] = row.codePointAt(x) ?? 0;
}
});
return { buffers: { char }, width, height: rows.length };
}
/**
* Minimal east-asian-wide check — enough for the test fixtures ('文' is
* U+6587: BMP, but two columns wide).
*/
function isWideChar(cp: number): boolean {
return (
(cp >= 0x1100 && cp <= 0x115f) ||
(cp >= 0x2e80 && cp <= 0xa4cf) ||
(cp >= 0xac00 && cp <= 0xd7a3) ||
(cp >= 0xf900 && cp <= 0xfaff) ||
(cp >= 0xfe30 && cp <= 0xfe4f) ||
(cp >= 0xff00 && cp <= 0xff60) ||
(cp >= 0xffe0 && cp <= 0xffe6) ||
cp > 0xffff
);
}
/**
* Native-buffer simulation: `char` holds flag-tagged sentinel values (not
* code points) and text resolves through `getRealCharBytes`, mirroring the
* zig-backed OptimizedBuffer encoding.
*/
function nativeGridFromLines(lines: string[]): CellGrid {
const displayWidth = (line: string) =>
[...line].reduce(
(w, ch) => w + (isWideChar(ch.codePointAt(0)!) ? 2 : 1),
0,
);
const width = Math.max(...lines.map(displayWidth), 1);
const char = new Uint32Array(width * lines.length).fill(0x800100ff);
lines.forEach((line, y) => {
let x = 0;
for (const ch of line) {
if (isWideChar(ch.codePointAt(0)!)) {
char[y * width + x + 1] = 0xc0000001; // wide-char continuation
}
x += isWideChar(ch.codePointAt(0)!) ? 2 : 1;
}
});
return {
buffers: { char },
width,
height: lines.length,
getRealCharBytes: (addLineBreaks = true) =>
new TextEncoder().encode(
addLineBreaks ? lines.join('\n') : lines.join(''),
),
};
}
describe('readBufferRow', () => {
it('reads a plain ASCII row', () => {
const row = readBufferRow(gridFromRows(['see https://a.dev now']), 0);
expect(row.text).toBe('see https://a.dev now');
expect(row.cellColumns.slice(4, 8)).toEqual([4, 5, 6, 7]);
});
it('skips zero cells (wide-char continuation / untouched)', () => {
// '文' occupies cell 0; cell 1 is its zero continuation.
const grid: CellGrid = {
buffers: {
char: Uint32Array.from([0x6587, 0, 0x68, 0x69]), // 文 h i
},
width: 4,
height: 1,
};
const row = readBufferRow(grid, 0);
expect(row.text).toBe('文hi');
expect(row.cellColumns).toEqual([0, 2, 3]);
});
it('trims trailing whitespace and returns empty for out-of-range rows', () => {
const grid = gridFromRows(['abc ']);
expect(readBufferRow(grid, 0).text).toBe('abc');
expect(readBufferRow(grid, 5).text).toBe('');
expect(readBufferRow(grid, -1).text).toBe('');
});
it('decodes native flag-tagged cells through getRealCharBytes', () => {
// Regression: native char cells hold flag bits (e.g. 0x800100FF
// sentinels, 0xC0000000 continuation marks), not code points — decoding
// them directly crashed with a code-point RangeError.
const grid = nativeGridFromLines(['hi 文 there']);
const row = readBufferRow(grid, 0);
expect(row.text).toBe('hi 文 there');
// '文' occupies columns 3-4; the continuation cell is skipped.
expect(row.cellColumns).toEqual([0, 1, 2, 3, 5, 6, 7, 8, 9, 10]);
});
it('decodeRowCells marks continuation cells and spaces on native grids', () => {
const grid = nativeGridFromLines(['a文b']);
expect(decodeRowCells(grid, 0)).toEqual(['a', '文', '', 'b']);
expect(decodeRowCells(grid, -1)).toBeNull();
expect(decodeRowCells(grid, 1)).toBeNull();
});
it('pushes one cellColumns entry per UTF-16 unit for non-BMP cells (R1-88)', () => {
// The emoji is a single cell but occupies two UTF-16 units in `text`;
// findUrlAtRow indexes cellColumns with UTF-16 offsets, so both units
// must map back to the emoji's cell column.
const grid: CellGrid = {
buffers: {
char: Uint32Array.from([
0x1f600,
0x20,
...'https://a.dev'.split('').map((c) => c.codePointAt(0)!),
]),
},
width: 15,
height: 1,
};
const row = readBufferRow(grid, 0);
expect(row.text).toBe('😀 https://a.dev');
expect(row.cellColumns).toEqual([
0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14,
]);
expect(findUrlAtRow(row, 2)?.url).toBe('https://a.dev');
expect(findUrlAtRow(row, 14)?.url).toBe('https://a.dev');
expect(findUrlAtRow(row, 1)).toBeNull(); // on the space cell
expect(findUrlAtRow(row, 15)).toBeNull(); // past the row
});
});
describe('extractUrlHits', () => {
it('finds scheme URLs and www matches', () => {
const hits = extractUrlHits('a https://a.dev/path b www.b.io c');
expect(hits.map((h) => h.url)).toEqual([
'https://a.dev/path',
'https://www.b.io',
]);
});
it('renders markdown links as "label (url)" — the url half is hit-able', () => {
const hits = extractUrlHits('Docs (https://docs.example.com/x) end');
expect(hits).toHaveLength(1);
expect(hits[0]!.url).toBe('https://docs.example.com/x');
});
it('trims trailing punctuation', () => {
const hits = extractUrlHits('see https://a.dev/x, then https://b.dev/y.');
expect(hits.map((h) => h.url)).toEqual([
'https://a.dev/x',
'https://b.dev/y',
]);
});
it('keeps balanced parentheses inside the URL', () => {
const hits = extractUrlHits('https://en.wikipedia.org/wiki/Foo_(bar)');
expect(hits[0]!.url).toBe('https://en.wikipedia.org/wiki/Foo_(bar)');
});
it('refuses unsafe schemes', () => {
expect(extractUrlHits('javascript:alert(1)')).toEqual([]);
expect(extractUrlHits('file:///etc/passwd')).toEqual([]);
});
it('stops at quotes and backticks', () => {
const hits = extractUrlHits('`https://a.dev/x` "https://b.dev/y"');
expect(hits.map((h) => h.url)).toEqual([
'https://a.dev/x',
'https://b.dev/y',
]);
});
it('stops the body at CJK/fullwidth punctuation glued to the URL', () => {
// The shared linkification break set (osc8 BARE_URL_BREAK_CHARACTERS):
// the ASCII-only trailing trimmer cannot remove fullwidth punctuation,
// so the grammar itself must stop there.
expect(extractUrlHits('文档 https://example.com/docs。其余文字')).toEqual([
expect.objectContaining({ url: 'https://example.com/docs' }),
]);
expect(extractUrlHits('见https://example.com/a,然后')).toEqual([
expect.objectContaining({ url: 'https://example.com/a' }),
]);
expect(extractUrlHits('(见https://example.com/x)')).toEqual([
expect.objectContaining({ url: 'https://example.com/x' }),
]);
});
});
describe('findUrlAtRow', () => {
it('hits inside the URL and misses outside', () => {
const row = readBufferRow(gridFromRows(['see https://a.dev now']), 0);
expect(findUrlAtRow(row, 6)?.url).toBe('https://a.dev');
expect(findUrlAtRow(row, 0)).toBeNull(); // on 's'
expect(findUrlAtRow(row, 17)).toBeNull(); // on 'n' of 'now'
});
it('hit-tests in cell space when wide characters precede the URL', () => {
// '文档 ' takes cells 0-3 (文=0,1 档=2,3), space at cell 4, url from 5.
const grid: CellGrid = {
buffers: {
char: Uint32Array.from([
0x6587,
0,
0x6863,
0,
0x20,
...'https://a.dev'.split('').map((c) => c.codePointAt(0)!),
]),
},
width: 18,
height: 1,
};
const row = readBufferRow(grid, 0);
expect(findUrlAtRow(row, 5)?.url).toBe('https://a.dev');
expect(findUrlAtRow(row, 4)).toBeNull();
});
it('returns null on empty rows', () => {
expect(findUrlAtRow({ text: '', cellColumns: [] }, 3)).toBeNull();
});
});
+193
View File
@@ -0,0 +1,193 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* App-level URL detection behind OpenTUI click-to-open (audit gap #54,
* option 1). The framework renders markdown links as `label (url)` plain
* text and does not emit OSC 8 in any @opentui 0.5.x release, while
* `useMouse: true` makes the terminal hand pointer events to the app —
* terminal-native cmd+click link handling included. Clicks that land on a
* URL cell are therefore opened here. Terminal-side OSC 8 remains a
* follow-up pending framework support (it already ships `detectLinks` +
* `caps.hyperlinks` groundwork).
*
* Security reuses the ink OSC 8 constraints (scheme allowlist, trailing
* punctuation trimming) from `../utils/osc8.js`.
*
* Known boundary: URLs wrapped across buffer rows are not stitched back
* together — the same class of limitation terminal auto-detection has.
*/
import stringWidth from 'string-width';
import {
BARE_URL_BREAK_CHARACTERS,
isSafeOscScheme,
trimTrailingUrlPunctuation,
} from '../utils/osc8.js';
export interface UrlHit {
/** Openable URL; `www.` matches are normalized to `https://`. */
url: string;
/** The matched text exactly as rendered. */
text: string;
/** String index range [start, end) of `text` within the row text. */
start: number;
end: number;
}
/** Structural slice of OptimizedBuffer so tests don't need the native lib. */
export interface CellGrid {
buffers: { char: Uint32Array };
width: number;
height: number;
/**
* Native buffers carry flag bits (not code points) in `char` and resolve
* cell text through this API; plain-code-point stubs omit it.
*/
getRealCharBytes?(addLineBreaks?: boolean): Uint8Array;
}
/** A buffer row as text, with the source cell column of each character. */
export interface BufferRow {
text: string;
/** cellColumns[i] is the terminal cell column of text[i]. */
cellColumns: number[];
}
// `scheme://…` or `www.…`, stopped by whitespace, quotes, backticks, control
// bytes, and the shared linkification break set (CJK/fullwidth punctuation
// glued to the URL — the same body grammar as osc8's BARE_URL_PATTERN, whose
// ASCII-only trailing trimmer cannot remove fullwidth punctuation). Trailing
// ASCII punctuation (and unsafe schemes) are filtered by the osc8 helpers
// afterwards, mirroring the ink renderer.
const URL_PATTERN = new RegExp(
`(?:[a-zA-Z][a-zA-Z0-9+.-]*://|www\\.)[^\\s<>"'\`\\u0000-\\u001f\\u007f${BARE_URL_BREAK_CHARACTERS}]+`,
'g',
);
/**
* High bits of native cell values are flags, not code points: 0xC0000000
* marks a wide-character continuation (spacer) cell. Mirrors the flag
* handling in @opentui/core's `OptimizedBuffer.getSpanLines`.
*/
const CHAR_FLAG_CONTINUATION = 0xc0000000;
function isContinuationCell(codePoint: number): boolean {
// `&` yields a signed int32 (0xC0000000 reads as negative there), so
// coerce back to unsigned before comparing with the hex literal —
// the signed comparison is always false and lets continuation cells
// leak through as real characters.
return (codePoint & CHAR_FLAG_CONTINUATION) >>> 0 === CHAR_FLAG_CONTINUATION;
}
/**
* Decoded character of every cell on a row, left to right; spacer and
* untouched cells read as `''` on stub grids, `' '` on native grids (the
* resolved text has no holes, so untouched cells fall back to a space).
*/
export function decodeRowCells(grid: CellGrid, y: number): string[] | null {
if (y < 0 || y >= grid.height) return null;
const chars = grid.buffers.char;
const base = y * grid.width;
const cells: string[] = new Array(grid.width).fill('');
if (typeof grid.getRealCharBytes === 'function') {
const realLines = new TextDecoder()
.decode(grid.getRealCharBytes(true))
.split('\n');
const lineChars = Array.from(realLines[y] ?? '');
let ci = 0;
for (let x = 0; x < grid.width; x++) {
const codePoint = chars[base + x];
cells[x] = isContinuationCell(codePoint) ? '' : (lineChars[ci++] ?? ' ');
}
return cells;
}
for (let x = 0; x < grid.width; x++) {
const codePoint = chars[base + x];
cells[x] =
codePoint > 0 && codePoint <= 0x10ffff && !isContinuationCell(codePoint)
? String.fromCodePoint(codePoint)
: '';
}
return cells;
}
/**
* Extract a buffer row into text. Spacer cells (wide-character
* continuation and untouched cells) are skipped; the mapping back to
* cell columns is preserved so a click column can be matched exactly even
* when CJK characters precede the URL. One entry is pushed per UTF-16
* unit: a non-BMP character (emoji) from a single cell occupies two units
* in `text`, and the hit-test indexes `cellColumns` with UTF-16 offsets.
*/
export function readBufferRow(grid: CellGrid, y: number): BufferRow {
const cells = decodeRowCells(grid, y);
if (!cells) return { text: '', cellColumns: [] };
let text = '';
const cellColumns: number[] = [];
for (let x = 0; x < cells.length; x++) {
if (cells[x] === '') continue;
text += cells[x];
for (let i = 0; i < cells[x].length; i++) cellColumns.push(x);
}
// Trim trailing whitespace from the TEXT only. cellColumns must keep the
// mapping for every character of the untrimmed text: truncating it to the
// trimmed length degrades the hit-test end boundary to the last+1
// fallback, which misses the right-half cell of a wide final glyph.
return { text: text.replace(/\s+$/, ''), cellColumns };
}
/** All safe URL candidates in a rendered row, left to right. */
export function extractUrlHits(rowText: string): UrlHit[] {
const hits: UrlHit[] = [];
URL_PATTERN.lastIndex = 0;
for (const match of rowText.matchAll(URL_PATTERN)) {
const raw = match[0];
const start = match.index ?? 0;
const nextCharacter = rowText[start + raw.length] ?? '';
const trimmed = trimTrailingUrlPunctuation(raw, nextCharacter);
if (!trimmed) continue;
let url: string;
if (/^www\./i.test(trimmed)) {
url = `https://${trimmed}`;
} else if (isSafeOscScheme(trimmed)) {
url = trimmed;
} else {
// `javascript:`, `file:`, unknown schemes… never open those.
continue;
}
hits.push({ url, text: trimmed, start, end: start + trimmed.length });
}
return hits;
}
/**
* The URL covering click column `x` (terminal cells), or null. Comparison
* runs in cell space via `row.cellColumns`, so wide characters earlier in
* the row do not shift the hit test.
*/
export function findUrlAtRow(row: BufferRow, x: number): UrlHit | null {
for (const hit of extractUrlHits(row.text)) {
const startCell = row.cellColumns[hit.start];
let endCellExclusive: number | undefined;
if (hit.end < row.cellColumns.length) {
endCellExclusive = row.cellColumns[hit.end];
} else {
// The URL run reaches the end of the row text. The last character
// may occupy TWO columns (CJK/emoji): a wide final glyph owns both
// its cells, so the boundary is its start column plus its width.
// Use the last code POINT (not UTF-16 unit) so non-BMP emoji
// (surrogate pairs) are measured correctly.
const lastChar = [...row.text.slice(0, hit.end)].at(-1) ?? '';
const lastColumn = row.cellColumns[row.cellColumns.length - 1] ?? 0;
endCellExclusive = lastColumn + (stringWidth(lastChar) || 1);
}
if (startCell === undefined || endCellExclusive === undefined) continue;
if (x >= startCell && x < endCellExclusive) return hit;
}
return null;
}
@@ -0,0 +1,118 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
visualClickToOffset,
type ClickableBufferState,
} from './mouse-caret.js';
function buffer(
lines: string[],
allVisualLines?: string[],
visualToLogicalMap?: Array<[number, number]>,
): ClickableBufferState {
return {
lines,
allVisualLines: allVisualLines ?? lines,
visualToLogicalMap: visualToLogicalMap ?? lines.map((_, i) => [i, 0]),
};
}
describe('mouse-caret: ASCII click-to-offset (input-mouse parity)', () => {
it('maps each cell to its left boundary', () => {
const b = buffer(['hello']);
expect(visualClickToOffset(b, 0, 0)).toBe(0);
expect(visualClickToOffset(b, 0, 1)).toBe(1);
expect(visualClickToOffset(b, 0, 4)).toBe(4);
});
it('clicks at/past the end clamp to the line length', () => {
const b = buffer(['hello']);
expect(visualClickToOffset(b, 0, 5)).toBe(5);
expect(visualClickToOffset(b, 0, 99)).toBe(5);
});
it('returns null for a visual row that maps to nothing', () => {
const b = buffer(['hello']);
expect(visualClickToOffset(b, 1, 0)).toBeNull();
expect(visualClickToOffset(b, 9, 0)).toBeNull();
});
it('empty line always resolves to its (empty) start', () => {
expect(visualClickToOffset(buffer(['']), 0, 0)).toBe(0);
expect(visualClickToOffset(buffer(['']), 0, 5)).toBe(0);
});
});
describe('mouse-caret: wide-character midpoint snap', () => {
// '你好' — two glyphs, two cells each (total width 4).
const b = buffer(['你好']);
it('left half of a wide glyph snaps before it', () => {
expect(visualClickToOffset(b, 0, 0)).toBe(0); // left cell of 你
expect(visualClickToOffset(b, 0, 2)).toBe(1); // left cell of 好
});
it('right half of a wide glyph snaps after it', () => {
expect(visualClickToOffset(b, 0, 1)).toBe(1); // right cell of 你
expect(visualClickToOffset(b, 0, 3)).toBe(2); // right cell of 好
});
it('past the last wide glyph clamps to end of line', () => {
expect(visualClickToOffset(b, 0, 4)).toBe(2);
expect(visualClickToOffset(b, 0, 40)).toBe(2);
});
});
describe('mouse-caret: zero-width marks stay attached to the base glyph', () => {
// 'e\u0301x' — e + combining acute (zero width) + x; renders as 2 cells.
const b = buffer(['e\u0301x']);
it('click on the base cell stays before the grapheme', () => {
expect(visualClickToOffset(b, 0, 0)).toBe(0);
});
it('click past the base cell lands after the full grapheme', () => {
// The combining mark is skipped, so cell 1 belongs to 'x'.
expect(visualClickToOffset(b, 0, 1)).toBe(2);
expect(visualClickToOffset(b, 0, 9)).toBe(3);
});
it('midpoint snap of a wide glyph skips trailing zero-width marks', () => {
// 你 + combining mark: right cell of 你 must land AFTER the mark.
const wideMark = buffer(['\u4f60\u0301y']); // 你 + mark + y
expect(visualClickToOffset(wideMark, 0, 0)).toBe(0);
expect(visualClickToOffset(wideMark, 0, 1)).toBe(2);
expect(visualClickToOffset(wideMark, 0, 2)).toBe(2);
});
});
describe('mouse-caret: multi-line and wrapped lines', () => {
it('offsets include newline separators across logical lines', () => {
const b = buffer(['abc', 'de']);
expect(visualClickToOffset(b, 0, 2)).toBe(2);
expect(visualClickToOffset(b, 1, 0)).toBe(4); // 'abc' + \n
expect(visualClickToOffset(b, 1, 1)).toBe(5);
expect(visualClickToOffset(b, 1, 9)).toBe(6); // clamps to 'de' length
});
it('wrapped visual lines map back into their logical line', () => {
// 'abcdefgh' wrapped at width 4.
const b = buffer(
['abcdefgh'],
['abcd', 'efgh'],
[
[0, 0],
[0, 4],
],
);
expect(visualClickToOffset(b, 0, 3)).toBe(3);
expect(visualClickToOffset(b, 1, 0)).toBe(4);
expect(visualClickToOffset(b, 1, 2)).toBe(6);
expect(visualClickToOffset(b, 1, 99)).toBe(8);
});
});
@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Prompt-click caret placement for the OpenTUI composer (PR1 slice 4).
*
* Re-exports the framework-neutral original (`utils/input-mouse.ts`) so
* width/grapheme fixes land in one place for both renderers instead of
* diverging between a fork and its source.
*/
export {
visualClickToOffset,
type ClickableBufferState,
} from '../utils/input-mouse.js';
@@ -0,0 +1,136 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
frameAnchor,
terminalRowToLayoutRow,
terminalToGrid,
pointInViewport,
clampToViewport,
findItemAtLayoutRow,
resolveListIndex,
hitTestScrollbar,
type ScrollbarGeometry,
type VisibleItemRect,
} from './mouse-hit.js';
describe('mouse-hit: frame anchor (list-mouse parity)', () => {
it('is 0 when the frame fits the terminal (top-anchored)', () => {
expect(frameAnchor(40, 40)).toBe(0);
expect(frameAnchor(40, 20)).toBe(0);
});
it('is negative when the frame overflows (bottom-pinned)', () => {
expect(frameAnchor(40, 50)).toBe(-10);
expect(frameAnchor(24, 100)).toBe(-76);
});
it('maps terminal rows through the anchor', () => {
// Frame fits: row 1 -> layout 0.
expect(terminalRowToLayoutRow(1, frameAnchor(40, 30))).toBe(0);
expect(terminalRowToLayoutRow(5, frameAnchor(40, 30))).toBe(4);
// Frame overflows: top rows scrolled off, anchor -10.
expect(terminalRowToLayoutRow(1, frameAnchor(40, 50))).toBe(10);
expect(terminalRowToLayoutRow(40, frameAnchor(40, 50))).toBe(49);
});
it('terminalToGrid applies col/row - 1 and the anchor', () => {
expect(terminalToGrid(1, 1, 40, 30)).toEqual({ x: 0, y: 0 });
expect(terminalToGrid(7, 3, 40, 50)).toEqual({ x: 6, y: 12 });
});
});
describe('mouse-hit: viewport membership / clamp', () => {
const viewport = { x: 1, y: 2, width: 10, height: 5 };
it('accepts interior points and rejects exterior ones', () => {
expect(pointInViewport({ x: 1, y: 2 }, viewport)).toBe(true);
expect(pointInViewport({ x: 10, y: 6 }, viewport)).toBe(true);
expect(pointInViewport({ x: 0, y: 2 }, viewport)).toBe(false);
expect(pointInViewport({ x: 11, y: 2 }, viewport)).toBe(false);
expect(pointInViewport({ x: 1, y: 1 }, viewport)).toBe(false);
expect(pointInViewport({ x: 1, y: 7 }, viewport)).toBe(false);
});
it('clamps outside points onto the viewport border', () => {
expect(clampToViewport({ x: -5, y: -5 }, viewport)).toEqual({ x: 1, y: 2 });
expect(clampToViewport({ x: 99, y: 99 }, viewport)).toEqual({
x: 10,
y: 6,
});
expect(clampToViewport({ x: 4, y: 3 }, viewport)).toEqual({ x: 4, y: 3 });
});
});
describe('mouse-hit: row hit-testing', () => {
const rects: VisibleItemRect[] = [
{ index: 0, top: 0, height: 1 },
{ index: 1, top: 1, height: 3 }, // multi-line item
{ index: 2, top: 5, height: 1 }, // gap at row 4
];
it('resolves single- and multi-line items', () => {
expect(findItemAtLayoutRow(rects, 0)).toBe(0);
expect(findItemAtLayoutRow(rects, 1)).toBe(1);
expect(findItemAtLayoutRow(rects, 2)).toBe(1);
expect(findItemAtLayoutRow(rects, 3)).toBe(1);
expect(findItemAtLayoutRow(rects, 5)).toBe(2);
});
it('returns null for gaps and outside rows', () => {
expect(findItemAtLayoutRow(rects, 4)).toBeNull();
expect(findItemAtLayoutRow(rects, 6)).toBeNull();
expect(findItemAtLayoutRow(rects, -1)).toBeNull();
});
it('gates interactions outside the container columns', () => {
const geometry = {
container: { x: 2, y: 0, width: 20, height: 6 },
items: rects,
};
expect(resolveListIndex(geometry, { x: 2, y: 0 })).toBe(0);
expect(resolveListIndex(geometry, { x: 21, y: 0 })).toBe(0);
expect(resolveListIndex(geometry, { x: 1, y: 0 })).toBeNull();
expect(resolveListIndex(geometry, { x: 22, y: 0 })).toBeNull();
});
it('skips disabled rows', () => {
const geometry = {
container: { x: 0, y: 0, width: 30, height: 6 },
items: rects,
};
expect(
resolveListIndex(geometry, { x: 5, y: 2 }, (i) => i === 1),
).toBeNull();
expect(resolveListIndex(geometry, { x: 5, y: 0 }, (i) => i === 1)).toBe(0);
});
});
describe('mouse-hit: scrollbar hit-testing (VirtualizedList parity)', () => {
const geometry: ScrollbarGeometry = { col: 79, top: 0, height: 10 };
it('hits only the exact track column within its rows', () => {
// 1-based terminal coordinates.
expect(hitTestScrollbar(geometry, { col: 80, row: 1 })).toBe(true);
expect(hitTestScrollbar(geometry, { col: 80, row: 10 })).toBe(true);
expect(hitTestScrollbar(geometry, { col: 80, row: 11 })).toBe(false);
expect(hitTestScrollbar(geometry, { col: 79, row: 1 })).toBe(false);
expect(hitTestScrollbar(geometry, { col: 81, row: 1 })).toBe(false);
});
it('offset tracks only hit inside their row span', () => {
const offset: ScrollbarGeometry = { col: 10, top: 5, height: 4 };
expect(hitTestScrollbar(offset, { col: 11, row: 5 })).toBe(false);
expect(hitTestScrollbar(offset, { col: 11, row: 6 })).toBe(true);
expect(hitTestScrollbar(offset, { col: 11, row: 9 })).toBe(true);
expect(hitTestScrollbar(offset, { col: 11, row: 10 })).toBe(false);
});
it('no geometry means no hit (content fits the viewport)', () => {
expect(hitTestScrollbar(null, { col: 80, row: 1 })).toBe(false);
});
});
+185
View File
@@ -0,0 +1,185 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Mouse hit-testing parity layer for the OpenTUI renderer (PR1 slice 4).
*
* Framework-neutral port of the geometry the ink TUI uses to resolve mouse
* coordinates onto list rows and scrollbars:
* - `list-mouse.ts` (`frameAnchor`, `terminalRowToLayoutRow`,
* `findItemAtLayoutRow`) — menu / dialog / completion row hit-testing;
* - `selection-coords.ts` (`terminalToGrid`, `pointInViewport`,
* `clampToViewport`) — frame-anchor-corrected grid coordinates;
* - `VirtualizedList.hitTestScrollbar` — scrollbar-track hit-testing;
* - `RowMouseController.resolveIndex` — container-column gating plus
* hover (`move`) vs select (`left-press`) dispatch, disabled rows skipped.
*
* Pure arithmetic so it can be unit-tested without a renderer.
*/
/** A point in composited-frame (grid/layout) coordinates. */
export interface MousePoint {
x: number;
y: number;
}
/** A rectangle in the same 0-based coordinate space as `MousePoint`. */
export interface MouseRect {
x: number;
y: number;
width: number;
height: number;
}
/**
* The 0-based terminal row of the layout's top edge.
*
* When the frame overflows the terminal it is bottom-pinned, so its top rows
* scroll off-screen and the anchor is NEGATIVE (`terminalHeight -
* frameHeight`); a frame that fits is top-anchored (anchor 0). Parity with
* `list-mouse.ts#frameAnchor` — the negative value must not be clamped to 0.
*/
export function frameAnchor(
terminalHeight: number,
frameHeight: number,
): number {
return Math.min(0, terminalHeight - frameHeight);
}
/**
* Convert a 1-based terminal mouse row into a 0-based layout row, via the
* frame anchor. Parity with `list-mouse.ts#terminalRowToLayoutRow`.
*/
export function terminalRowToLayoutRow(
terminalRow1Based: number,
anchor: number,
): number {
return terminalRow1Based - 1 - anchor;
}
/**
* Map a 1-based terminal cell (col, row) to composited-frame grid
* coordinates. Parity with `selection-coords.ts#terminalToGrid`.
*/
export function terminalToGrid(
col: number,
row: number,
terminalHeight: number,
frameHeight: number,
): MousePoint {
const anchor = frameAnchor(terminalHeight, frameHeight);
return { x: col - 1, y: row - 1 - anchor };
}
/** Whether a grid point falls inside a viewport region. */
export function pointInViewport(point: MousePoint, rect: MouseRect): boolean {
return (
point.y >= rect.y &&
point.y < rect.y + rect.height &&
point.x >= rect.x &&
point.x < rect.x + rect.width
);
}
/** Clamp a grid point to the viewport interior, for drag extension. */
export function clampToViewport(
point: MousePoint,
rect: MouseRect,
): MousePoint {
return {
x: Math.max(rect.x, Math.min(rect.x + rect.width - 1, point.x)),
y: Math.max(rect.y, Math.min(rect.y + rect.height - 1, point.y)),
};
}
/** A visible list item's layout-space vertical span (rows). */
export interface VisibleItemRect {
/** Index into the full items array (not the visible slice). */
index: number;
/** Top row of the item, in the same 0-based space as the click row. */
top: number;
/** Item height in rows (>= 1; multi-line items span several rows). */
height: number;
}
/**
* Find the item whose row span contains `layoutRow`, or null when the row
* falls in no item (scroll arrows, gaps, or outside the list). Multi-line
* items and inter-item gaps are handled without assuming a uniform row
* height. Parity with `list-mouse.ts#findItemAtLayoutRow`.
*/
export function findItemAtLayoutRow(
rects: readonly VisibleItemRect[],
layoutRow: number,
): number | null {
for (const rect of rects) {
if (layoutRow >= rect.top && layoutRow < rect.top + rect.height) {
return rect.index;
}
}
return null;
}
/** A list's hit-testing geometry: container bounds plus measured items. */
export interface ListHitGeometry {
/** Container bounds in layout coordinates (horizontal gating). */
container: MouseRect;
/** Measured item spans, indices already offset by the scroll position. */
items: readonly VisibleItemRect[];
}
/**
* Resolve a layout-space pointer position to a list item index, applying the
* `RowMouseController` rules: interactions outside the container's columns
* are ignored (a click elsewhere on the same terminal row must not hijack a
* selection) and disabled rows are skipped. Returns the item index or null.
*/
export function resolveListIndex(
geometry: ListHitGeometry,
location: MousePoint,
isDisabled?: (index: number) => boolean,
): number | null {
const { container, items } = geometry;
if (
container.width > 0 &&
(location.x < container.x || location.x >= container.x + container.width)
) {
return null;
}
const index = findItemAtLayoutRow(items, location.y);
if (index === null || isDisabled?.(index)) return null;
return index;
}
/**
* The scrollbar track's geometry in 0-based layout coordinates. Parity with
* `VirtualizedList#getScrollbarGeometry`: the track occupies the container's
* rightmost column.
*/
export interface ScrollbarGeometry {
col: number;
top: number;
height: number;
}
/**
* Hit-test the scrollbar track against a 1-based terminal mouse location.
* Parity with `VirtualizedList#hitTestScrollbar` (no frame-anchor correction:
* the track is always in the visible region).
*/
export function hitTestScrollbar(
geometry: ScrollbarGeometry | null,
location: { col: number; row: number },
): boolean {
if (!geometry) return false;
const zeroBasedCol = location.col - 1;
const zeroBasedRow = location.row - 1;
return (
zeroBasedCol === geometry.col &&
zeroBasedRow >= geometry.top &&
zeroBasedRow < geometry.top + geometry.height
);
}
@@ -0,0 +1,164 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies the OpenTUI OSC 8 parity module keeps the ink hyperlink security
* constraints intact: scheme allowlist, whitespace rejection, legacy
* `label (url)` fallback, the label-deception suffix, and bare-URL target
* trimming that leaves visible bytes untouched.
*/
import { describe, it, expect } from 'vitest';
import {
renderMarkdownLink,
renderBareUrl,
osc8Open,
osc8Close,
isSafeOscScheme,
shouldWrapMarkdownLink,
labelMayDeceive,
} from './osc8-parity.js';
describe('osc8-parity renderMarkdownLink', () => {
it('wraps an allowlisted URL in an OSC 8 envelope when hyperlinks are available', () => {
const out = renderMarkdownLink('click me', 'https://example.com', true);
expect(out.wrapped).toBe(true);
expect(out.deceptionSuffix).toBe(false);
expect(out.text).toBe(
`${osc8Open('https://example.com')}click me${osc8Close()}`,
);
});
it('shows the URL as the label when the markdown label is empty', () => {
const out = renderMarkdownLink('', 'https://example.com', true);
expect(out.text).toBe(
`${osc8Open('https://example.com')}https://example.com${osc8Close()}`,
);
});
it('falls back to legacy `label (url)` when the terminal cannot hyperlink', () => {
const out = renderMarkdownLink('click me', 'https://example.com', false);
expect(out).toEqual({
text: 'click me (https://example.com)',
wrapped: false,
deceptionSuffix: false,
});
});
it('falls back to legacy spelling for a disallowed scheme', () => {
const out = renderMarkdownLink('trap', 'javascript:alert(1)', true);
expect(out.wrapped).toBe(false);
expect(out.text).toBe('trap (javascript:alert(1))');
});
it('falls back to legacy spelling when the URL contains whitespace', () => {
const out = renderMarkdownLink('docs', 'https://example.com/a b', true);
expect(out.wrapped).toBe(false);
expect(out.text).toBe('docs (https://example.com/a b)');
});
it('appends the real target when the label spoofs a different host', () => {
const out = renderMarkdownLink(
'https://google.com',
'https://attacker.com',
true,
);
expect(out.wrapped).toBe(true);
expect(out.deceptionSuffix).toBe(true);
expect(out.text.endsWith(' (https://attacker.com)')).toBe(true);
});
it('appends the target for a bare-host spoof label too', () => {
const out = renderMarkdownLink('google.com', 'https://attacker.com', true);
expect(out.deceptionSuffix).toBe(true);
});
it('does not append the suffix when the label matches the target host', () => {
const out = renderMarkdownLink(
'example.com docs',
'https://example.com/docs',
true,
);
expect(out.wrapped).toBe(true);
expect(out.deceptionSuffix).toBe(false);
});
it('strips escape bytes a model embedded in the label before emitting', () => {
const out = renderMarkdownLink('lbl\x1b[31m', 'https://example.com', true);
expect(out.text).not.toContain('\x1b[31m');
});
it('unescapes backslash-dollars in the label (R1-9)', () => {
// The math-enabled markdown pipeline emits '\$' labels; ink shows '$'.
const wrapped = renderMarkdownLink('cost \\$5', 'https://x.dev', true);
expect(wrapped.text).toContain('cost $5');
const legacy = renderMarkdownLink('cost \\$5', 'https://x.dev', false);
expect(legacy.text).toBe('cost $5 (https://x.dev)');
});
});
describe('osc8-parity renderBareUrl', () => {
it('wraps the trimmed target while keeping the visible bytes untouched', () => {
const out = renderBareUrl('https://example.com/page.', true);
expect(out.wrapped).toBe(true);
expect(out.text).toBe(
`${osc8Open('https://example.com/page')}https://example.com/page.${osc8Close()}`,
);
});
it('emits the URL as-is when hyperlinks are unavailable', () => {
const out = renderBareUrl('https://example.com/page.', false);
expect(out).toEqual({
text: 'https://example.com/page.',
wrapped: false,
deceptionSuffix: false,
});
});
it('rebalances a trailing paren against opens in the URL', () => {
const out = renderBareUrl('https://en.wikipedia.org/wiki/Foo_(bar)', true);
expect(out.text).toBe(
`${osc8Open('https://en.wikipedia.org/wiki/Foo_(bar)')}https://en.wikipedia.org/wiki/Foo_(bar)${osc8Close()}`,
);
});
it('drops a trailing underscore when the next char ends the URL run (R1-93)', () => {
// The break set is CJK/fullwidth punctuation; a fullwidth comma after
// the underscore ends the run, so the target trims it off.
const out = renderBareUrl('https://x.dev/a_', true, ',');
expect(out.text).toBe(
`${osc8Open('https://x.dev/a')}https://x.dev/a_${osc8Close()}`,
);
const kept = renderBareUrl('https://x.dev/a_', true, 'b');
expect(kept.text).toBe(
`${osc8Open('https://x.dev/a_')}https://x.dev/a_${osc8Close()}`,
);
});
});
describe('osc8-parity re-exported primitives', () => {
it('exposes the scheme allowlist', () => {
expect(isSafeOscScheme('https://x.com')).toBe(true);
expect(isSafeOscScheme('mailto:[email protected]')).toBe(true);
expect(isSafeOscScheme('file:///etc/passwd')).toBe(false);
expect(isSafeOscScheme('data:text/html,hi')).toBe(false);
expect(isSafeOscScheme('relative/path')).toBe(false);
});
it('exposes the wrap predicate (scheme + whitespace)', () => {
expect(shouldWrapMarkdownLink('https://x.com', true)).toBe(true);
expect(shouldWrapMarkdownLink('https://x.com', false)).toBe(false);
expect(shouldWrapMarkdownLink('file:///x', true)).toBe(false);
expect(shouldWrapMarkdownLink('https://x.com/a b', true)).toBe(false);
});
it('exposes the label-deception heuristic', () => {
expect(labelMayDeceive('https://google.com', 'https://evil.com')).toBe(
true,
);
expect(labelMayDeceive('https://evil.com', 'https://evil.com')).toBe(false);
});
});
+119
View File
@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* OpenTUI parity of the OSC 8 hyperlink security constraints from the ink
* renderers (ui/utils/osc8.ts + InlineMarkdownRenderer + TableRenderer):
* scheme allowlist, label-deception defense, unsafe/whitespace fallback to
* the legacy `label (url)` spelling, and bare-URL target trimming. Output is
* a plain string (envelope + visible text) that an OpenTUI text renderable
* can emit unchanged, so both renderers stay in lockstep.
*/
import {
osc8Open,
osc8Close,
isSafeOscScheme,
trimTrailingUrlPunctuation,
shouldWrapMarkdownLink,
labelMayDeceive,
sanitizeForOsc,
supportsHyperlinks,
osc8Hyperlink,
wrapForMultiplexer,
HYPERLINK_ENV_KEYS,
MD_LINK_PATTERN,
MD_LINK_CAPTURE,
} from '../utils/osc8.js';
import { unescapeMarkdownDollars } from '../utils/inline-math.js';
export {
osc8Open,
osc8Close,
isSafeOscScheme,
trimTrailingUrlPunctuation,
shouldWrapMarkdownLink,
labelMayDeceive,
sanitizeForOsc,
supportsHyperlinks,
osc8Hyperlink,
wrapForMultiplexer,
HYPERLINK_ENV_KEYS,
MD_LINK_PATTERN,
MD_LINK_CAPTURE,
};
export interface Osc8LinkRender {
/** Bytes to emit: optional OSC 8 envelope around the visible text. */
text: string;
/** True when the OSC 8 envelope was applied. */
wrapped: boolean;
/** True when the anti-deception `(url)` suffix was appended. */
deceptionSuffix: boolean;
}
/**
* Render one markdown `[label](url)` token with the ink
* InlineMarkdownRenderer semantics:
* - OSC 8 active (capable terminal + allowlisted scheme + no whitespace):
* emit only the clickable label; an empty label falls back to the URL so
* the link stays discoverable. When the label could deceive about the
* real target (it looks like a different URL/host), keep the `(url)`
* suffix visible even though wrapping is active.
* - Otherwise: byte-identical legacy `label (url)` spelling so the user
* sees the suspicious target before any click.
*/
export function renderMarkdownLink(
label: string,
url: string,
canHyperlink: boolean,
): Osc8LinkRender {
// Ink unescapes backslash-dollars in BOTH branches before use (the
// math-enabled markdown pipeline emits '\$' labels routinely).
const renderedLabel = unescapeMarkdownDollars(label);
const wrap = shouldWrapMarkdownLink(url, canHyperlink);
if (!wrap) {
return {
text: `${renderedLabel} (${url})`,
wrapped: false,
deceptionSuffix: false,
};
}
const safeLabel = sanitizeForOsc(renderedLabel);
const safeUrl = sanitizeForOsc(url);
const showSuffix = labelMayDeceive(safeLabel, safeUrl);
const envelope = `${osc8Open(url)}${safeLabel || safeUrl}${osc8Close()}`;
return {
text: showSuffix ? `${envelope} (${safeUrl})` : envelope,
wrapped: true,
deceptionSuffix: showSuffix,
};
}
/**
* Render a bare `https://…` URL run with the ink InlineMarkdownRenderer
* semantics: the OSC 8 *target* drops trailing sentence punctuation (so the
* click resolves), while the visible text keeps it byte-for-byte for
* terminals without OSC 8.
*/
export function renderBareUrl(
url: string,
canHyperlink: boolean,
nextCharacter = '',
): Osc8LinkRender {
const trimmed = canHyperlink
? trimTrailingUrlPunctuation(url, nextCharacter)
: url;
const wrap = canHyperlink && isSafeOscScheme(trimmed);
if (!wrap) {
return { text: url, wrapped: false, deceptionSuffix: false };
}
return {
text: `${osc8Open(trimmed)}${url}${osc8Close()}`,
wrapped: true,
deceptionSuffix: false,
};
}
@@ -0,0 +1,598 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies OpenTUI slash-command dispatch: parsing/resolution through the
* original shared parser, the original command registry (built-in loader),
* and result mapping — with `/help` producing the original help output.
*/
import { describe, it, expect, vi } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
import type { HistoryItemWithoutId } from '../types.js';
import type { SlashCommand } from '../commands/types.js';
import { CommandKind } from '../commands/types.js';
import {
executeSlashCommand,
isSlashCommandInput,
loadInteractiveCommands,
resolveSlashCommand,
} from './slash-dispatch.js';
import { HELP_DOCS_URL, formatHelpText } from './help-content.js';
function stub(
overrides: Partial<SlashCommand> & { name: string },
): SlashCommand {
return {
description: `stub ${overrides.name}`,
kind: CommandKind.BUILT_IN,
...overrides,
};
}
const registry: SlashCommand[] = [
stub({ name: 'help', altNames: ['?'] }),
stub({
name: 'greet',
action: () => ({
type: 'message',
messageType: 'info',
content: 'hello from greet',
}),
}),
stub({
name: 'boom',
action: () => {
throw new Error('kaboom');
},
}),
stub({
name: 'ask',
action: () => ({
type: 'submit_prompt',
content: [{ text: 'part one ' }, { text: 'part two' }],
}),
}),
stub({
name: 'memory',
description: 'parent',
subCommands: [
stub({ name: 'add', description: 'add memory' }),
stub({ name: 'show', description: 'show memory' }),
],
}),
stub({
name: 'theme',
action: () => ({ type: 'dialog', dialog: 'theme' }),
}),
stub({ name: 'hidden', hidden: true }),
];
describe('isSlashCommandInput (ink submission gate parity)', () => {
it('accepts /-prefixed input', () => {
expect(isSlashCommandInput('/help')).toBe(true);
expect(isSlashCommandInput(' /help args ')).toBe(true);
});
it('rejects ?-prefixed input — ink routes it to the model/btw path', () => {
expect(isSlashCommandInput('?')).toBe(false);
expect(isSlashCommandInput('?btw side question')).toBe(false);
expect(isSlashCommandInput('?stats')).toBe(false);
});
it('rejects plain prompts and path-like input', () => {
expect(isSlashCommandInput('hello world')).toBe(false);
expect(isSlashCommandInput('/usr/bin/ls')).toBe(false);
expect(isSlashCommandInput('')).toBe(false);
});
});
describe('resolveSlashCommand (original parseSlashCommand)', () => {
it('resolves by primary name with args', () => {
const resolution = resolveSlashCommand('/greet world ', registry);
expect(resolution.type).toBe('command');
if (resolution.type !== 'command') return;
expect(resolution.command.name).toBe('greet');
expect(resolution.args).toBe('world');
expect(resolution.canonicalPath).toEqual(['greet']);
});
it('resolves aliases like ? → help', () => {
const resolution = resolveSlashCommand('/?', registry);
expect(resolution.type).toBe('command');
if (resolution.type !== 'command') return;
expect(resolution.command.name).toBe('help');
});
it('resolves subcommand paths and reports unknown commands', () => {
const resolution = resolveSlashCommand('/memory add something', registry);
expect(resolution.type).toBe('command');
if (resolution.type !== 'command') return;
expect(resolution.canonicalPath).toEqual(['memory', 'add']);
expect(resolution.args).toBe('something');
expect(resolveSlashCommand('/nope', registry)).toEqual({
type: 'unknown',
input: '/nope',
});
});
});
function makeEnv(
extra: Partial<Parameters<typeof executeSlashCommand>[2]> = {},
): Parameters<typeof executeSlashCommand>[2] {
return {
config: null,
settings: { merged: {} } as never,
...extra,
};
}
describe('executeSlashCommand (result mapping)', () => {
const env = makeEnv();
it('unknown command → same error as the ink TUI', async () => {
const effect = await executeSlashCommand('/nope', registry, env);
expect(effect).toEqual({
kind: 'message',
messageType: 'error',
content: 'Unknown command: /nope',
});
});
it('message results carry type and content', async () => {
const effect = await executeSlashCommand('/greet', registry, env);
expect(effect).toEqual({
kind: 'message',
messageType: 'info',
content: 'hello from greet',
});
});
it('dialog results map to dialog effects (non-help)', async () => {
const effect = await executeSlashCommand('/theme', registry, env);
expect(effect).toEqual({
kind: 'dialog',
dialog: 'theme',
command: 'theme',
});
});
it('dialog results carry the OpenDialogActionReturn payload (R2-42)', async () => {
const commands = [
stub({
name: 'resume',
action: () => ({
type: 'dialog' as const,
dialog: 'resume' as const,
sessionId: 'session-abc-123',
}),
}),
];
const effect = await executeSlashCommand(
'/resume session-abc-123',
commands,
env,
);
expect(effect).toEqual({
kind: 'dialog',
dialog: 'resume',
command: 'resume',
sessionId: 'session-abc-123',
});
});
it('submit_prompt results stringify content', async () => {
const effect = await executeSlashCommand('/ask', registry, env);
expect(effect).toEqual({
kind: 'submit',
content: [{ text: 'part one ' }, { text: 'part two' }],
textContent: 'part one part two',
modelOverride: undefined,
onComplete: undefined,
refreshContextFilesOnWrite: undefined,
});
});
it('submit_prompt carries modelOverride/onComplete/refreshContextFilesOnWrite (R3-2)', async () => {
// Ink honors all three: /model <id> <prompt> runs on the chosen model,
// /dream records manual runs via onComplete, /remember refreshes
// context files. The effect must carry them or the backend degrades
// them silently.
const onComplete = vi.fn();
const commands = [
stub({
name: 'ask',
action: () => ({
type: 'submit_prompt' as const,
content: 'summarize this file',
modelOverride: 'qwen3-max',
onComplete,
refreshContextFilesOnWrite: true,
}),
}),
];
const effect = await executeSlashCommand('/ask', commands, env);
expect(effect).toEqual({
kind: 'submit',
content: 'summarize this file',
textContent: 'summarize this file',
modelOverride: 'qwen3-max',
onComplete,
refreshContextFilesOnWrite: true,
});
expect(onComplete).not.toHaveBeenCalled();
});
it('parent commands without an action list their subcommands', async () => {
const effect = await executeSlashCommand('/memory', registry, env);
expect(effect.kind).toBe('message');
if (effect.kind !== 'message') return;
expect(effect.messageType).toBe('info');
expect(effect.content).toContain("'/memory' requires a subcommand");
expect(effect.content).toContain('- add:');
expect(effect.content).toContain('- show:');
});
it('thrown actions become error messages', async () => {
const effect = await executeSlashCommand('/boom', registry, env);
expect(effect).toEqual({
kind: 'message',
messageType: 'error',
content: "Command '/boom' failed: kaboom",
});
});
});
describe('executeSlashCommand ink-processor guards (R1-96/100/101/102)', () => {
it('does not treat comment-style input as slash commands', () => {
expect(isSlashCommandInput('/* This is a block comment */')).toBe(false);
expect(isSlashCommandInput('// line note')).toBe(false);
});
it('maps ui.clear() to the clear effect', async () => {
const commands = [
stub({
name: 'wipe',
action: (ctx) => {
ctx.ui.clear();
},
}),
];
const effect = await executeSlashCommand('/wipe', commands, makeEnv());
expect(effect).toEqual({ kind: 'clear' });
});
it('drops the action result once the submission is aborted', async () => {
const controller = new AbortController();
controller.abort();
const effect = await executeSlashCommand(
'/ask',
registry,
makeEnv({ abortSignal: controller.signal }),
);
expect(effect).toEqual({ kind: 'handled' });
});
it('races non-cooperative actions against the abort signal (R1-18)', async () => {
const controller = new AbortController();
const commands = [
stub({
name: 'stuck',
action: () =>
new Promise(() => {
/* never settles on its own, like /compress mid-operation */
}),
}),
];
const pending = executeSlashCommand(
'/stuck',
commands,
makeEnv({ abortSignal: controller.signal }),
);
controller.abort();
await expect(pending).resolves.toEqual({ kind: 'handled' });
});
it('an AbortError from a pre-aborted signal is handled, not a failure (R3-1)', async () => {
// ESC before dispatch reaches the race: the signal is already aborted,
// addEventListener never fires, and the action's I/O rejects with
// AbortError into the catch — the user's own cancellation must not be
// recorded as a command failure or shown as an error message.
const controller = new AbortController();
controller.abort();
const commands = [
stub({
name: 'doctor',
action: (ctx) => {
void ctx.abortSignal;
return Promise.reject(new Error('This operation was aborted'));
},
}),
];
const effect = await executeSlashCommand(
'/doctor',
commands,
makeEnv({ abortSignal: controller.signal }),
);
expect(effect).toEqual({ kind: 'handled' });
});
it('defers stacked skill invocations instead of leaking the second skill', async () => {
const skills = [
stub({ name: 'feat-dev', kind: CommandKind.SKILL }),
stub({ name: 'e2e-testing', kind: CommandKind.SKILL }),
];
const effect = await executeSlashCommand(
'/feat-dev /e2e-testing do it',
skills,
makeEnv(),
);
expect(effect.kind).toBe('message');
if (effect.kind !== 'message') return;
expect(effect.content).toContain('Stacked skill invocations');
expect(effect.content).toContain('/feat-dev /e2e-testing');
});
it('projects ui.addItem history items to transcript text', async () => {
const commands = [
stub({
name: 'showstats',
action: (ctx) => {
ctx.ui.addItem({ type: 'stats', duration: '9m' }, Date.now());
},
}),
];
const effect = await executeSlashCommand('/showstats', commands, makeEnv());
expect(effect.kind).toBe('message');
if (effect.kind !== 'message') return;
expect(effect.messageType).toBe('info');
expect(effect.content).toContain('Session Stats');
expect(effect.content).toContain('Session duration: 9m');
});
it('surfaces added-item text alongside non-handled effects (R1-102)', async () => {
const commands = [
stub({
name: 'init',
action: (ctx) => {
ctx.ui.addItem(
{ type: 'info', text: 'Empty QWEN.md created.' },
Date.now(),
);
return {
type: 'submit_prompt' as const,
content: 'analyze the project',
};
},
}),
];
const effect = await executeSlashCommand('/init', commands, makeEnv());
expect(effect).toEqual({
kind: 'submit',
content: 'analyze the project',
textContent: 'analyze the project',
notice: 'Empty QWEN.md created.',
});
});
it('projects message items instead of the generic deferral (R2-5)', async () => {
const commands = [
stub({
name: 'extensions',
action: (ctx) => {
ctx.ui.addItem(
{ type: 'error', text: 'Unknown extensions source: bogus.' },
Date.now(),
);
},
}),
];
const effect = await executeSlashCommand(
'/extensions explore bogus',
commands,
makeEnv(),
);
expect(effect.kind).toBe('message');
if (effect.kind !== 'message') return;
expect(effect.content).toBe('Unknown extensions source: bogus.');
// Info items with a link (like /bug) append the link footer — ink's
// InfoMessage renders it, and headless/SSH users need the URL printed.
const bugCommands = [
stub({
name: 'bug',
action: (ctx) => {
ctx.ui.addItem(
{
type: 'info',
text: 'To report a bug, open:',
linkUrl: 'https://example.com/report',
linkText: 'Open GitHub bug report form',
},
Date.now(),
);
},
}),
];
const bugEffect = await executeSlashCommand('/bug', bugCommands, makeEnv());
expect(bugEffect.kind).toBe('message');
if (bugEffect.kind !== 'message') return;
expect(bugEffect.content).toContain('https://example.com/report');
expect(bugEffect.content).toContain('Open GitHub bug report form');
expect(effect.content).not.toContain('not yet available');
});
it('exposes env.history through the command context (R2-6)', async () => {
let observed: HistoryItemWithoutId[] | undefined;
const commands = [
stub({
name: 'scan',
action: (ctx) => {
observed = ctx.ui.history;
},
}),
];
await executeSlashCommand(
'/scan',
commands,
makeEnv({
history: [{ type: 'error', text: 'boom' } as HistoryItemWithoutId],
}),
);
expect(observed).toHaveLength(1);
expect((observed?.[0] as { text?: string })?.text).toBe('boom');
});
});
describe('original built-in registry', () => {
it('loads built-in commands without a config (BuiltinCommandLoader)', async () => {
const commands = await loadInteractiveCommands(null);
const names = commands.map((cmd) => cmd.name);
expect(names).toContain('help');
expect(names).toContain('quit');
expect(names).toContain('clear');
expect(names).toContain('stats');
// every interactive command is user-invocable and visible
for (const cmd of commands) {
expect(cmd.hidden).toBeFalsy();
expect(cmd.userInvocable).not.toBe(false);
}
}, 30000);
it('/help dispatches to the help dialog effect', async () => {
const commands = await loadInteractiveCommands(null);
const effect = await executeSlashCommand('/help', commands, makeEnv());
expect(effect).toEqual({ kind: 'help' });
const viaAlias = await executeSlashCommand('/?', commands, makeEnv());
expect(viaAlias).toEqual({ kind: 'help' });
}, 30000);
it('/quit produces the quit effect', async () => {
const commands = await loadInteractiveCommands(null);
const effect = await executeSlashCommand('/quit', commands, makeEnv());
expect(effect.kind).toBe('quit');
}, 30000);
it('/quit carries the quitting messages on the effect (ytahdn-1)', async () => {
// ink renders QuitActionReturn.messages via QuittingDisplay (the /quit
// echo + session-duration summary); the effect must carry the projected
// text or the output is permanently lost under the new renderer.
const commands = [
stub({
name: 'quit',
action: () => ({
type: 'quit' as const,
messages: [
{ type: 'user', text: '/quit' },
{ type: 'quit', duration: '2m' },
] as never,
}),
}),
];
const effect = await executeSlashCommand('/quit', commands, makeEnv());
expect(effect.kind).toBe('quit');
// The quit item projects to the ink QuittingDisplay summary; the user
// echo item ({type:'user', text:'/quit'}) has no special-item projection
// and the backend's own input echo covers it, exactly like the ink TUI.
expect((effect as { notice?: string }).notice).toContain(
'Agent powering down. Goodbye!',
);
expect((effect as { notice?: string }).notice).toContain('2m');
});
it('help output matches the original dialog content', async () => {
const commands = await loadInteractiveCommands(null);
const text = formatHelpText(commands);
expect(text).toContain('Qwen Code');
expect(text).toContain('Shortcuts');
expect(text).toContain('↑/↓');
expect(text).toContain('Browse built-in commands:');
expect(text).toContain('Built-in Commands');
expect(text).toContain('/help');
expect(text).toContain('/quit');
expect(text).toContain(HELP_DOCS_URL);
}, 30000);
});
describe('model-invocable commands registration (ink loader-effect parity)', () => {
type InvocableProvider = () => ReadonlyArray<{
name: string;
description: string;
}>;
type InvocableExecutor = (
name: string,
args?: string,
) => Promise<string | { error: string } | null>;
// Minimal config stub: registration methods are captured while every
// dynamic loader (skills, file commands, MCP prompts) stays on its empty
// path, so the provider lists nothing but stays well-formed.
function createConfigStub(
onProvider?: (provider: InvocableProvider) => void,
onExecutor?: (executor: InvocableExecutor) => void,
): Config {
return {
initialize: async () => {},
getDisabledSlashCommands: () => [],
setModelInvocableCommandsProvider: (provider: InvocableProvider) =>
onProvider?.(provider),
setModelInvocableCommandsExecutor: (executor: InvocableExecutor) =>
onExecutor?.(executor),
getBareMode: () => true,
isWorkflowsEnabled: () => false,
isManagedMemoryAvailable: () => false,
getFolderTrust: () => false,
getFolderTrustFeature: () => false,
getFileCheckpointingEnabled: () => false,
isLspEnabled: () => false,
isCronEnabled: () => false,
getMcpServers: () => ({}),
getSkillManager: () => undefined,
getDisabledSkillNames: () => new Set<string>(),
getPermissionManager: () => undefined,
getModel: () => undefined,
getCliVersion: () => undefined,
getProjectRoot: () => '/nonexistent-opentui-test-root',
} as unknown as Config;
}
it('registers the provider and executor on the config', async () => {
const providerSpy = vi.fn();
const executorSpy = vi.fn();
const config = createConfigStub(providerSpy, executorSpy);
await loadInteractiveCommands(config);
expect(providerSpy).toHaveBeenCalledTimes(1);
expect(executorSpy).toHaveBeenCalledTimes(1);
}, 30000);
it('provider() returns a {name, description} listing', async () => {
let provider: InvocableProvider | undefined;
await loadInteractiveCommands(
createConfigStub((p) => {
provider = p;
}),
);
expect(provider).toBeTypeOf('function');
if (!provider) return;
// Built-ins are forced modelInvocable:false and the stub keeps every
// dynamic loader empty, so the listing is empty but well-formed.
expect(provider()).toEqual([]);
}, 30000);
it('executor() returns null for names the model cannot invoke', async () => {
let executor: InvocableExecutor | undefined;
await loadInteractiveCommands(
createConfigStub(undefined, (e) => {
executor = e;
}),
);
expect(executor).toBeTypeOf('function');
if (!executor) return;
// built-ins are never model-invocable, and unknown names miss entirely
await expect(executor('help')).resolves.toBeNull();
await expect(executor('definitely-not-a-command')).resolves.toBeNull();
}, 30000);
});
@@ -0,0 +1,638 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Slash-command dispatch for the OpenTUI renderer (PR1 slice 1).
*
* Parses '/'-prefixed input and dispatches into the ORIGINAL command
* registry: the same loader stack (MCP prompts, built-ins, bundled skills,
* skill dirs, saved workflows, file commands) and `CommandService` the ink
* `useSlashCommandProcessor` builds, resolved through the shared
* `parseSlashCommand` so name/alias/subcommand resolution is identical.
*
* Slice 1 scope: dispatch + help. Command results are mapped onto neutral
* effects the OpenTUI backend applies (message, help overlay, clear, quit,
* submit-to-model); dialogs beyond help report themselves as pending parity.
*/
import type { PartListUnion } from '@google/genai';
import type { Config, SessionListItem } from '@qwen-code/qwen-code-core';
import {
SlashCommandStatus,
logSlashCommand,
makeSlashCommandEvent,
recordSkillInvocation,
} from '@qwen-code/qwen-code-core';
import type {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
} from '../commands/types.js';
import { CommandKind } from '../commands/types.js';
import type { LoadedSettings } from '../../config/settings.js';
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { BundledSkillLoader } from '../../services/BundledSkillLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import { SavedWorkflowLoader } from '../../services/saved-workflow-loader.js';
import {
SkillCommandLoader,
recordAutoSkillCommandUsage,
} from '../../services/SkillCommandLoader.js';
import { CommandService } from '../../services/CommandService.js';
import {
parseSlashCommand,
parseStackedSlashCommands,
} from '../commands/commands.js';
import { isSlashCommand } from '../utils/commandUtils.js';
import type { HistoryItemWithoutId } from '../types.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import { projectSpecialItemText } from './item-projection.js';
import {
appendUserPromptExpansionAdditionalContext,
formatUserPromptExpansionBlockedMessage,
serializeUserPromptExpansionPrompt,
} from '../../utils/userPromptExpansionHook.js';
function hasUserPromptExpansionHooks(config: Config | null): boolean {
return (
!!config &&
!config.getDisableAllHooks?.() &&
(config.hasHooksForEvent?.('UserPromptExpansion') ?? false)
);
}
/**
* Builds the interactive command list exactly like the ink processor does
* (same loader order, same disabled-command denylist, same mode filter), and
* registers the model-invocable commands provider/executor on the config
* (ink loader-effect parity): without them the startup snapshot and per-turn
* drain miss bundled skills, file commands, and MCP prompts, and SkillTool
* cannot invoke model-invocable commands that are not file-based skills.
*/
export async function loadInteractiveCommands(
config: Config | null,
signal?: AbortSignal,
settings?: LoadedSettings | null,
): Promise<readonly SlashCommand[]> {
// Skill/MCP/project commands need the config fully initialized (the skill
// manager is created in initialize()); without this /skills errors and
// skill commands are missing from /-completion.
try {
await config?.initialize();
} catch {
/* proceed with partial commands */
}
const loaders = [
new McpPromptLoader(config),
new BuiltinCommandLoader(config),
new BundledSkillLoader(config),
new SkillCommandLoader(config),
new SavedWorkflowLoader(config),
new FileCommandLoader(config),
];
const disabled = config?.getDisabledSlashCommands() ?? [];
const commandService = await CommandService.create(
loaders,
signal ?? new AbortController().signal,
disabled.length > 0 ? new Set(disabled) : undefined,
);
if (config) {
config.setModelInvocableCommandsProvider(() =>
commandService.getModelInvocableCommands().map((cmd) => ({
name: cmd.name,
description: cmd.modelDescription ?? cmd.description,
})),
);
config.setModelInvocableCommandsExecutor(
async (name: string, args: string = '') => {
const commands = commandService.getModelInvocableCommands();
const cmd = commands.find((c) => c.name === name);
if (!cmd?.action) return null;
// Build a minimal context; submit_prompt actions only need
// invocation + services.config, not UI state.
const minimalContext = {
executionMode: 'non_interactive' as const,
invocation: {
raw: args ? `/${name} ${args}` : `/${name}`,
name,
args,
},
services: { config, settings: settings ?? null, logger: null },
} as unknown as Parameters<typeof cmd.action>[0];
const result = await cmd.action(minimalContext, args);
if (!result || result.type !== 'submit_prompt') return null;
const output = hasUserPromptExpansionHooks(config)
? await config
.getHookSystem()
?.fireUserPromptExpansionEvent(
name,
args,
serializeUserPromptExpansionPrompt(result.content),
signal ?? new AbortController().signal,
)
: undefined;
if (signal?.aborted) {
return { error: 'Skill execution cancelled by user.' };
}
if (output) {
const blockingError = output.getBlockingError();
if (blockingError.blocked || output.shouldStopExecution()) {
return {
error: formatUserPromptExpansionBlockedMessage(
blockingError.reason || output.getEffectiveReason(),
),
};
}
}
const content = appendUserPromptExpansionAdditionalContext(
result.content,
output?.getAdditionalContext(),
);
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((p) =>
typeof p === 'string' ? p : ((p as { text?: string }).text ?? ''),
)
.join('');
}
return null;
},
);
}
return commandService.getCommandsForMode('interactive');
}
/**
* Whether the submitted text must be treated as a slash command. Mirrors the
* ink submission gate (useGeminiStream): only '/'-prefixed input classified
* by the shared `isSlashCommand` (which excludes `//`/`/*` comments and
* file-path-like input) routes here; '?'-prefixed input reaches the
* model/btw path exactly like ink.
*/
export function isSlashCommandInput(raw: string): boolean {
return isSlashCommand(raw.trim());
}
export type SlashResolution =
| { type: 'unknown'; input: string }
| {
type: 'command';
command: SlashCommand;
args: string;
canonicalPath: string[];
};
/** Resolves '/name args' against the registry via the shared parser. */
export function resolveSlashCommand(
raw: string,
commands: readonly SlashCommand[],
): SlashResolution {
const trimmed = raw.trim();
const { commandToExecute, args, canonicalPath } = parseSlashCommand(
trimmed,
commands,
);
if (!commandToExecute) {
return { type: 'unknown', input: trimmed };
}
return { type: 'command', command: commandToExecute, args, canonicalPath };
}
/**
* Neutral effects the OpenTUI backend applies for a dispatched command.
* `notice` carries projected text for history items the command added via
* `ui.addItem` alongside a non-handled effect (e.g. /init adds an info
* notice and returns submit_prompt) — the backend renders it before
* applying the effect, mirroring ink's item-then-result ordering.
*/
export type SlashEffect =
| { kind: 'handled' }
| {
kind: 'message';
messageType: 'info' | 'warning' | 'error';
content: string;
}
| { kind: 'help' }
| {
kind: 'dialog';
dialog: string;
command: string;
/** /resume <id>: the session to open directly. */
sessionId?: string;
/** /resume <title>: pre-filtered sessions for the picker. */
matchedSessions?: SessionListItem[];
/** /branch: the name passed through to handleBranch. */
name?: string;
/** Model dialogs: which settings file the selection persists to. */
persistScope?: 'workspace' | 'user';
}
| { kind: 'clear' }
| { kind: 'quit'; notice?: string }
| {
kind: 'submit';
/** The prompt content in full: ink keeps the PartListUnion so image
* parts (e.g. @{…} file injection) reach the model; `textContent` is
* the text-only view for consumers that print it. */
content: PartListUnion;
textContent: string;
/** Per-turn model id (ink: /model <id> <prompt> runs on the chosen
* model without changing the session selection). */
modelOverride?: string;
/** Invoked after the agent turn completes successfully (ink: /dream
* records the manual run this way). */
onComplete?: () => Promise<void>;
/** Refresh context-file-backed instructions after this prompt
* writes them (ink: /remember). */
refreshContextFilesOnWrite?: boolean;
};
export type SlashEffectWithNotice = SlashEffect & { notice?: string };
export interface SlashDispatchEnv {
config: Config | null;
/**
* Loaded settings for the command context. The real
* `CommandContext.services.settings` is non-null (commands/types.ts), so
* this is required: a null would surface as a generic command failure
* the first time a command reads `.merged`.
*/
settings: LoadedSettings;
abortSignal?: AbortSignal;
/**
* Live session stats (start time, metrics, counters) that commands such as
* /quit and /clear read and persist; the backend owns the true values.
*/
sessionStats?: SessionStatsState;
/**
* Live transcript history commands scan through `context.ui.history`
* (/doctor's oversized-tool-output check, etc.); absent means empty.
*/
history?: readonly HistoryItemWithoutId[];
/**
* Toggle vim mode and report the new state (commands-context.ts routes
* the same seam to the host); without it /vim would report a fake
* "Exited Vim mode." confirmation while toggling nothing.
*/
toggleVimEnabled?: () => Promise<boolean>;
/**
* The backend's session-stats seam for /clear: core rotates the config
* session id and hands the new id back so the backend can reset its
* SessionStatsState (start time, counters) instead of leaking pre-clear
* state across the boundary.
*/
startNewSession?: (sessionId: string) => void;
}
function stringifyPromptContent(content: unknown): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((part) =>
typeof part === 'string'
? part
: ((part as { text?: string }).text ?? ''),
)
.join('');
}
return String(content ?? '');
}
function mapActionResult(
result: SlashCommandActionReturn | void,
command: SlashCommand,
env: SlashDispatchEnv,
): SlashEffect {
if (!result) {
return { kind: 'handled' };
}
switch (result.type) {
case 'message':
return {
kind: 'message',
messageType: result.messageType,
content: result.content,
};
case 'dialog':
return result.dialog === 'help'
? { kind: 'help' }
: {
kind: 'dialog',
dialog: result.dialog,
command: command.name,
sessionId: result.sessionId,
matchedSessions: result.matchedSessions,
name: result.name,
persistScope: result.persistScope,
};
case 'quit': {
// ink renders QuitActionReturn.messages via QuittingDisplay (the
// `/quit` echo + session-duration summary); carry the projected text
// on the effect so the backend can print it during the exit window.
const notice = projectItems(result.messages, env);
return notice ? { kind: 'quit', notice } : { kind: 'quit' };
}
case 'load_history':
return result.history.length === 0
? { kind: 'clear' }
: {
kind: 'message',
messageType: 'info',
content: `'/${command.name}' history restore is not yet available in the OpenTUI renderer.`,
};
case 'submit_prompt':
// Carry the full SubmitPromptActionReturn contract: the backend
// honors modelOverride (/model <id> <prompt>), onComplete (/dream
// records manual runs), refreshContextFilesOnWrite (/remember), and
// the PartListUnion content (image parts from @{…} injection) exactly
// like ink's processor.
return {
kind: 'submit',
content: result.content,
textContent: stringifyPromptContent(result.content),
modelOverride: result.modelOverride,
onComplete: result.onComplete,
refreshContextFilesOnWrite: result.refreshContextFilesOnWrite,
};
case 'tool':
return {
kind: 'message',
messageType: 'info',
content: `Tool scheduling for '/${command.name}' is not yet available in the OpenTUI renderer.`,
};
case 'goal_control':
return {
kind: 'message',
messageType: 'info',
content: `Goal controls are not yet available in the OpenTUI renderer.`,
};
case 'confirm_shell_commands':
case 'confirm_action':
return {
kind: 'message',
messageType: 'info',
content: `'/${command.name}' needs a confirmation dialog, which is not yet available in the OpenTUI renderer.`,
};
case 'stream_messages':
return {
kind: 'message',
messageType: 'error',
content:
'stream_messages result type is not supported in interactive mode',
};
default: {
const unhandled: never = result;
return {
kind: 'message',
messageType: 'error',
content: `Unhandled slash command result: ${unhandled}`,
};
}
}
}
/**
* Dispatches one slash command end-to-end and returns the effect to apply.
* Mirrors the ink processor: unknown commands produce the same
* `Unknown command: <input>` error; parent commands without an action list
* their subcommands.
*/
export async function executeSlashCommand(
raw: string,
commands: readonly SlashCommand[],
env: SlashDispatchEnv,
): Promise<SlashEffectWithNotice> {
// ink merges stacked skill invocations (/feat-dev /e2e-testing text);
// the merge is not ported yet, so report an explicit deferral instead of
// leaking the second skill token into the first skill's prompt.
const stacked = parseStackedSlashCommands(raw, commands);
if (stacked.skills.length >= 2) {
return {
kind: 'message',
messageType: 'info',
content: `Stacked skill invocations (${stacked.skills
.map((skill) => `/${skill.name}`)
.join(
' ',
)}) are not yet available in the OpenTUI renderer. Run each skill separately.`,
};
}
const resolution = resolveSlashCommand(raw, commands);
if (resolution.type === 'unknown') {
return {
kind: 'message',
messageType: 'error',
content: `Unknown command: ${resolution.input}`,
};
}
const { command, args } = resolution;
// Telemetry parity (ink slashCommandProcessor): every executed command
// logs a SUCCESS/ERROR slash-command event, including parent commands
// that return early (help listing / bare handled).
const subcommand =
resolution.canonicalPath.length > 1
? resolution.canonicalPath.slice(1).join(' ')
: undefined;
const logEvent = (status: SlashCommandStatus) => {
if (!env.config) return;
logSlashCommand(
env.config,
makeSlashCommandEvent({
command: resolution.canonicalPath[0],
subcommand,
status,
}),
);
};
if (!command.action) {
if (command.subCommands && command.subCommands.length > 0) {
const helpText = `Command '/${command.name}' requires a subcommand. Available:\n${command.subCommands
.map((sc) => ` - ${sc.name}: ${sc.description || ''}`)
.join('\n')}`;
logEvent(SlashCommandStatus.SUCCESS);
return { kind: 'message', messageType: 'info', content: helpText };
}
logEvent(SlashCommandStatus.SUCCESS);
return { kind: 'handled' };
}
// Skill-specific telemetry: skill invocations feed /stats skills via
// recordSkillInvocation + recordAutoSkillCommandUsage.
const isSkillCommand = command.kind === CommandKind.SKILL;
const skillName = command.skillDetail?.name ?? command.name;
const recordSkill = (success: boolean) => {
if (env.config && isSkillCommand) {
recordSkillInvocation(env.config, { skillName, success });
}
};
let cleared = false;
const addedItems: HistoryItemWithoutId[] = [];
const context = {
executionMode: 'interactive',
invocation: { raw: raw.trim(), name: command.name, args },
services: {
config: env.config,
settings: env.settings,
logger: null,
},
ui: {
// Live transcript, not a hardcoded []: /doctor's oversized-tool-output
// scan (and any other history reader) must see the real session.
get history() {
return env.history ? [...env.history] : [];
},
addItem: (item: HistoryItemWithoutId) => {
addedItems.push(item);
return 0;
},
clear: () => {
cleared = true;
},
setDebugMessage: () => {},
pendingItem: null,
setPendingItem: () => {},
btwItem: null,
setBtwItem: () => {},
cancelBtw: () => {},
btwAbortControllerRef: { current: null },
isIdleRef: { current: true },
loadHistory: () => {},
refreshStatic: () => {},
toggleVimEnabled: () =>
env.toggleVimEnabled?.() ?? Promise.resolve(false),
setMemoryFileCount: () => {},
reloadCommands: () => {},
setSessionName: () => {},
extensionsUpdateState: new Map(),
dispatchExtensionStateUpdate: () => {},
addConfirmUpdateExtensionRequest: () => {},
},
session: {
// Commands such as /quit read session timing stats and /clear persists
// them; supply the backend's real stats when available. Absent stats
// get a start time of now: an epoch fabrication would stamp ~57-year
// durations into the persisted usage history (clearCommand's
// ?? new Date() guard passes a non-nullish epoch straight through).
stats: env.sessionStats ?? {
sessionId: '',
sessionStartTime: new Date(),
metrics: {},
lastPromptTokenCount: 0,
promptCount: 0,
},
// /clear calls config.startNewSession() and checks this seam to
// hand the new session id to the backend's SessionStatsState; without
// it the reset is silently skipped (commands-context.ts wires the
// same seam to the host).
startNewSession: env.startNewSession
? (sessionId: string) => env.startNewSession!(sessionId)
: undefined,
sessionShellAllowlist: new Set<string>(),
},
abortSignal: env.abortSignal,
} as unknown as CommandContext;
try {
// ink races the action against the abort signal so ESC cancels
// non-cooperative commands (e.g. /compress, whose tryCompressChat takes
// no AbortSignal) instead of blocking until they settle.
let result: SlashCommandActionReturn | void;
if (env.abortSignal) {
const signal = env.abortSignal;
if (signal.aborted) {
// Already aborted: skip the action entirely — its side effects
// (clear, persist, addItem) must not run on a cancelled submission.
result = undefined;
} else {
const aborted = new Promise<undefined>((resolve) => {
signal.addEventListener('abort', () => resolve(undefined), {
once: true,
});
});
result = await Promise.race([command.action(context, args), aborted]);
}
} else {
result = await command.action(context, args);
}
// ink discards command results once the submission is aborted.
if (env.abortSignal?.aborted) {
return { kind: 'handled' };
}
recordSkill(true);
if (isSkillCommand && env.config) {
void recordAutoSkillCommandUsage(env.config, command);
}
logEvent(SlashCommandStatus.SUCCESS);
if (cleared) {
return { kind: 'clear' };
}
const effect = mapActionResult(result, command, env);
if (addedItems.length > 0) {
const notice = projectAddedItems(addedItems, env);
if (effect.kind === 'handled') {
return { kind: 'message', messageType: 'info', content: notice };
}
return { ...effect, notice };
}
return effect;
} catch (error) {
// ink's mirrored catch checks the signal first: an ESC-cancelled
// command (the action rejects with AbortError) is the user's own
// cancellation, not a failure — no error telemetry, no error message.
if (env.abortSignal?.aborted) {
return { kind: 'handled' };
}
recordSkill(false);
logEvent(SlashCommandStatus.ERROR);
return {
kind: 'message',
messageType: 'error',
content: `Command '/${command.name}' failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Projects history items to transcript text (ui.addItem payloads, quit
* messages, …); null when none of them has a projection.
*/
function projectItems(
items: readonly HistoryItemWithoutId[],
env: SlashDispatchEnv,
): string | null {
const texts = items
.map((item) =>
projectSpecialItemText(item, {
config: env.config,
stats: env.sessionStats,
// model-pricing (R1-92) resolves through settings.merged.modelPricing
settings: env.settings,
}),
)
.filter((text): text is string => Boolean(text));
return texts.length > 0 ? texts.join('\n') : null;
}
/**
* Projects history items a command added via `ui.addItem` (e.g. `/stats
* model`) to transcript text; falls back to an explicit parity deferral
* when no projection exists.
*/
function projectAddedItems(
items: HistoryItemWithoutId[],
env: SlashDispatchEnv,
): string {
return (
projectItems(items, env) ??
'This command renders a history item, which is not yet available in the OpenTUI renderer.'
);
}
@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies the OpenTUI auto-theme adaptation keeps the ink parity chain in
* order — COLORFGBG → OSC 10/11 (renderer probe) → macOS appearance → dark
* — plus Qwen Light/Dark pair selection and live `theme_mode` subscription.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { ThemeMode } from '@opentui/core';
import {
detectInitialThemeMode,
resolveAutoTheme,
resolveThemeMode,
subscribeThemeMode,
THEME_MODE_WAIT_MS,
type OpenTuiThemeModeEmitter,
type OpenTuiThemeModeHost,
} from './theme-auto.js';
import { QwenDark } from '../themes/qwen-dark.js';
import { QwenLight } from '../themes/qwen-light.js';
import { OSC11_TIMEOUT_MS } from '../themes/detect-terminal-theme.js';
describe('resolveThemeMode', () => {
it('keeps light, defaults everything else to dark', () => {
expect(resolveThemeMode('light')).toBe('light');
expect(resolveThemeMode('dark')).toBe('dark');
expect(resolveThemeMode(null)).toBe('dark');
expect(resolveThemeMode(undefined)).toBe('dark');
});
});
describe('resolveAutoTheme (ink ThemeManager auto parity)', () => {
it('selects the Qwen pair by detected brightness', () => {
expect(resolveAutoTheme('light')).toBe(QwenLight);
expect(resolveAutoTheme('dark')).toBe(QwenDark);
expect(resolveAutoTheme(null)).toBe(QwenDark);
});
});
describe('detectInitialThemeMode', () => {
beforeEach(() => {
delete process.env['COLORFGBG'];
});
afterEach(() => {
delete process.env['COLORFGBG'];
});
function makeHost(
overrides: Partial<OpenTuiThemeModeHost> = {},
): OpenTuiThemeModeHost {
return {
themeMode: null,
waitForThemeMode: vi.fn().mockResolvedValue(null),
...overrides,
};
}
it('prefers COLORFGBG over the renderer probe (ink chain order)', async () => {
process.env['COLORFGBG'] = '15;15'; // light background index
const wait = vi.fn();
const host = makeHost({ themeMode: 'dark', waitForThemeMode: wait });
await expect(detectInitialThemeMode(host)).resolves.toBe('light');
expect(wait).not.toHaveBeenCalled();
});
it('uses the renderer mode when already known (no probe wait)', async () => {
const wait = vi.fn();
const host = makeHost({ themeMode: 'light', waitForThemeMode: wait });
await expect(detectInitialThemeMode(host)).resolves.toBe('light');
expect(wait).not.toHaveBeenCalled();
});
it('waits for the renderer OSC 10/11 probe with the ink timeout', async () => {
const host = makeHost({
waitForThemeMode: vi.fn().mockResolvedValue('light'),
});
await expect(detectInitialThemeMode(host)).resolves.toBe('light');
expect(host.waitForThemeMode).toHaveBeenCalledWith(THEME_MODE_WAIT_MS);
// The wait window must track ink's probe timeout by construction.
expect(THEME_MODE_WAIT_MS).toBe(OSC11_TIMEOUT_MS);
});
it('falls back to COLORFGBG when the probe has no answer', async () => {
process.env['COLORFGBG'] = '15;0'; // dark background index
await expect(detectInitialThemeMode(makeHost())).resolves.toBe('dark');
});
it('degrades to the non-OSC fallbacks when the probe rejects', async () => {
const host = makeHost({
waitForThemeMode: vi.fn().mockRejectedValue(new Error('no tty')),
});
// macOS appearance (on darwin) or the dark default — never throws.
await expect(detectInitialThemeMode(host)).resolves.toMatch(
/^(dark|light)$/,
);
});
it('uses the sync fallbacks without a renderer', async () => {
process.env['COLORFGBG'] = '15;0';
await expect(detectInitialThemeMode()).resolves.toBe('dark');
await expect(detectInitialThemeMode(null)).resolves.toBe('dark');
});
});
describe('subscribeThemeMode', () => {
function makeEmitter(): OpenTuiThemeModeEmitter & {
emitted: (mode: ThemeMode) => void;
onSpy: ReturnType<typeof vi.fn>;
offSpy: ReturnType<typeof vi.fn>;
} {
let listener: ((mode: ThemeMode) => void) | undefined;
const onSpy = vi.fn(
(_event: 'theme_mode', cb: (mode: ThemeMode) => void) => {
listener = cb;
},
);
const offSpy = vi.fn(() => {
listener = undefined;
});
return {
on: onSpy,
off: offSpy,
onSpy,
offSpy,
emitted: (mode: ThemeMode) => listener?.(mode),
};
}
it('forwards live theme_mode events until unsubscribed', () => {
const emitter = makeEmitter();
const seen: ThemeMode[] = [];
const unsubscribe = subscribeThemeMode(emitter, (mode) => seen.push(mode));
expect(emitter.onSpy).toHaveBeenCalledWith(
'theme_mode',
expect.any(Function),
);
emitter.emitted('light');
emitter.emitted('dark');
expect(seen).toEqual(['light', 'dark']);
unsubscribe();
expect(emitter.offSpy).toHaveBeenCalledWith(
'theme_mode',
emitter.onSpy.mock.calls[0]![1],
);
emitter.emitted('light');
expect(seen).toEqual(['light', 'dark']);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Terminal theme auto-adaptation parity (OSC 10/11 + live switching).
*
* Ink's `auto` theme resolves the terminal's light/dark background through a
* detection chain (COLORFGBG → OSC 11 → macOS appearance → dark) and picks
* Qwen Light / Qwen Dark accordingly (`ThemeManager.resolveAutoTheme`).
* OpenTUI's renderer performs the OSC 10/11 probe itself and emits
* `theme_mode` on live changes, so this module combines both worlds: the
* renderer's mode wins, the ink sync chain is the fallback, and the Qwen
* light/dark pair selection matches the ink manager exactly.
*/
import type { ThemeMode } from '@opentui/core';
import type { Theme } from '../themes/theme.js';
import { QwenDark } from '../themes/qwen-dark.js';
import { QwenLight } from '../themes/qwen-light.js';
import {
detectFromColorFgBg,
detectMacOSTheme,
OSC11_TIMEOUT_MS,
} from '../themes/detect-terminal-theme.js';
/** Ink's OSC 11 probe timeout — the shared constant, not a hand copy. */
export const THEME_MODE_WAIT_MS = OSC11_TIMEOUT_MS;
/** Structural view of the OpenTUI renderer's theme-mode query API. */
export interface OpenTuiThemeModeHost {
themeMode: ThemeMode | null;
waitForThemeMode(timeoutMs?: number): Promise<ThemeMode | null>;
}
/** Structural view of the OpenTUI renderer's `theme_mode` event API. */
export interface OpenTuiThemeModeEmitter {
on(event: 'theme_mode', listener: (mode: ThemeMode) => void): unknown;
off(event: 'theme_mode', listener: (mode: ThemeMode) => void): unknown;
}
/**
* Normalises a probed mode to a definite value. Unknown / null stays dark —
* the exact ink default (`detectTerminalTheme` ends in 'dark').
*/
export function resolveThemeMode(
mode: ThemeMode | null | undefined,
): ThemeMode {
return mode === 'light' ? 'light' : 'dark';
}
/**
* Parity of `ThemeManager.resolveAutoTheme`: auto resolves to the Qwen pair,
* light terminal → Qwen Light, otherwise Qwen Dark.
*/
export function resolveAutoTheme(mode: ThemeMode | null | undefined): Theme {
return resolveThemeMode(mode) === 'light' ? QwenLight : QwenDark;
}
/**
* Initial dark/light resolution for OpenTUI, in the exact order of ink's
* async chain (`detectTerminalThemeAsync`): COLORFGBG first (instant), then
* the OSC 10/11 probe — performed by the renderer here —, then macOS system
* appearance, then the dark default.
*/
export async function detectInitialThemeMode(
host?: OpenTuiThemeModeHost | null,
timeoutMs: number = THEME_MODE_WAIT_MS,
): Promise<ThemeMode> {
const colorFgBg = detectFromColorFgBg();
if (colorFgBg) {
return colorFgBg;
}
if (host) {
if (host.themeMode) {
return host.themeMode;
}
try {
const waited = await host.waitForThemeMode(timeoutMs);
if (waited) {
return waited;
}
} catch {
// A failing probe must degrade to the fallback chain, never crash.
}
}
return detectMacOSTheme() ?? 'dark';
}
/**
* Subscribes to live terminal theme changes (OSC 10/11 updates reported by
* the renderer as `theme_mode`). Returns the unsubscribe function.
*/
export function subscribeThemeMode(
emitter: OpenTuiThemeModeEmitter,
onChange: (mode: ThemeMode) => void,
): () => void {
emitter.on('theme_mode', onChange);
return () => {
emitter.off('theme_mode', onChange);
};
}
@@ -0,0 +1,208 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies every built-in ink theme maps onto OpenTUI: the 15 selectable
* themes plus the NoColor theme (the 16th, activated via NO_COLOR), palette
* values taken from the same semantic tokens the ink UI renders with, and
* syntax styles taken from each theme's resolved hljs color map.
*/
import { describe, it, expect, vi } from 'vitest';
vi.mock('@opentui/core', () => ({
SyntaxStyle: {
fromStyles: (styles: Record<string, unknown>) => ({ styles }),
},
}));
import {
createSyntaxStyle,
getActiveOpenTuiTheme,
getBuiltInOpenTuiThemes,
getOpenTuiTheme,
HLJS_TO_SYNTAX_TOKEN,
} from './theme-parity.js';
import { themeManager } from '../themes/theme-manager.js';
import { QwenDark } from '../themes/qwen-dark.js';
const PALETTE_KEYS = [
'text',
'dim',
'accent',
'green',
'red',
'yellow',
'purple',
'hover',
] as const;
describe('getBuiltInOpenTuiThemes', () => {
it('maps all 15 selectable ink themes', () => {
const builtIns = getBuiltInOpenTuiThemes();
const expected = themeManager
.getAvailableThemes()
.filter((theme) => !theme.isCustom);
expect(builtIns).toHaveLength(expected.length);
expect(builtIns.map((theme) => theme.name)).toEqual(
expected.map((theme) => theme.name),
);
});
it('produces a full palette and syntax map for every built-in theme', () => {
for (const definition of getBuiltInOpenTuiThemes()) {
for (const key of PALETTE_KEYS) {
expect(
typeof definition.palette[key],
`${definition.name}.${key}`,
).toBe('string');
}
expect(definition.syntaxStyles['emphasis']).toMatchObject({
italic: true,
});
expect(definition.syntaxStyles['strong']).toMatchObject({ bold: true });
expect(definition.type).toMatch(/^(dark|light|ansi)$/);
}
});
it('includes the Qwen pair and the ANSI themes', () => {
const names = getBuiltInOpenTuiThemes().map((theme) => theme.name);
for (const expected of [
'Qwen Dark',
'Qwen Light',
'Dracula',
'ANSI',
'ANSI Light',
'GitHub Light',
]) {
expect(names).toContain(expected);
}
});
});
describe('palette parity (semantic tokens → opentui palette)', () => {
it('maps Qwen Dark through its semantic tokens with the hljs default text', () => {
const definition = getOpenTuiTheme('Qwen Dark');
expect(definition).toBeDefined();
// Semantic text.primary is empty for this theme; ink falls back to the
// theme default color, so opentui must too.
expect(definition!.palette.text).toBe('#bfbdb6');
expect(definition!.palette.dim).toBe('#6C7086');
expect(definition!.palette.accent).toBe('#CBA6F7');
expect(definition!.palette.green).toBe('#A6E3A1');
expect(definition!.palette.red).toBe('#F38BA8');
expect(definition!.palette.yellow).toBe('#F9E2AF');
expect(definition!.palette.purple).toBe('#89B4FA');
expect(definition!.palette.hover).toBe('#1E1E2E');
});
it('maps Dracula from its own color set', () => {
const definition = getOpenTuiTheme('Dracula');
expect(definition!.palette).toEqual({
text: '#a3afb7',
dim: '#6272a4',
accent: '#ff79c6',
green: '#50fa7b',
red: '#ff5555',
yellow: '#fff783',
purple: '#8be9fd',
hover: '#282a36',
});
});
it('keeps ANSI color names in the syntax map (palette follows semantic tokens)', () => {
const definition = getOpenTuiTheme('ANSI');
// The ANSI theme wires its UI palette through the dark semantic tokens
// (exactly as the ink theme passes darkSemanticColors), so the ANSI names
// live in the syntax styles, not the palette.
expect(definition!.palette.text).toBe('white');
expect(definition!.syntaxStyles['keyword']).toEqual({ fg: 'blue' });
expect(definition!.syntaxStyles['string']).toEqual({ fg: 'yellow' });
expect(definition!.syntaxStyles['comment']).toEqual({ fg: 'green' });
});
});
describe('syntax style parity (hljs map → opentui tokens)', () => {
it('uses each theme’s own resolved hljs colors', () => {
// Ink resolves hljs colors through `resolveColor`, which lowercases hex.
const qwen = getOpenTuiTheme('Qwen Dark')!.syntaxStyles;
expect(qwen['keyword']).toEqual({ fg: '#ffd700' });
expect(qwen['string']).toEqual({ fg: '#aad94c' });
expect(qwen['comment']).toEqual({ fg: '#646a71' });
expect(qwen['variable']).toEqual({ fg: '#bfbdb6' });
expect(qwen['type']).toEqual({ fg: '#39bae6' });
const dracula = getOpenTuiTheme('Dracula')!.syntaxStyles;
expect(dracula['keyword']).toEqual({ fg: '#8be9fd' });
expect(dracula['string']).toEqual({ fg: '#fff783' });
expect(dracula['comment']).toEqual({ fg: '#6272a4' });
expect(dracula['heading']).toEqual({ fg: '#8be9fd' });
});
it('keeps the fixed italic/bold semantics for emphasis and strong', () => {
const styles = getOpenTuiTheme('Dracula')!.syntaxStyles;
expect(styles['emphasis']).toEqual({ italic: true });
expect(styles['strong']).toEqual({ bold: true });
});
it('omits tokens the theme has no hljs color for (default-fg parity)', () => {
const styles = getOpenTuiTheme('Dracula')!.syntaxStyles;
// Dracula defines no hljs-number entry — opentui must fall back, not
// invent a color.
expect(styles['number']).toBeUndefined();
});
it('only emits token names the opentui renderer understands', () => {
const known = new Set(HLJS_TO_SYNTAX_TOKEN.map(([, token]) => token));
known.add('emphasis');
known.add('strong');
for (const definition of getBuiltInOpenTuiThemes()) {
for (const token of Object.keys(definition.syntaxStyles)) {
expect(
known,
`${definition.name}: unexpected token ${token}`,
).toContain(token);
}
}
});
});
describe('getOpenTuiTheme / unknown themes', () => {
it('returns undefined for unknown theme names', () => {
expect(getOpenTuiTheme('does-not-exist')).toBeUndefined();
});
});
describe('getActiveOpenTuiTheme (NO_COLOR parity)', () => {
it('returns the active theme (Qwen Dark by default)', () => {
expect(getActiveOpenTuiTheme().name).toBe(QwenDark.name);
});
it('switches to the empty NoColor palette under NO_COLOR', () => {
const previous = process.env['NO_COLOR'];
process.env['NO_COLOR'] = '1';
try {
const definition = getActiveOpenTuiTheme();
expect(definition.name).toBe('NoColor');
for (const key of PALETTE_KEYS) {
expect(definition.palette[key]).toBe('');
}
} finally {
if (previous === undefined) delete process.env['NO_COLOR'];
else process.env['NO_COLOR'] = previous;
}
});
});
describe('createSyntaxStyle', () => {
it('builds the opentui SyntaxStyle from the mapped styles', () => {
const definition = getOpenTuiTheme('Dracula')!;
const style = createSyntaxStyle(definition) as unknown as {
styles: Record<string, unknown>;
};
expect(style.styles).toBe(definition.syntaxStyles);
});
});
+137
View File
@@ -0,0 +1,137 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Theme parity: maps every built-in ink theme (the 15 selectable themes plus
* the NO_COLOR theme the ink `ThemeManager` activates under `NO_COLOR`) onto
* the OpenTUI rendering world — a `Palette` for the mutable `C`-style color
* object and an opentui `SyntaxStyle` token map for markdown/code. The ink
* side stays the single source of truth: definitions are derived from the
* live `themeManager`, including its `NO_COLOR` override in
* `getActiveTheme()`.
*/
import { SyntaxStyle, type StyleDefinitionInput } from '@opentui/core';
import type { Palette } from './theme.js';
import type { Theme, ThemeType } from '../themes/theme.js';
import { themeManager } from '../themes/theme-manager.js';
export interface OpenTuiThemeDefinition {
name: string;
type: ThemeType;
palette: Palette;
syntaxStyles: Record<string, StyleDefinitionInput>;
}
/**
* hljs token classes (ink's `Theme._colorMap` keys) mapped onto opentui
* `SyntaxStyle` token names — the same names `theme.ts`'s built-in styles
* use. Tokens without an hljs counterpart stay unset and fall back to the
* element foreground, mirroring ink's "color omitted → default" behavior.
*/
export const HLJS_TO_SYNTAX_TOKEN: ReadonlyArray<readonly [string, string]> = [
['hljs-keyword', 'keyword'],
['hljs-string', 'string'],
['hljs-comment', 'comment'],
['hljs-function', 'function'],
['hljs-type', 'type'],
['hljs-number', 'number'],
['hljs-variable', 'variable'],
['hljs-link', 'link'],
['hljs-section', 'heading'],
];
/**
* Derives the OpenTUI palette from an ink theme's semantic tokens. Empty
* strings are preserved — they mean "no color" in ink and must stay unset in
* opentui (e.g. the NoColor theme, or themes with an empty Foreground that
* rely on the terminal default).
*/
export function paletteFromInkTheme(theme: Theme): Palette {
const semantic = theme.semanticColors;
return {
text: semantic.text.primary || theme.defaultColor,
dim: semantic.text.secondary,
accent: semantic.text.accent,
green: semantic.status.success,
red: semantic.status.error,
yellow: semantic.status.warning,
purple: semantic.text.link,
hover: semantic.background.primary,
};
}
/**
* Derives the opentui `SyntaxStyle.fromStyles` input from an ink theme's
* resolved hljs color map. Emphasis/strong keep their fixed markdown
* semantics (italic/bold), as in both the ink renderer and the previous
* hard-coded opentui styles.
*/
export function syntaxStylesFromInkTheme(
theme: Theme,
): Record<string, StyleDefinitionInput> {
const styles: Record<string, StyleDefinitionInput> = {};
for (const [hljsClass, token] of HLJS_TO_SYNTAX_TOKEN) {
const fg = theme.getInkColor(hljsClass);
if (fg) {
styles[token] = { fg };
}
}
const emphasis = theme.getInkColor('hljs-emphasis');
styles['emphasis'] = emphasis
? { fg: emphasis, italic: true }
: { italic: true };
const strong = theme.getInkColor('hljs-strong');
styles['strong'] = strong ? { fg: strong, bold: true } : { bold: true };
return styles;
}
/** Full OpenTUI theme definition derived from one ink theme. */
export function openTuiThemeFromInkTheme(theme: Theme): OpenTuiThemeDefinition {
return {
name: theme.name,
type: theme.type,
palette: paletteFromInkTheme(theme),
syntaxStyles: syntaxStylesFromInkTheme(theme),
};
}
/** Resolves one theme by name (built-in, custom or file path), or undefined. */
export function getOpenTuiTheme(
name: string,
): OpenTuiThemeDefinition | undefined {
const theme = themeManager.getTheme(name);
return theme ? openTuiThemeFromInkTheme(theme) : undefined;
}
/** All built-in ink themes mapped for OpenTUI, in dialog order. */
export function getBuiltInOpenTuiThemes(): OpenTuiThemeDefinition[] {
return themeManager
.getAvailableThemes()
.filter((display) => !display.isCustom)
.flatMap((display) => {
const definition = getOpenTuiTheme(display.name);
return definition ? [definition] : [];
});
}
/**
* The currently active theme mapped for OpenTUI — includes the ink parity
* rule that `NO_COLOR` forces the NoColor theme.
*/
export function getActiveOpenTuiTheme(): OpenTuiThemeDefinition {
return openTuiThemeFromInkTheme(themeManager.getActiveTheme());
}
/** Builds a live opentui `SyntaxStyle` for a theme definition. */
export function createSyntaxStyle(
definition: OpenTuiThemeDefinition,
): SyntaxStyle {
return SyntaxStyle.fromStyles(definition.syntaxStyles);
}
+180
View File
@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Verifies the OpenTUI palette/syntax switching: light/dark mode swaps and
* the settings `ui.theme` face (applyOpenTuiTheme), including the
* `markup.*` markdown tokens the OpenTUI markdown renderable needs for
* heading / inline-code / emphasis / link styling.
*/
import { describe, it, expect, vi } from 'vitest';
// theme.ts builds SyntaxStyles at module scope; the real implementation
// needs the OpenTUI native FFI. Capture the registered token maps instead.
vi.mock('@opentui/core', () => ({
SyntaxStyle: {
fromStyles: (styles: Record<string, unknown>) => ({ styles }),
},
}));
import {
C,
SYNTAX,
applyOpenTuiTheme,
applyThemeMode,
markdownMarkupTokens,
} from './theme.js';
import type { OpenTuiThemeDefinition } from './theme-parity.js';
function syntaxTokens(): Record<string, unknown> {
return (SYNTAX as unknown as { styles: Record<string, unknown> }).styles;
}
describe('markdownMarkupTokens', () => {
it('covers the markdown renderable capture names', () => {
for (const mode of ['dark', 'light'] as const) {
const tokens = markdownMarkupTokens(mode);
for (const key of [
'markup.heading',
'markup.heading.1',
'markup.heading.2',
'markup.heading.3',
'markup.raw',
'markup.italic',
'markup.strong',
'markup.link',
'markup.link.url',
]) {
expect(tokens[key], `${mode}:${key}`).toBeDefined();
}
expect(tokens['markup.heading.1']).toMatchObject({ bold: true });
expect(tokens['markup.italic']).toMatchObject({ italic: true });
expect(tokens['markup.link.url']).toMatchObject({ underline: true });
}
});
});
describe('applyThemeMode', () => {
it('switches palette and syntax style by terminal mode', () => {
applyThemeMode('light');
expect(C.bg).toBe('#FAFAFA');
expect(syntaxTokens()['default']).toMatchObject({ fg: '#1f2328' });
expect(syntaxTokens()['markup.heading.1']).toBeDefined();
applyThemeMode('dark');
expect(C.bg).toBeUndefined();
expect(syntaxTokens()['default']).toMatchObject({ fg: '#e6edf3' });
expect(syntaxTokens()['markup.raw']).toBeDefined();
});
it('defaults unknown modes to dark', () => {
applyThemeMode(null);
expect(C.bg).toBeUndefined();
applyThemeMode(undefined);
expect(C.bg).toBeUndefined();
});
});
describe('applyOpenTuiTheme (settings ui.theme face)', () => {
const palette = {
text: '#112233',
dim: '#445566',
accent: '#778899',
green: '#00aa00',
red: '#aa0000',
yellow: '#aaaa00',
purple: '#aa00aa',
hover: '#010101',
};
it('applies the mapped palette and keeps dark transparency', () => {
applyOpenTuiTheme({
name: 'Some Dark',
type: 'dark',
palette,
syntaxStyles: { keyword: { fg: '#abcdef' } },
} satisfies OpenTuiThemeDefinition);
expect(C.text).toBe('#112233');
expect(C.hover).toBe('#010101');
expect(C.bg).toBeUndefined();
const tokens = syntaxTokens();
expect(tokens['default']).toMatchObject({ fg: '#112233' });
expect(tokens['keyword']).toMatchObject({ fg: '#abcdef' });
// Markdown structure tokens survive the named-theme swap.
expect(tokens['markup.heading.1']).toBeDefined();
});
it('paints the block background for light themes', () => {
applyOpenTuiTheme({
name: 'Some Light',
type: 'light',
palette,
syntaxStyles: {},
} satisfies OpenTuiThemeDefinition);
expect(C.bg).toBe('#FAFAFA');
expect(syntaxTokens()['markup.raw']).toBeDefined();
// Restore the dark default for other suites.
applyThemeMode('dark');
});
it('skips empty-string palette values (NoColor) instead of overwriting the surface', () => {
// The NoColor theme maps every ink color to ''. Downstream parseColor('')
// is not "unset" — it falls back to magenta — so the empties must be
// dropped and the built-in dark surface left in place.
const empty = {
text: '',
dim: '',
accent: '',
green: '',
red: '',
yellow: '',
purple: '',
hover: '',
};
applyOpenTuiTheme({
name: 'NoColor',
type: 'dark',
palette: empty,
syntaxStyles: {},
} satisfies OpenTuiThemeDefinition);
expect(C.text).toBe('#CDD6F4');
expect(C.hover).toBe('#313244');
expect(C.bg).toBeUndefined();
expect(syntaxTokens()['default']).toBeUndefined();
// Restore the dark default for other suites.
applyThemeMode('dark');
});
it('resolves CSS color names ink accepts (coral) instead of degrading to magenta', () => {
// opentui's parseColor knows only a small named table; ink themes
// accept the CSS names, so the palette must be resolved to hex first.
applyOpenTuiTheme({
name: 'Coral Dark',
type: 'dark',
palette: { ...palette, text: 'coral' },
syntaxStyles: {},
} satisfies OpenTuiThemeDefinition);
expect(C.text).toBe('#ff7f50');
// Restore the dark default for other suites.
applyThemeMode('dark');
});
it('keeps unresolvable palette values unset instead of degrading to magenta', () => {
applyOpenTuiTheme({
name: 'Odd Dark',
type: 'dark',
palette: { ...palette, text: 'not-a-color' },
syntaxStyles: { keyword: { fg: 'not-a-color' } },
} satisfies OpenTuiThemeDefinition);
expect(C.text).toBe('#CDD6F4');
// The unresolvable style registered without a fg color.
expect(syntaxTokens()['keyword']).toMatchObject({});
expect((syntaxTokens()['keyword'] as { fg?: string }).fg).toBeUndefined();
// Restore the dark default for other suites.
applyThemeMode('dark');
});
});
+216
View File
@@ -0,0 +1,216 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Theme support: detect the terminal's light/dark mode via OSC 10/11
* (opentui `waitForThemeMode`), subscribe to live changes (`theme_mode`),
* and swap the palette. POC previously hard-coded a dark palette, which is
* invisible on light terminal themes (Warp light, etc.).
*
* Named-theme support (settings `ui.theme` / `/theme`) layers on top through
* `applyOpenTuiTheme`, which maps one of the ink themes (via theme-parity)
* onto the mutable `C` palette and `SYNTAX` token map.
*/
import { SyntaxStyle, type StyleDefinitionInput } from '@opentui/core';
import type { OpenTuiThemeDefinition } from './theme-parity.js';
import { toHex } from '../themes/color-utils.js';
export interface Palette {
text: string;
dim: string;
accent: string;
green: string;
red: string;
yellow: string;
purple: string;
hover: string;
/** Mode background; lets selection colors keep contrast on light themes. */
bg?: string;
selectionBg?: string;
selectionFg?: string;
}
// Hex values mirror the original qwen-code default themes (themes/theme.ts):
// dark = Catppuccin-like, light = original light palette.
const DARK: Palette = {
text: '#CDD6F4',
dim: '#6C7086',
accent: '#CBA6F7',
green: '#A6E3A1',
red: '#F38BA8',
yellow: '#F9E2AF',
purple: '#89B4FA',
hover: '#313244',
// No bg on dark: the default invert selection (bg=text fg, fg=black) is
// readable on dark terminals, and leaving bg unset keeps transparency.
selectionBg: '#264F78',
selectionFg: '#FFFFFF',
};
const LIGHT: Palette = {
text: '#1F2328',
dim: '#97a0b0',
accent: '#8B5CF6',
green: '#3CA84B',
red: '#DD4C4C',
yellow: '#D5A40A',
purple: '#3B82F6',
hover: '#E6E9EF',
// Light: paint the markdown block with the original light theme's
// Background so opentui's invert-selection (fg→bg swap) stays readable —
// with an undefined cell bg the fallback selection fg is black-on-black.
bg: '#FAFAFA',
selectionBg: '#ADD6FF',
selectionFg: '#1F2328',
};
/** Mutable palette object — components read `C.x` at render time;
* `applyThemeMode` mutates it and a React re-render picks it up. */
export const C: Palette = { ...DARK };
function buildSyntax(mode: 'dark' | 'light'): SyntaxStyle {
const styles =
mode === 'light'
? {
// `default` colors unstyled markdown chunks (table cells, plain
// inline text); without it TextTable falls back to #FFFFFF.
default: { fg: '#1f2328' },
keyword: { fg: '#cf222e', bold: true },
string: { fg: '#0a3069' },
comment: { fg: '#59636e', italic: true },
function: { fg: '#8250df' },
type: { fg: '#953800' },
number: { fg: '#0550ae' },
operator: { fg: '#0550ae' },
variable: { fg: '#1f2328' },
heading: { fg: '#0550ae', bold: true },
emphasis: { italic: true },
strong: { bold: true },
link: { fg: '#0969da' },
code: { fg: '#0a3069' },
}
: {
default: { fg: '#e6edf3' },
keyword: { fg: '#bb9af7', bold: true },
string: { fg: '#9ece6a' },
comment: { fg: '#565f89', italic: true },
function: { fg: '#7aa2f7' },
type: { fg: '#e0af68' },
number: { fg: '#ff9e64' },
operator: { fg: '#89ddff' },
variable: { fg: '#e6edf3' },
heading: { fg: '#7aa2f7', bold: true },
emphasis: { italic: true },
strong: { bold: true },
link: { fg: '#7aa2f7' },
code: { fg: '#9ece6a' },
};
return SyntaxStyle.fromStyles({
...styles,
...markdownMarkupTokens(mode),
});
}
/**
* The OpenTUI markdown renderable styles inline structure and headings with
* tree-sitter `markup.*` captures (not the `emphasis`/`strong`/`code`/…
* token names used for fenced code). Without these entries headings render
* unstyled and inline code / emphasis / links lose their formatting, so the
* mapped names mirror the markdown / markdown_inline `highlights.scm`
* captures: `markup.heading[.N]`, `markup.raw`, `markup.italic`,
* `markup.strong`, `markup.link[.url|.label]`.
*/
export function markdownMarkupTokens(
mode: 'dark' | 'light',
): Record<
string,
{ fg?: string; bold?: boolean; italic?: boolean; underline?: boolean }
> {
const heading = mode === 'light' ? '#0550ae' : '#7aa2f7';
const inlineCode = mode === 'light' ? '#0a3069' : '#9ece6a';
const link = mode === 'light' ? '#0969da' : '#7aa2f7';
const headingStyle = { fg: heading, bold: true };
return {
'markup.heading': headingStyle,
'markup.heading.1': headingStyle,
'markup.heading.2': headingStyle,
'markup.heading.3': headingStyle,
'markup.heading.4': headingStyle,
'markup.heading.5': headingStyle,
'markup.heading.6': headingStyle,
'markup.raw': { fg: inlineCode },
'markup.italic': { italic: true },
'markup.strong': { bold: true },
'markup.link': { fg: link },
'markup.link.label': { fg: link },
'markup.link.url': { fg: link, underline: true },
};
}
/** Mutable syntax style — rebuilt on theme change. */
export let SYNTAX: SyntaxStyle = buildSyntax('dark');
export function applyThemeMode(
mode: 'dark' | 'light' | null | undefined,
): void {
const m = mode === 'light' ? 'light' : 'dark';
const surface = m === 'light' ? LIGHT : DARK;
Object.assign(C, surface);
// The dark surface has no `bg` (terminal transparency); Object.assign
// never deletes keys, so a previous light `bg` must be cleared explicitly.
C.bg = surface.bg;
SYNTAX = buildSyntax(m);
}
/**
* Applies one mapped ink theme (theme-parity `OpenTuiThemeDefinition`) — the
* settings `ui.theme` / `/theme` path. The mapped palette carries the
* semantic text/status colors but no opentui-only surface colors (bg /
* selection / hover contrast), so those are re-derived from the built-in
* dark/light surfaces based on the theme type to keep selection readable.
*
* Colors are resolved to #rrggbb first: opentui's parseColor recognizes only
* a small named-color table, while ink themes accept the ~120 CSS names
* ('coral', …) and *bright names — unresolved, they would silently degrade
* to magenta. Unresolvable values stay unset (ink degrades similarly).
*/
export function applyOpenTuiTheme(definition: OpenTuiThemeDefinition): void {
const light = definition.type === 'light';
const surface = light ? LIGHT : DARK;
// Empty-string palette values mean "no color" in ink themes (the NoColor
// theme is all empty strings) and must stay unset like unresolvable ones.
const palette = Object.fromEntries(
Object.entries(definition.palette)
.map(([key, value]) => [key, value === '' ? undefined : toHex(value)])
.filter(([, hex]) => hex !== undefined),
) as Partial<Palette>;
Object.assign(C, surface, palette);
// The dark surface intentionally has no `bg` (keeps terminal transparency);
// Object.assign never clears keys, so reset it explicitly.
C.bg = surface.bg;
const syntaxStyles: Record<string, StyleDefinitionInput> = {};
for (const [token, style] of Object.entries(definition.syntaxStyles)) {
const resolved: StyleDefinitionInput = { ...style };
if (typeof style.fg === 'string') {
resolved.fg = toHex(style.fg);
if (resolved.fg === undefined) delete resolved.fg;
}
if (typeof style.bg === 'string') {
resolved.bg = toHex(style.bg);
if (resolved.bg === undefined) delete resolved.bg;
}
syntaxStyles[token] = resolved;
}
SYNTAX = SyntaxStyle.fromStyles({
// `default` colors unstyled markdown chunks (table cells, plain inline
// text); anchor it on the theme's own foreground when it resolved.
...(palette.text ? { default: { fg: palette.text } } : {}),
...syntaxStyles,
// Markdown structure tokens are not part of the ink hljs maps; keep
// headings/inline styling alive on the theme's dark/light family.
...markdownMarkupTokens(light ? 'light' : 'dark'),
});
}
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more