mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 18:36:00 -10:00
fix(ci): adapt the /resolve replay to the publish-resolution job split
This commit is contained in:
commit
cde150c9dd
159 files changed
+27484
-684
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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  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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user