Merge branch 'main' into autofix/issue-6554

This commit is contained in:
易良 2026-07-09 11:36:18 +08:00 committed by GitHub
commit db5dbb5921
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2510 additions and 178 deletions

View file

@ -8,14 +8,13 @@ name: 'Qwen Autofix'
# The lifecycle is asynchronous — a PR is opened in one run and its review is
# addressed in a later run once a reviewer has weighed in — so each scheduled
# tick runs only the phase(s) that make sense, decided by the `route` job:
# • every 4h → review phase (sweep the bot's open PRs)
# • every 12h (00/12 UTC) → also the issue phase (locate + fix one new bug)
# • issues:labeled → issue phase when ready label, state, and sender match
# • pull_request_review → review phase (real-time feedback loop for bot PRs)
# • workflow_dispatch → force a phase, an issue, or a PR
# • every 10m → review phase; issue phase only if no PR needs work
# • issues:labeled → issue phase when ready label, state, and sender match
# • pull_request_review → review phase for submitted feedback on bot PRs
# • workflow_dispatch → force a phase, an issue, or a PR
#
# Every GitHub write (issue/PR comments, labels, branch push, PR create) goes
# through CI_DEV_BOT_PAT so the bot acts as a single identity, qwen-code-dev-bot.
# through CI_DEV_BOT_PAT so the bot acts as the configured autofix identity.
# PAT label writes can emit issues:labeled events; the route guards below make
# those runs exit unless the label, issue state, and ready label all match.
on:
@ -26,8 +25,7 @@ on:
types:
- 'submitted'
schedule:
- cron: '0 0,12 * * *' # Issue + review every 12h; must match route SCHEDULE check
- cron: '0 4,8,16,20 * * *' # Review only between issue runs
- cron: '*/10 * * * *' # Review first; issue fallback only when no PR needs work
workflow_dispatch:
inputs:
phase:
@ -64,7 +62,7 @@ permissions:
env:
# Identity of the autofix bot. All open in-repo PRs authored by this bot are
# eligible for the review phase — not limited to autofix/issue-* branches.
AUTOFIX_BOT: 'qwen-code-dev-bot'
AUTOFIX_BOT: "${{ vars.AUTOFIX_BOT_LOGIN || 'qwen-code-dev-bot' }}"
# Branch-name prefix used by the issue phase when creating PRs. Also used
# for duplicate-PR detection and issue-number extraction from branch names.
BRANCH_PREFIX: 'autofix/issue-'
@ -77,7 +75,9 @@ env:
TRUSTED_ASSOC: '["OWNER", "MEMBER", "COLLABORATOR"]'
# Hard cap on automated review-address rounds per PR. After this the bot stops
# and leaves the PR for a human.
MAX_ROUNDS: '3'
MAX_ROUNDS: '5'
# Do not claim more issues when too many existing autofix PRs are still open.
MAX_OPEN_AUTOFIX_PRS: '5'
jobs:
# ---------------------------------------------------------------------------
@ -147,8 +147,9 @@ jobs:
if [[ "${EVENT_NAME}" == 'schedule' || "${EVENT_NAME}" == 'workflow_dispatch' ]]; then
DO_REVIEW=true
fi
# Must match the issue-phase cron string on the schedule trigger.
if [[ "${EVENT_NAME}" == 'schedule' && "${SCHEDULE}" == '0 0,12 * * *' ]]; then
# Scheduled runs scan review PRs first; issue-autofix runs only
# when review-scan reports no target.
if [[ "${EVENT_NAME}" == 'schedule' ]]; then
DO_ISSUE=true
fi
# Real-time review triggers: only process in-repo bot PRs with
@ -248,9 +249,13 @@ jobs:
# ISSUE PHASE — locate one maintainer-ready issue, fix it, open a PR.
# ===========================================================================
issue-autofix:
needs: 'route'
needs: ['route', 'review-scan']
if: |-
${{ needs.route.outputs.do_issue == 'true' }}
${{
always() &&
needs.route.outputs.do_issue == 'true' &&
(github.event_name != 'schedule' || (needs.review-scan.result == 'success' && needs.review-scan.outputs.has_targets != 'true'))
}}
runs-on: 'ubuntu-latest'
timeout-minutes: 180
concurrency:
@ -371,6 +376,7 @@ jobs:
FORCED_ISSUE: '${{ needs.route.outputs.issue_number || github.event.issue.number }}'
run: |-
mkdir -p "${WORKDIR}"
OPEN_AUTOFIX_PR_COUNT=0
if [[ -n "${FORCED_ISSUE}" ]]; then
echo "🎯 Forced issue #${FORCED_ISSUE}"
@ -406,34 +412,51 @@ jobs:
fi
fi
else
echo "🔍 Ready-for-agent issues (newest first)..."
if ! gh issue list --repo "${REPO}" \
if ! gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \
--limit 100 --json number,headRefName,isCrossRepository > "${WORKDIR}/open-autofix-prs.json"; then
echo "::warning::Open autofix PR scan failed; proceeding without WIP-cap enforcement."
else
OPEN_AUTOFIX_PR_COUNT="$(jq --arg p "${BRANCH_PREFIX}" \
'[.[] | select((.isCrossRepository != true) and ((.headRefName // "") | startswith($p)))] | length' \
"${WORKDIR}/open-autofix-prs.json")"
fi
if [[ "${OPEN_AUTOFIX_PR_COUNT}" -ge "${MAX_OPEN_AUTOFIX_PRS}" ]]; then
echo "⏭️ ${OPEN_AUTOFIX_PR_COUNT} open autofix PR(s) already exist; WIP limit is ${MAX_OPEN_AUTOFIX_PRS}; skipping issue fallback."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
else
echo "🔍 Ready-for-agent issues (newest first)..."
if ! gh issue list --repo "${REPO}" \
--search "is:open is:issue label:${READY_FOR_AGENT_LABEL} label:${AUTOFIX_APPROVED_LABEL} ${AUTOFIX_ISSUE_EXCLUDES}" \
--limit 30 --json number,title,body,labels,createdAt,url \
> "${WORKDIR}/scan.json"; then
echo "::warning::Ready-for-agent issue scan failed; falling back to an empty candidate list."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
else
if ! jq -c '.[0:10] | map(. + {autofixTier: 1})' \
"${WORKDIR}/scan.json" > "${WORKDIR}/candidates.json"; then
echo "::warning::Ready-for-agent result processing failed; falling back to an empty candidate list."
echo "::warning::Ready-for-agent issue scan failed; falling back to an empty candidate list."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
else
if ! jq -c '.[0:10] | map(. + {autofixTier: 1})' \
"${WORKDIR}/scan.json" > "${WORKDIR}/candidates.json"; then
echo "::warning::Ready-for-agent result processing failed; falling back to an empty candidate list."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
fi
fi
fi
fi
COUNT="$(jq length "${WORKDIR}/candidates.json")"
if [[ "${COUNT}" -gt 0 ]]; then
if ! gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \
--limit 100 --json number,headRefName > "${WORKDIR}/open-autofix-prs.json"; then
if [[ -s "${WORKDIR}/open-autofix-prs.json" ]]; then
echo " Reusing open autofix PR scan for duplicate-PR annotation."
elif ! gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \
--limit 100 --json number,headRefName,isCrossRepository > "${WORKDIR}/open-autofix-prs.json"; then
echo "::warning::Open autofix PR scan failed; candidates will proceed without duplicate-PR annotation."
else
fi
if [[ -s "${WORKDIR}/open-autofix-prs.json" ]]; then
if ! jq -c --arg p "${BRANCH_PREFIX}" --slurpfile prs "${WORKDIR}/open-autofix-prs.json" '
($prs[0] // []) as $prs
| map(
($p + (.number | tostring)) as $branch
| (
first($prs[] | select((.headRefName // "") == $branch) | {
first($prs[] | select((.isCrossRepository != true) and ((.headRefName // "") == $branch)) | {
number,
headRefName
}) // null
@ -804,8 +827,8 @@ jobs:
if: |-
${{ steps.decision.outputs.go_issue != '' && needs.route.outputs.dry_run != 'true' }}
env:
# CI_DEV_BOT_PAT (the qwen-code-dev-bot PAT) opens the PR as
# qwen-code-dev-bot. This is required: the default GITHUB_TOKEN is
# CI_DEV_BOT_PAT opens the PR as the configured autofix bot. This is
# required: the default GITHUB_TOKEN is
# blocked from creating PRs ("GitHub Actions is not permitted to
# create or approve pull requests"), and PRs it does create do not
# trigger CI. The bot PAT clears both problems.
@ -813,7 +836,7 @@ jobs:
ISSUE: '${{ steps.decision.outputs.go_issue }}'
run: |-
if [[ -z "${GITHUB_TOKEN}" ]]; then
echo '::error::CI_DEV_BOT_PAT is required to publish the PR as qwen-code-dev-bot.'
echo '::error::CI_DEV_BOT_PAT is required to publish the PR as the autofix bot.'
exit 1
fi
api_error_file="$(mktemp)"
@ -901,8 +924,8 @@ jobs:
fi
# ===========================================================================
# REVIEW PHASE (scan) — find every autofix PR with new, unaddressed feedback
# (or a base conflict) and emit them as a matrix. Cheap: GitHub API only.
# REVIEW PHASE (scan) — find one autofix PR with new, unaddressed feedback
# (or a base conflict) and emit it as a matrix. Cheap: GitHub API only.
# ===========================================================================
review-scan:
needs: 'route'
@ -936,6 +959,7 @@ jobs:
and ((.baseRefName // "") == "main"))' <<< "${META}")"
if [[ "${OK}" != "true" ]]; then
echo "❌ #${FORCED_PR} is not an open in-repo main-targeting PR owned by ${AUTOFIX_BOT}"
echo "targets=[]" >> "${GITHUB_OUTPUT}"
echo "has_targets=false" >> "${GITHUB_OUTPUT}"
exit 0
fi
@ -943,10 +967,10 @@ jobs:
else
gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \
--base main \
--limit 100 --json number,headRefName > "${WORKDIR}/bot-prs.json"
# gh pr list with --author on the same repo only returns in-repo PRs,
# and --base main limits to main-targeting PRs.
CANDIDATES="$(jq -r '.[] | .number' "${WORKDIR}/bot-prs.json")"
--limit 100 --json number,headRefName,isCrossRepository > "${WORKDIR}/bot-prs.json"
CANDIDATES="$(jq -r \
'.[] | select(.isCrossRepository != true) | .number' \
"${WORKDIR}/bot-prs.json")"
fi
TARGETS='[]'
@ -959,7 +983,18 @@ jobs:
ISSUE="${PR}"
fi
HEAD_SHA="$(gh api "repos/${REPO}/pulls/${PR}" --jq '.head.sha')"
CHECKS_JSON="$(gh pr view "${PR}" --repo "${REPO}" \
--json statusCheckRollup --jq '.statusCheckRollup // []' 2> /dev/null || echo '[]')"
HAS_PENDING_CHECKS="$(jq -r '
[ .[]
| select((.status // .state // "") | IN("QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED"))
| select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address")))) ]
| length > 0
' <<< "${CHECKS_JSON}")"
if [[ "${HAS_PENDING_CHECKS}" == "true" ]]; then
echo "⏳ #${PR}: PR has pending checks; skipping until the current verification finishes"
continue
fi
# Push watermark: the PR's last push. Feedback older than this was in
# front of the agent on a previous round.
PUSH_WM="$(gh api "repos/${REPO}/commits/${HEAD_SHA}" --jq '.commit.committer.date')"
@ -983,6 +1018,13 @@ jobs:
echo "🚧 #${PR}: hit MAX_ROUNDS (${ROUND}/${MAX_ROUNDS}) — leaving for a human"
continue
fi
N_FAILED_CHECKS="$(jq --arg wm "${EFF_WM}" '
[ .[]
| select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED"))
| select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address"))))
| select((.completedAt // .updatedAt // "") > $wm) ]
| length
' <<< "${CHECKS_JSON}")"
gh api "repos/${REPO}/pulls/${PR}/reviews" --paginate > "${WORKDIR}/rv.json"
gh api "repos/${REPO}/pulls/${PR}/comments" --paginate > "${WORKDIR}/rc.json"
@ -1021,17 +1063,18 @@ jobs:
HAS_CONFLICT='false'
if [[ "${MERGEABLE}" == "CONFLICTING" ]]; then HAS_CONFLICT='true'; fi
if [[ "${N_REVIEWS}" -eq 0 && "${N_COMMENTS}" -eq 0 && "${N_ISSUE_COMMENTS}" -eq 0 && "${HAS_CONFLICT}" != "true" ]]; then
if [[ "${N_REVIEWS}" -eq 0 && "${N_COMMENTS}" -eq 0 && "${N_ISSUE_COMMENTS}" -eq 0 && "${N_FAILED_CHECKS}" -eq 0 && "${HAS_CONFLICT}" != "true" ]]; then
echo "✅ #${PR}: nothing new since ${EFF_WM} (conflict=${HAS_CONFLICT})"
continue
fi
echo "🔎 #${PR}: ${N_REVIEWS} review(s) + ${N_COMMENTS} inline + ${N_ISSUE_COMMENTS} issue comment(s) new, conflict=${HAS_CONFLICT}, round=${ROUND}"
echo "🔎 #${PR}: ${N_REVIEWS} review(s) + ${N_COMMENTS} inline + ${N_ISSUE_COMMENTS} issue comment(s) + ${N_FAILED_CHECKS} failed check(s) new, conflict=${HAS_CONFLICT}, round=${ROUND}"
TARGETS="$(jq -c \
--arg pr "${PR}" --arg branch "${BRANCH}" --arg issue "${ISSUE}" \
--arg round "${ROUND}" --arg wm "${EFF_WM}" \
'. + [{pr: $pr, branch: $branch, issue: $issue, round: $round, watermark: $wm}]' \
<<< "${TARGETS}")"
break # one PR per scheduled scan
done
COUNT="$(jq 'length' <<< "${TARGETS}")"
@ -1181,6 +1224,9 @@ jobs:
gh api "repos/${REPO}/pulls/${PR}/reviews" --paginate > "${WORKDIR}/rv.json"
gh api "repos/${REPO}/pulls/${PR}/comments" --paginate > "${WORKDIR}/rc.json"
gh api "repos/${REPO}/issues/${PR}/comments" --paginate > "${WORKDIR}/ic.json"
gh pr view "${PR}" --repo "${REPO}" \
--json statusCheckRollup --jq '.statusCheckRollup // []' > "${WORKDIR}/checks.json" \
2> /dev/null || echo '[]' > "${WORKDIR}/checks.json"
# Newest actionable feedback timestamp — stamped into the eval marker so
# the next scan knows everything up to here has been considered.
@ -1200,7 +1246,11 @@ jobs:
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((.body // "") | test("<!-- (autofix-eval|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not) | .created_at))
| max // ""' "${WORKDIR}/rv.json" "${WORKDIR}/rc.json" "${WORKDIR}/ic.json")"
+ (.[3] | map(select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED"))
| select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address"))))
| select((.completedAt // .updatedAt // "") > $wm)
| (.completedAt // .updatedAt // "")))
| max // ""' "${WORKDIR}/rv.json" "${WORKDIR}/rc.json" "${WORKDIR}/ic.json" "${WORKDIR}/checks.json")"
[[ -z "${NEWEST}" ]] && NEWEST="${WATERMARK}"
echo "newest=${NEWEST}" >> "${GITHUB_OUTPUT}"
@ -1244,6 +1294,15 @@ jobs:
| select((.body // "") | test("<!-- (autofix-eval|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not)
| "- @\(.user.login): \(.body // "" | gsub("\r"; ""))"' \
"${WORKDIR}/ic.json"
echo
echo "## Failed checks"
jq -r --arg wm "${WATERMARK}" '
.[]
| select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED"))
| select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address"))))
| select((.completedAt // .updatedAt // "") > $wm)
| "- \((.workflowName // "external check") | gsub("[^A-Za-z0-9 _./()-]"; "") | .[0:80]): \(.conclusion // .state // "?")"' \
"${WORKDIR}/checks.json"
} > "${WORKDIR}/feedback.md"
echo '--- feedback.md ---'
cat "${WORKDIR}/feedback.md"

View file

@ -54,6 +54,15 @@ const { mockRunManagedAutoMemoryDream, mockRunManagedRememberByAgent } =
mockRunManagedRememberByAgent: vi.fn(),
}));
const { mockDebugLogger } = vi.hoisted(() => ({
mockDebugLogger: {
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock('@agentclientprotocol/sdk', () => ({
AgentSideConnection: vi.fn().mockImplementation(() => ({
get closed() {
@ -119,12 +128,7 @@ vi.mock('node:stream', async (importOriginal) => {
// Mock core dependencies
vi.mock('@qwen-code/qwen-code-core', () => ({
createDebugLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
}),
createDebugLogger: () => mockDebugLogger,
registerAcpEventLoopLagGauge: vi.fn(),
startEventLoopLagMonitor: vi.fn(() => ({
snapshot: vi.fn(() => ({
@ -3431,6 +3435,354 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});
it('rejects workspace memory remember when the bridge reports managed memory unavailable', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});
mockRunManagedRememberByAgent.mockRejectedValue({
data: {
errorKind: 'managed_memory_unavailable',
details: 'memory service stopped',
},
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
let rejection: unknown;
try {
await agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
});
} catch (err) {
rejection = err;
}
expect(rejection).toMatchObject({
code: -32009,
message: 'Managed memory is unavailable for this daemon workspace',
data: { errorKind: 'managed_memory_unavailable' },
});
expect(
(rejection as { data: Record<string, unknown> }).data,
).not.toHaveProperty('details');
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember failed:',
expect.objectContaining({
code: 'managed_memory_unavailable',
details: 'memory service stopped',
}),
);
mockConnectionState.resolve();
await agentPromise;
});
it('includes details for workspace memory remember failures', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});
mockRunManagedRememberByAgent.mockRejectedValue(
new Error('remember agent stopped early'),
);
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
}),
).rejects.toMatchObject({
code: -32099,
message: 'Workspace memory remember failed',
data: {
errorKind: 'remember_failed',
details: 'remember agent stopped early',
},
});
mockConnectionState.resolve();
await agentPromise;
});
it('logs sanitized details for workspace memory failures', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});
mockRunManagedRememberByAgent.mockRejectedValue(
new Error('Authorization: Bearer secret-token-value'),
);
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
}),
).rejects.toMatchObject({
code: -32099,
message: 'Workspace memory remember failed',
data: {
errorKind: 'remember_failed',
details: 'Authorization: <redacted>',
},
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember failed:',
expect.objectContaining({
code: 'remember_failed',
details: 'Authorization: <redacted>',
stack: expect.stringContaining('Authorization: <redacted>'),
}),
);
expect(JSON.stringify(mockDebugLogger.error.mock.calls)).not.toContain(
'secret-token-value',
);
mockConnectionState.resolve();
await agentPromise;
});
it('falls back when workspace memory error code extraction throws', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});
const err = new Proxy(
{},
{
get() {
throw new Error('code getter failed');
},
},
);
mockRunManagedRememberByAgent.mockRejectedValue(err);
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const error = await agent
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: -32099,
message: 'Workspace memory remember failed',
data: { errorKind: 'remember_failed' },
});
expect(
(error as { data?: Record<string, unknown> }).data,
).not.toHaveProperty('details');
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
'Failed to extract workspace memory error code:',
{ extractionError: 'code getter failed' },
);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember failed:',
expect.objectContaining({
code: 'remember_failed',
details: '<details unavailable>',
}),
);
mockConnectionState.resolve();
await agentPromise;
});
it('uses remember-specific error codes for workspace memory remember timeouts', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});
mockRunManagedRememberByAgent.mockRejectedValue(new Error('late abort'));
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const controller = new AbortController();
controller.abort();
const timeoutSpy = vi
.spyOn(AbortSignal, 'timeout')
.mockReturnValue(controller.signal);
try {
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
}),
).rejects.toMatchObject({
code: -32099,
message: 'Workspace memory remember timed out',
data: { errorKind: 'remember_timeout', details: 'late abort' },
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember timed out:',
expect.objectContaining({
code: 'remember_timeout',
details: 'late abort',
stack: expect.stringContaining('late abort'),
}),
);
} finally {
timeoutSpy.mockRestore();
mockConnectionState.resolve();
await agentPromise;
}
});
it('preserves timeout codes for timed out workspace memory remember unavailable errors', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});
mockRunManagedRememberByAgent.mockRejectedValue({
data: {
errorKind: 'managed_memory_unavailable',
details: 'memory service stopped',
},
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const controller = new AbortController();
controller.abort();
const timeoutSpy = vi
.spyOn(AbortSignal, 'timeout')
.mockReturnValue(controller.signal);
try {
const error = await agent
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: -32099,
message: 'Workspace memory remember timed out',
data: {
errorKind: 'remember_timeout',
details: 'memory service stopped',
},
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember timed out:',
expect.objectContaining({
code: 'remember_timeout',
details: 'memory service stopped',
}),
);
} finally {
timeoutSpy.mockRestore();
mockConnectionState.resolve();
await agentPromise;
}
});
it('omits details for workspace memory failures without a detail source', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});
mockRunManagedRememberByAgent.mockRejectedValue({
code: 'remember_failed',
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const error = await agent
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: -32099,
data: { errorKind: 'remember_failed' },
});
expect(
(error as { data?: Record<string, unknown> }).data,
).not.toHaveProperty('details');
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember failed:',
expect.objectContaining({ details: '<details unavailable>' }),
);
mockConnectionState.resolve();
await agentPromise;
});
it('runs workspace memory forget without requiring a session', async () => {
const forget = vi.fn().mockResolvedValue({
systemMessage: 'Forgot 1 entry.',
@ -3556,8 +3908,71 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
).rejects.toMatchObject({
code: -32099,
message: 'Workspace memory forget failed',
data: { errorKind: 'forget_failed' },
data: { errorKind: 'forget_failed', details: 'boom' },
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory forget failed:',
expect.objectContaining({
code: 'forget_failed',
details: 'boom',
stack: expect.stringContaining('boom'),
}),
);
mockConnectionState.resolve();
await agentPromise;
});
it('rejects workspace memory forget when the bridge reports managed memory unavailable', async () => {
const forget = vi.fn().mockRejectedValue({
data: {
errorKind: 'managed_memory_unavailable',
details: 'memory service stopped',
},
});
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
getMemoryManager: vi.fn().mockReturnValue({ forget }),
getChatRecordingService: vi.fn().mockReturnValue({
recordUiTelemetryEvent: vi.fn(),
}),
getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'),
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const error = await agent
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryForget, {
query: 'old preference',
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: -32009,
message: 'Managed memory is unavailable for this daemon workspace',
data: { errorKind: 'managed_memory_unavailable' },
});
expect(
(error as { data?: Record<string, unknown> }).data,
).not.toHaveProperty('details');
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory forget failed:',
expect.objectContaining({
code: 'managed_memory_unavailable',
details: 'memory service stopped',
}),
);
mockConnectionState.resolve();
await agentPromise;
@ -3600,8 +4015,79 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
).rejects.toMatchObject({
code: -32099,
message: 'Workspace memory forget timed out',
data: { errorKind: 'forget_timeout' },
data: { errorKind: 'forget_timeout', details: 'late abort' },
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory forget timed out:',
expect.objectContaining({
code: 'forget_timeout',
details: 'late abort',
stack: expect.stringContaining('late abort'),
}),
);
} finally {
timeoutSpy.mockRestore();
mockConnectionState.resolve();
await agentPromise;
}
});
it('preserves timeout codes for timed out workspace memory forget unavailable errors', async () => {
const forget = vi.fn().mockRejectedValue({
data: {
errorKind: 'managed_memory_unavailable',
details: 'memory service stopped',
},
});
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
getMemoryManager: vi.fn().mockReturnValue({ forget }),
getChatRecordingService: vi.fn().mockReturnValue({
recordUiTelemetryEvent: vi.fn(),
}),
getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'),
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const controller = new AbortController();
controller.abort();
const timeoutSpy = vi
.spyOn(AbortSignal, 'timeout')
.mockReturnValue(controller.signal);
try {
const error = await agent
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryForget, {
query: 'old preference',
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: -32099,
message: 'Workspace memory forget timed out',
data: {
errorKind: 'forget_timeout',
details: 'memory service stopped',
},
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory forget timed out:',
expect.objectContaining({
code: 'forget_timeout',
details: 'memory service stopped',
}),
);
} finally {
timeoutSpy.mockRestore();
mockConnectionState.resolve();
@ -3690,8 +4176,68 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
).rejects.toMatchObject({
code: -32099,
message: 'Workspace memory dream failed',
data: { errorKind: 'dream_failed' },
data: { errorKind: 'dream_failed', details: 'boom' },
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory dream failed:',
expect.objectContaining({
code: 'dream_failed',
details: 'boom',
stack: expect.stringContaining('boom'),
}),
);
mockConnectionState.resolve();
await agentPromise;
});
it('rejects workspace memory dream when the bridge reports managed memory unavailable', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
getChatRecordingService: vi.fn().mockReturnValue({
recordUiTelemetryEvent: vi.fn(),
}),
getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'),
});
mockRunManagedAutoMemoryDream.mockRejectedValue({
data: {
errorKind: 'managed_memory_unavailable',
details: 'memory service stopped',
},
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const error = await agent
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream, {})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: -32009,
message: 'Managed memory is unavailable for this daemon workspace',
data: { errorKind: 'managed_memory_unavailable' },
});
expect(
(error as { data?: Record<string, unknown> }).data,
).not.toHaveProperty('details');
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory dream failed:',
expect.objectContaining({
code: 'managed_memory_unavailable',
details: 'memory service stopped',
}),
);
mockConnectionState.resolve();
await agentPromise;
@ -3731,8 +4277,76 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
).rejects.toMatchObject({
code: -32099,
message: 'Workspace memory dream timed out',
data: { errorKind: 'dream_timeout' },
data: { errorKind: 'dream_timeout', details: 'late abort' },
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory dream timed out:',
expect.objectContaining({
code: 'dream_timeout',
details: 'late abort',
stack: expect.stringContaining('late abort'),
}),
);
} finally {
timeoutSpy.mockRestore();
mockConnectionState.resolve();
await agentPromise;
}
});
it('preserves timeout codes for timed out workspace memory dream unavailable errors', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
getChatRecordingService: vi.fn().mockReturnValue({
recordUiTelemetryEvent: vi.fn(),
}),
getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'),
});
mockRunManagedAutoMemoryDream.mockRejectedValue({
data: {
errorKind: 'managed_memory_unavailable',
details: 'memory service stopped',
},
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const controller = new AbortController();
controller.abort();
const timeoutSpy = vi
.spyOn(AbortSignal, 'timeout')
.mockReturnValue(controller.signal);
try {
const error = await agent
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream, {})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: -32099,
message: 'Workspace memory dream timed out',
data: {
errorKind: 'dream_timeout',
details: 'memory service stopped',
},
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory dream timed out:',
expect.objectContaining({
code: 'dream_timeout',
details: 'memory service stopped',
}),
);
} finally {
timeoutSpy.mockRestore();
mockConnectionState.resolve();

View file

@ -168,7 +168,12 @@ import {
buildDisabledSkillNamesProvider,
loadCliConfig,
} from '../config/config.js';
import { extractRememberErrorCode } from '../serve/workspace-remember-errors.js';
import {
createWorkspaceMemoryExtractionErrorLogger,
shouldSuppressRememberErrorDetails,
workspaceMemoryFailureCode,
workspaceMemoryFailureDiagnostics,
} from '../serve/workspace-remember-errors.js';
import { formatWorkspaceMemoryForgetSummary } from '../serve/workspace-memory-summaries.js';
import { mapSkillConfigToStatus } from '../serve/workspace-skills-mapping.js';
import { Session, buildAvailableCommandsSnapshot } from './session/Session.js';
@ -272,6 +277,19 @@ const BTW_CHILD_TIMEOUT_MS = 55_000;
// Must be less than WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS (300s) in bridge.ts.
const WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS = 295_000;
function workspaceMemoryErrorData(
code: string,
diagnostics: { details?: string },
): { errorKind: string; details?: string } {
return {
errorKind: code,
...(diagnostics.details ? { details: diagnostics.details } : {}),
};
}
const logWorkspaceMemoryExtractionError =
createWorkspaceMemoryExtractionErrorLogger(debugLogger);
function parseAcpLocalReadRootsEnv(
raw = process.env[QWEN_ACP_LOCAL_READ_ROOTS_ENV],
): string[] {
@ -5812,10 +5830,12 @@ class QwenAgent implements Agent {
const childSignal = AbortSignal.timeout(
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
);
let projectRoot = '<unknown>';
try {
projectRoot = this.config.getProjectRoot();
const result = await runManagedRememberByAgent({
config: this.config,
projectRoot: this.config.getProjectRoot(),
projectRoot,
content: content.trim(),
contextMode,
abortSignal: childSignal,
@ -5825,15 +5845,36 @@ class QwenAgent implements Agent {
if (err instanceof RequestError) {
throw err;
}
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
logWorkspaceMemoryExtractionError,
);
const code = workspaceMemoryFailureCode(
err,
'remember_failed',
logWorkspaceMemoryExtractionError,
);
if (childSignal.aborted) {
const timeoutCode = 'remember_timeout';
debugLogger.error('Workspace memory remember timed out:', {
projectRoot,
code: timeoutCode,
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
throw new RequestError(
-32099,
'Workspace memory remember timed out',
{ errorKind: 'remember_timeout' },
workspaceMemoryErrorData(timeoutCode, diagnostics),
);
}
const code = extractRememberErrorCode(err);
if (code === 'managed_memory_unavailable') {
debugLogger.error('Workspace memory remember failed:', {
projectRoot,
code,
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
if (shouldSuppressRememberErrorDetails(code)) {
throw new RequestError(
-32009,
'Managed memory is unavailable for this daemon workspace',
@ -5842,12 +5883,8 @@ class QwenAgent implements Agent {
}
throw new RequestError(
-32099,
err instanceof Error && err.message
? err.message
: 'Workspace memory remember failed',
{
errorKind: code,
},
'Workspace memory remember failed',
workspaceMemoryErrorData(code, diagnostics),
);
}
}
@ -5879,8 +5916,9 @@ class QwenAgent implements Agent {
const childSignal = AbortSignal.timeout(
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
);
let projectRoot = '<unknown>';
try {
const projectRoot = this.config.getProjectRoot();
projectRoot = this.config.getProjectRoot();
const hiddenConfig = createHiddenWorkspaceMemoryConfig(this.config);
const result = await this.config
.getMemoryManager()
@ -5900,26 +5938,47 @@ class QwenAgent implements Agent {
if (err instanceof RequestError) {
throw err;
}
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
logWorkspaceMemoryExtractionError,
);
const code = workspaceMemoryFailureCode(
err,
'forget_failed',
logWorkspaceMemoryExtractionError,
);
if (childSignal.aborted) {
const timeoutCode = 'forget_timeout';
debugLogger.error('Workspace memory forget timed out:', {
projectRoot,
code: timeoutCode,
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
throw new RequestError(
-32099,
'Workspace memory forget timed out',
{
errorKind: 'forget_timeout',
},
workspaceMemoryErrorData(timeoutCode, diagnostics),
);
}
const code = extractRememberErrorCode(err, 'forget_failed');
if (code === 'managed_memory_unavailable') {
debugLogger.error('Workspace memory forget failed:', {
projectRoot,
code,
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
if (shouldSuppressRememberErrorDetails(code)) {
throw new RequestError(
-32009,
'Managed memory is unavailable for this daemon workspace',
{ errorKind: 'managed_memory_unavailable' },
);
}
throw new RequestError(-32099, 'Workspace memory forget failed', {
errorKind: code,
});
throw new RequestError(
-32099,
'Workspace memory forget failed',
workspaceMemoryErrorData(code, diagnostics),
);
}
}
case SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream: {
@ -5934,9 +5993,11 @@ class QwenAgent implements Agent {
const childSignal = AbortSignal.timeout(
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
);
let projectRoot = '<unknown>';
try {
projectRoot = this.config.getProjectRoot();
const result = await runManagedAutoMemoryDream(
this.config.getProjectRoot(),
projectRoot,
new Date(),
createHiddenWorkspaceMemoryConfig(this.config),
childSignal,
@ -5955,22 +6016,47 @@ class QwenAgent implements Agent {
if (err instanceof RequestError) {
throw err;
}
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
logWorkspaceMemoryExtractionError,
);
const code = workspaceMemoryFailureCode(
err,
'dream_failed',
logWorkspaceMemoryExtractionError,
);
if (childSignal.aborted) {
throw new RequestError(-32099, 'Workspace memory dream timed out', {
errorKind: 'dream_timeout',
const timeoutCode = 'dream_timeout';
debugLogger.error('Workspace memory dream timed out:', {
projectRoot,
code: timeoutCode,
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
throw new RequestError(
-32099,
'Workspace memory dream timed out',
workspaceMemoryErrorData(timeoutCode, diagnostics),
);
}
const code = extractRememberErrorCode(err, 'dream_failed');
if (code === 'managed_memory_unavailable') {
debugLogger.error('Workspace memory dream failed:', {
projectRoot,
code,
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
if (shouldSuppressRememberErrorDetails(code)) {
throw new RequestError(
-32009,
'Managed memory is unavailable for this daemon workspace',
{ errorKind: 'managed_memory_unavailable' },
);
}
throw new RequestError(-32099, 'Workspace memory dream failed', {
errorKind: code,
});
throw new RequestError(
-32099,
'Workspace memory dream failed',
workspaceMemoryErrorData(code, diagnostics),
);
}
}
case SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart: {

View file

@ -102,7 +102,7 @@ function buildChromeDevToolsMcpRuntimeConfig(
) {
return undefined;
}
const command = resolveCdpMcpCommand();
const command = resolveCdpMcpCommand(process.env);
if (!command) {
writeStderrLine(
`qwen serve: set ${QWEN_CDP_MCP_COMMAND_ENV} to enable browser automation MCP (no adapter is bundled)`,

View file

@ -8,20 +8,23 @@
export const QWEN_CDP_MCP_COMMAND_ENV = 'QWEN_CDP_MCP_COMMAND';
export function resolveCdpMcpCommand(
env: NodeJS.ProcessEnv = process.env,
env: NodeJS.ProcessEnv,
): string | undefined {
const command = env[QWEN_CDP_MCP_COMMAND_ENV]?.trim();
return command ? command : undefined;
}
export function isBrowserAutomationMcpAvailable(opts: {
cdpTunnelOverWs?: boolean;
token?: string;
}): boolean {
export function isBrowserAutomationMcpAvailable(
opts: {
cdpTunnelOverWs?: boolean;
token?: string;
},
env: NodeJS.ProcessEnv,
): boolean {
return (
opts.cdpTunnelOverWs === true &&
!opts.token &&
process.env['QWEN_SERVE_ACP_HTTP'] !== '0' &&
resolveCdpMcpCommand() !== undefined
env['QWEN_SERVE_ACP_HTTP'] !== '0' &&
resolveCdpMcpCommand(env) !== undefined
);
}

View file

@ -1516,10 +1516,13 @@ describe('runQwenServe runtime startup failures', () => {
it('does not enable browser automation MCP on bearer-protected endpoints', () => {
expect(
isBrowserAutomationMcpAvailable({
cdpTunnelOverWs: true,
token: 'secret-token',
}),
isBrowserAutomationMcpAvailable(
{
cdpTunnelOverWs: true,
token: 'secret-token',
},
{},
),
).toBe(false);
});

View file

@ -854,7 +854,10 @@ function currentServeFeaturesForRunQwenServe(
// so the bootstrap `/capabilities` window doesn't briefly under-report them.
clientMcpOverWsEnabled: opts.clientMcpOverWs === true,
cdpTunnelOverWsEnabled: opts.cdpTunnelOverWs === true,
browserAutomationMcpAvailable: isBrowserAutomationMcpAvailable(opts),
browserAutomationMcpAvailable: isBrowserAutomationMcpAvailable(
opts,
process.env,
),
});
}

View file

@ -95,7 +95,10 @@ export function createServeFeatures(
multiWorkspaceSessionsEnabled,
clientMcpOverWsEnabled: opts.clientMcpOverWs === true,
cdpTunnelOverWsEnabled: opts.cdpTunnelOverWs === true,
browserAutomationMcpAvailable: isBrowserAutomationMcpAvailable(opts),
browserAutomationMcpAvailable: isBrowserAutomationMcpAvailable(
opts,
process.env,
),
voiceTranscriptionAvailable: getCachedVoiceTranscriptionAvailable(),
// Advertised whenever the `/voice/stream` WS endpoint exists (ACP HTTP
// on). A configured token no longer suppresses it — the browser carries

View file

@ -5,7 +5,14 @@
*/
import { describe, expect, it } from 'vitest';
import { extractRememberErrorCode } from './workspace-remember-errors.js';
import {
extractRememberErrorCode,
extractRememberErrorDetails,
extractRememberErrorStack,
shouldSuppressRememberErrorDetails,
workspaceMemoryFailureCode,
workspaceMemoryFailureDiagnostics,
} from './workspace-remember-errors.js';
describe('extractRememberErrorCode', () => {
it('extracts remember error codes from common error shapes', () => {
@ -25,9 +32,553 @@ describe('extractRememberErrorCode', () => {
cause: { code: 'remember_timeout' },
}),
).toBe('remember_timeout');
expect(
extractRememberErrorCode({
cause: { cause: { code: 'remember_path_escape' } },
}),
).toBe('remember_path_escape');
expect(extractRememberErrorCode(new Error('boom'))).toBe('remember_failed');
expect(extractRememberErrorCode(new Error('boom'), 'forget_failed')).toBe(
'forget_failed',
);
});
it('limits cause traversal depth', () => {
const root: Record<string, unknown> = {};
let current = root;
for (let index = 0; index < 60; index += 1) {
const next: Record<string, unknown> = {};
current['cause'] = next;
current = next;
}
current['code'] = 'too_deep';
expect(extractRememberErrorCode(root)).toBe('remember_failed');
});
it('falls back when code extraction throws', () => {
const extractionErrors: Array<{ target: string; message: string }> = [];
const err = new Proxy(
{},
{
get() {
throw new Error('code getter failed');
},
},
);
expect(
workspaceMemoryFailureCode(err, 'dream_failed', (target, cause) =>
extractionErrors.push({
target,
message: cause instanceof Error ? cause.message : String(cause),
}),
),
).toBe('dream_failed');
expect(extractionErrors).toEqual([
{ target: 'code', message: 'code getter failed' },
]);
});
it('keeps fallback behavior when code extraction logging throws', () => {
const err = new Proxy(
{},
{
get() {
throw new Error('code getter failed');
},
},
);
expect(
workspaceMemoryFailureCode(err, 'dream_failed', () => {
throw new Error('logger failed');
}),
).toBe('dream_failed');
});
});
describe('extractRememberErrorDetails', () => {
it('extracts details from common error shapes', () => {
expect(extractRememberErrorDetails(new Error('boom'))).toBe('boom');
expect(
extractRememberErrorDetails({
data: { details: 'agent stopped because max turns exceeded' },
}),
).toBe('agent stopped because max turns exceeded');
expect(extractRememberErrorDetails({ data: 'raw data detail' })).toBe(
'raw data detail',
);
expect(
extractRememberErrorDetails({
data: 'ERR_BRIDGE_INTERNAL',
message: 'Connection to memory service refused',
}),
).toBe('Connection to memory service refused');
expect(
extractRememberErrorDetails({
data: { message: 'provider rejected the request' },
}),
).toBe('provider rejected the request');
expect(
extractRememberErrorDetails({
cause: new Error('nested failure reason'),
}),
).toBe('nested failure reason');
expect(extractRememberErrorDetails({ cause: 'string cause reason' })).toBe(
'string cause reason',
);
expect(extractRememberErrorDetails('raw string error')).toBe(
'raw string error',
);
});
it('prefers bridge details over generic messages', () => {
expect(
extractRememberErrorDetails({
data: { details: 'specific bridge reason' },
message: 'generic message',
}),
).toBe('specific bridge reason');
});
it('redacts credentials before exposing details', () => {
const details = extractRememberErrorDetails(
new Error('Authorization: Bearer secret-token-value'),
);
expect(details).toBe('Authorization: <redacted>');
expect(details).not.toContain('secret-token-value');
});
it('normalizes hidden separators before redacting credentials', () => {
for (const separator of [
'\u00ad',
'\u061c',
'\u180e',
'\u200b',
'\u2060',
'\u2064',
]) {
const details = extractRememberErrorDetails(
new Error(`Authorization: Bearer${separator}secret-token-value`),
);
expect(details).toBe('Authorization: <redacted>');
expect(details).not.toContain('secret-token-value');
}
});
it('redacts credentials with hidden separators inside token values', () => {
for (const separator of [
'\u00ad',
'\u061c',
'\u180e',
'\u200b',
'\u2060',
'\u2064',
]) {
const details = extractRememberErrorDetails(
new Error(`Authorization: Bearer secret${separator}token-value`),
);
expect(details).toBe('Authorization: <redacted>');
expect(details).not.toContain('secret');
expect(details).not.toContain('token-value');
}
});
it('redacts bearer tokens split by control characters', () => {
const details = extractRememberErrorDetails(
new Error('Authorization: Bearer secret\x01token-value'),
);
expect(details).toBe('Authorization: <redacted>');
expect(details).not.toContain('secret');
expect(details).not.toContain('token-value');
});
it('redacts bearer tokens split by unicode space separators', () => {
for (const separator of [
'\u00a0',
'\u1680',
'\u2000',
'\u2009',
'\u200a',
'\u202f',
'\u205f',
'\u3000',
]) {
const details = extractRememberErrorDetails(
new Error(`Bearer sk-AAAAAAAAAA${separator}BBBBBBBBBBBBBBB`),
);
expect(details).toBe('Bearer <redacted>');
expect(details).not.toContain('AAAAAAAAAA');
expect(details).not.toContain('BBBBBBBBBBBBBBB');
}
});
it('redacts bearer tokens after unicode space separators', () => {
for (const separator of ['\u00a0', '\u2009', '\u202f']) {
const details = extractRememberErrorDetails(
new Error(`Bearer${separator}secret-token-value`),
);
expect(details).toBe('Bearer <redacted>');
expect(details).not.toContain('secret-token-value');
}
});
it('normalizes unicode space separators without collapsing words', () => {
const details = extractRememberErrorDetails(
new Error('missing column\u00a0name'),
);
expect(details).toBe('missing column name');
});
it('redacts bare tokens split by invisible characters', () => {
const details = extractRememberErrorDetails(
new Error('OpenAI key sk-AAAAAAAAAA\u2062BBBBBBBBBBBBBBB'),
);
expect(details).toBe('OpenAI key sk-<redacted>');
expect(details).not.toContain('AAAAAAAAAA');
expect(details).not.toContain('BBBBBBBBBBBBBBB');
});
it('redacts platform tokens split by invisible characters', () => {
for (const prefix of [
'ghp_',
'gho_',
'ghs_',
'ghu_',
'github_pat_',
'glpat-',
'xoxb-',
'xoxp-',
]) {
const details = extractRememberErrorDetails(
new Error(`Platform token ${prefix}AAAAAAAAAA\u200bBBBBBBBBBBBBBBB`),
);
expect(details).toBe('Platform token <redacted>');
expect(details).not.toContain('AAAAAAAAAA');
expect(details).not.toContain('BBBBBBBBBBBBBBB');
}
});
it('redacts platform tokens split by unicode space separators', () => {
const details = extractRememberErrorDetails(
new Error('Platform token ghp_AAAAAAAAAA\u00a0BBBBBBBBBBBBBBB'),
);
expect(details).toBe('Platform token <redacted>');
expect(details).not.toContain('AAAAAAAAAA');
expect(details).not.toContain('BBBBBBBBBBBBBBB');
});
it('redacts AWS access keys split by credential separators', () => {
for (const separator of ['\x01', '\u00a0', '\u200b']) {
const details = extractRememberErrorDetails(
new Error(`AWS key AKIA12345678${separator}90123456`),
);
expect(details).toBe('AWS key <redacted>');
expect(details).not.toContain('12345678');
expect(details).not.toContain('90123456');
}
});
it('redacts secret assignments split by credential separators', () => {
for (const separator of ['\x01', '\u00a0', '\u200b']) {
const details = extractRememberErrorDetails(
new Error(`token=aaaaaaaaaa${separator}bbbbbbbb`),
);
expect(details).toBe('token=<redacted>');
expect(details).not.toContain('aaaaaaaaaa');
expect(details).not.toContain('bbbbbbbb');
}
});
it('redacts env secret assignments split by credential separators', () => {
const details = extractRememberErrorDetails(
new Error('QWEN_DAEMON_TOKEN=AAAAAAAAAA\u00a0BBBBBBBB'),
);
expect(details).toBe('QWEN_DAEMON_TOKEN=<redacted>');
expect(details).not.toContain('AAAAAAAAAA');
expect(details).not.toContain('BBBBBBBB');
});
it('redacts URL credentials split by credential separators', () => {
for (const separator of ['\x01', '\u00a0', '\u200b']) {
const details = extractRememberErrorDetails(
new Error(
`postgresql://admin:S3cret${separator}P4ssw0rd@db.internal:5432/mydb`,
),
);
expect(details).toBe('postgresql://<redacted>@db.internal:5432/mydb');
expect(details).not.toContain('admin');
expect(details).not.toContain('S3cret');
expect(details).not.toContain('P4ssw0rd');
}
});
it('redacts bare bearer tokens separated by invisible characters', () => {
const details = extractRememberErrorDetails(
new Error('Bearer\u200BeyJhbGciOiABCDEFGHIJKLMN'),
);
expect(details).toBe('Bearer <redacted>');
expect(details).not.toContain('eyJhbGciOiABCDEFGHIJKLMN');
});
it('redacts QQBot tokens separated by invisible characters', () => {
const details = extractRememberErrorDetails(
new Error('QQBot\u200Bsecret-token-value'),
);
expect(details).toBe('QQBot <redacted>');
expect(details).not.toContain('secret-token-value');
});
it('normalizes line separators before redacting credentials', () => {
for (const separator of ['\u2028', '\u2029']) {
const details = extractRememberErrorDetails(
new Error(`Authorization: Bearer${separator}secret-token-value`),
);
expect(details).toBe('Authorization: <redacted>');
expect(details).not.toContain('secret-token-value');
}
});
it('normalizes bidi isolation characters before redacting credentials', () => {
for (const separator of ['\u2066', '\u2067', '\u2068', '\u2069']) {
const details = extractRememberErrorDetails(
new Error(`Authorization: Bearer${separator}secret-token-value`),
);
expect(details).toBe('Authorization: <redacted>');
expect(details).not.toContain('secret-token-value');
}
});
it('normalizes BOM before redacting credentials', () => {
const details = extractRememberErrorDetails(
new Error('Authorization: Bearer\ufeffsecret-token-value'),
);
expect(details).toBe('Authorization: <redacted>');
expect(details).not.toContain('secret-token-value');
});
it('sanitizes control characters', () => {
expect(extractRememberErrorDetails(new Error('line1\nline2\ttab'))).toBe(
'line1 line2 tab',
);
});
it('omits empty details after sanitization', () => {
expect(extractRememberErrorDetails(new Error('\u200b'))).toBeUndefined();
});
it('guards against circular causes', () => {
const cyclic: Record<string, unknown> = {};
cyclic['cause'] = cyclic;
expect(extractRememberErrorDetails(cyclic)).toBeUndefined();
});
it('limits cause traversal depth', () => {
const root: Record<string, unknown> = {};
let current = root;
for (let index = 0; index < 60; index += 1) {
const next: Record<string, unknown> = {};
current['cause'] = next;
current = next;
}
current['message'] = 'too deep';
expect(extractRememberErrorDetails(root)).toBeUndefined();
});
it('caps long details', () => {
const details = extractRememberErrorDetails(new Error('x'.repeat(1100)));
expect(details).toMatch(/^x+\.{3} \[truncated\]$/);
expect(details).toHaveLength(1000);
});
it('does not split surrogate pairs when capping long details', () => {
const details = extractRememberErrorDetails(
new Error(`${'x'.repeat(984)}${'😀'.repeat(100)}`),
);
expect(details).toBe(`${'x'.repeat(984)}... [truncated]`);
expect(details).toHaveLength(999);
});
it('keeps the full prefix when the cut point falls before a surrogate pair', () => {
const details = extractRememberErrorDetails(
new Error(`${'x'.repeat(985)}${'😀'.repeat(100)}`),
);
expect(details).toBe(`${'x'.repeat(985)}... [truncated]`);
expect(details).toHaveLength(1000);
});
});
describe('extractRememberErrorStack', () => {
it('redacts and caps error stacks before logging', () => {
const err = new Error('Authorization: Bearer secret-token-value');
err.stack = `Error: Authorization: Bearer secret-token-value\n\tat handler (/workspace/file.ts:1:1)\n${'x'.repeat(1100)}`;
const stack = extractRememberErrorStack(err);
expect(stack).toContain('Authorization: <redacted>');
expect(stack).toContain('\n\tat handler');
expect(stack).not.toContain('secret-token-value');
expect(stack).toHaveLength(1000);
});
it('preserves CRLF stack line endings before logging', () => {
const err = new Error('boom');
err.stack = 'Error: boom\r\n\tat handler (/workspace/file.ts:1:1)';
const stack = extractRememberErrorStack(err);
expect(stack).toBe('Error: boom\r\n\tat handler (/workspace/file.ts:1:1)');
});
it('normalizes unicode space separators in stacks without collapsing words', () => {
const err = new Error('boom');
err.stack =
'Error: missing column\u202fname\n\tat handler (/workspace/file.ts:1:1)';
const stack = extractRememberErrorStack(err);
expect(stack).toBe(
'Error: missing column name\n\tat handler (/workspace/file.ts:1:1)',
);
});
it('redacts stack bearer tokens split by control characters', () => {
const err = new Error('Authorization: Bearer secret\x01token-value');
err.stack =
'Error: Authorization: Bearer secret\x01token-value\n\tat handler (/workspace/file.ts:1:1)';
const stack = extractRememberErrorStack(err);
expect(stack).toContain('Authorization: <redacted>');
expect(stack).not.toContain('secret');
expect(stack).not.toContain('token-value');
});
});
describe('workspaceMemoryFailureDiagnostics', () => {
it('falls back when detail extraction throws', () => {
const extractionErrors: Array<{ target: string; message: string }> = [];
const err = new Proxy(
{},
{
get() {
throw new Error('detail getter failed');
},
},
);
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
(target, cause) =>
extractionErrors.push({
target,
message: cause instanceof Error ? cause.message : String(cause),
}),
);
expect(diagnostics).toEqual({ debugDetails: '<details unavailable>' });
expect(extractionErrors).toEqual([
{ target: 'details', message: 'detail getter failed' },
]);
});
it('keeps fallback diagnostics when detail extraction logging throws', () => {
const err = new Proxy(
{},
{
get() {
throw new Error('detail getter failed');
},
},
);
const diagnostics = workspaceMemoryFailureDiagnostics(err, () => {
throw new Error('logger failed');
});
expect(diagnostics).toEqual({ debugDetails: '<details unavailable>' });
});
it('falls back when stack extraction throws', () => {
const extractionErrors: Array<{ target: string; message: string }> = [];
const err = new Proxy(new Error('boom'), {
get(target, property, receiver) {
if (property === 'stack') {
throw new Error('stack getter failed');
}
return Reflect.get(target, property, receiver);
},
});
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
(target, cause) =>
extractionErrors.push({
target,
message: cause instanceof Error ? cause.message : String(cause),
}),
);
expect(diagnostics).toEqual({
details: 'boom',
debugDetails: 'boom',
});
expect(extractionErrors).toEqual([
{ target: 'stack', message: 'stack getter failed' },
]);
});
it('keeps fallback diagnostics when stack extraction logging throws', () => {
const err = new Proxy(new Error('boom'), {
get(target, property, receiver) {
if (property === 'stack') {
throw new Error('stack getter failed');
}
return Reflect.get(target, property, receiver);
},
});
const diagnostics = workspaceMemoryFailureDiagnostics(err, () => {
throw new Error('logger failed');
});
expect(diagnostics).toEqual({
details: 'boom',
debugDetails: 'boom',
});
});
});
describe('shouldSuppressRememberErrorDetails', () => {
it('suppresses details only for configured public errors', () => {
expect(
shouldSuppressRememberErrorDetails('managed_memory_unavailable'),
).toBe(true);
expect(shouldSuppressRememberErrorDetails('remember_failed')).toBe(false);
});
});

View file

@ -4,6 +4,71 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { redactLogCredentials } from '@qwen-code/acp-bridge/logRedaction';
const MAX_REMEMBER_ERROR_DETAILS_CHARS = 1000;
const MAX_REMEMBER_ERROR_CAUSE_DEPTH = 50;
const REMEMBER_ERROR_FORMAT_RE = /[\p{Cf}]|\p{Variation_Selector}/gu;
const REMEMBER_ERROR_SPACE_SEPARATOR_RE =
/[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/gu;
/* eslint-disable no-control-regex */
const REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE =
/[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector}/gu;
const REMEMBER_ERROR_AUTH_SCHEME_INVISIBLE_RE =
/\b(Bearer|QQBot)(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+(?=[A-Za-z0-9._~+/=-])/giu;
const REMEMBER_ERROR_AUTH_TOKEN_WITH_SEPARATORS_RE =
/\b(Bearer|QQBot)(?:\s|[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+((?:[A-Za-z0-9._~+/=-]+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*[A-Za-z0-9._~+/=-]+)/giu;
const REMEMBER_ERROR_BARE_TOKEN_WITH_SEPARATORS_RE =
/\b(?:sk-|ghp_|gho_|ghs_|ghu_|github_pat_|glpat-|xox[b]-|xox[p]-)(?:[A-Za-z0-9_-]+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*[A-Za-z0-9_-]+/gu;
const REMEMBER_ERROR_AWS_KEY_WITH_SEPARATORS_RE =
/\b(?:AKIA|ASIA)(?:[A-Z0-9]+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*[A-Z0-9]+/gu;
const REMEMBER_ERROR_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE =
/((?:api[_-]?key|token|secret|password|pwd)[_-]?[=:]\s*)((?:\S+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*\S+)/giu;
const REMEMBER_ERROR_ENV_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE =
/([A-Z][A-Z0-9]{0,50}(?:_[A-Z0-9]{1,50}){0,10}_(?:KEY|TOKEN|SECRET|PASSWORD)\s*[=:]\s*)((?:\S+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*\S+)/gu;
const REMEMBER_ERROR_URL_CREDENTIALS_WITH_SEPARATORS_RE =
/(\b[a-z][a-z0-9+.-]{0,31}:\/\/)([^/@\s]*(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+[^/@\s]*)@/giu;
const REMEMBER_ERROR_CONTROL_RE = /[\x00-\x1f\x7f-\x9f]/g;
/* eslint-enable no-control-regex */
const DETAIL_SUPPRESSED_REMEMBER_ERROR_CODES = new Set([
'managed_memory_unavailable',
]);
export interface WorkspaceMemoryFailureDiagnostics {
details?: string;
debugDetails: string;
stack?: string;
}
type RememberErrorExtractionTarget = 'code' | 'details' | 'stack';
type WorkspaceMemoryExtractionLogger = {
warn(message: string, context: { extractionError: string }): void;
};
export function createWorkspaceMemoryExtractionErrorLogger(
logger: WorkspaceMemoryExtractionLogger,
): (target: RememberErrorExtractionTarget, err: unknown) => void {
return (target, err) => {
logger.warn(`Failed to extract workspace memory error ${target}:`, {
extractionError: err instanceof Error ? err.message : String(err),
});
};
}
function reportRememberErrorExtractionFailure(
onExtractionError:
| ((target: RememberErrorExtractionTarget, err: unknown) => void)
| undefined,
target: RememberErrorExtractionTarget,
err: unknown,
): void {
try {
onExtractionError?.(target, err);
} catch {
// Preserve fallback behavior if extraction logging fails.
}
}
function errorCodeFromRecord(
record: Record<string, unknown>,
): string | undefined {
@ -19,19 +84,253 @@ function errorCodeFromRecord(
return undefined;
}
function rawRememberErrorCode(
err: unknown,
seen: WeakSet<object>,
depth: number,
): string | undefined {
if (depth > MAX_REMEMBER_ERROR_CAUSE_DEPTH) return undefined;
if (!err || typeof err !== 'object') return undefined;
if (seen.has(err)) return undefined;
seen.add(err);
const record = err as Record<string, unknown>;
const direct = errorCodeFromRecord(record);
if (direct) return direct;
const cause = record['cause'];
if (cause != null) {
return rawRememberErrorCode(cause, seen, depth + 1);
}
return undefined;
}
export function extractRememberErrorCode(
err: unknown,
fallback = 'remember_failed',
): string {
if (err && typeof err === 'object') {
const record = err as Record<string, unknown>;
const direct = errorCodeFromRecord(record);
if (direct) return direct;
const cause = record['cause'];
if (cause && typeof cause === 'object') {
const causedBy = errorCodeFromRecord(cause as Record<string, unknown>);
if (causedBy) return causedBy;
}
}
return fallback;
return rawRememberErrorCode(err, new WeakSet<object>(), 0) ?? fallback;
}
export function workspaceMemoryFailureCode(
err: unknown,
fallback = 'remember_failed',
onExtractionError?: (
target: RememberErrorExtractionTarget,
err: unknown,
) => void,
): string {
try {
return extractRememberErrorCode(err, fallback);
} catch (extractionErr) {
reportRememberErrorExtractionFailure(
onExtractionError,
'code',
extractionErr,
);
return fallback;
}
}
function detailFromRecord(
record: Record<string, unknown>,
seen: WeakSet<object>,
depth: number,
): string | undefined {
// Bridge errors carry the best failure reason in `data`; top-level
// `message` and `cause` are generic fallbacks.
const data = record['data'];
if (data && typeof data === 'object') {
const dataRecord = data as Record<string, unknown>;
const details = dataRecord['details'];
if (typeof details === 'string' && details.length > 0) return details;
const message = dataRecord['message'];
if (typeof message === 'string' && message.length > 0) return message;
}
const message = record['message'];
if (typeof message === 'string' && message.length > 0) return message;
if (typeof data === 'string' && data.length > 0) return data;
const cause = record['cause'];
if (cause != null) {
return rawRememberErrorDetails(cause, seen, depth + 1);
}
return undefined;
}
function rawRememberErrorDetails(
err: unknown,
seen: WeakSet<object>,
depth: number,
): string | undefined {
if (depth > MAX_REMEMBER_ERROR_CAUSE_DEPTH) return undefined;
if (typeof err === 'string' && err.length > 0) return err;
if (!err || typeof err !== 'object') return undefined;
if (seen.has(err)) return undefined;
seen.add(err);
return detailFromRecord(err as Record<string, unknown>, seen, depth);
}
function replaceControlChars(details: string): string {
return details
.replace(REMEMBER_ERROR_AUTH_SCHEME_INVISIBLE_RE, '$1 ')
.replace(REMEMBER_ERROR_FORMAT_RE, '')
.replace(REMEMBER_ERROR_SPACE_SEPARATOR_RE, ' ')
.replace(REMEMBER_ERROR_CONTROL_RE, ' ');
}
function replaceStackControlChars(stack: string): string {
return stack
.replace(REMEMBER_ERROR_AUTH_SCHEME_INVISIBLE_RE, '$1 ')
.replace(REMEMBER_ERROR_FORMAT_RE, '')
.replace(REMEMBER_ERROR_SPACE_SEPARATOR_RE, ' ')
.replace(REMEMBER_ERROR_CONTROL_RE, (char) =>
char === '\n' || char === '\r' || char === '\t' ? char : ' ',
);
}
function collapseCredentialSeparators(text: string): string {
return text
.replace(
REMEMBER_ERROR_URL_CREDENTIALS_WITH_SEPARATORS_RE,
(_match, scheme: string, userinfo: string) =>
`${scheme}${userinfo.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}@`,
)
.replace(
REMEMBER_ERROR_AUTH_TOKEN_WITH_SEPARATORS_RE,
(_match, scheme: string, token: string) =>
`${scheme} ${token.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}`,
)
.replace(REMEMBER_ERROR_BARE_TOKEN_WITH_SEPARATORS_RE, (token) =>
token.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, ''),
)
.replace(REMEMBER_ERROR_AWS_KEY_WITH_SEPARATORS_RE, (token) =>
token.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, ''),
)
.replace(
REMEMBER_ERROR_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE,
(_match, prefix: string, value: string) =>
`${prefix}${value.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}`,
)
.replace(
REMEMBER_ERROR_ENV_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE,
(_match, prefix: string, value: string) =>
`${prefix}${value.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}`,
);
}
function collapseCredentialSeparatorsByStackLine(stack: string): string {
return stack
.split(/(\r\n|\n|\r)/)
.map((part) =>
part === '\r\n' || part === '\n' || part === '\r'
? part
: collapseCredentialSeparators(part),
)
.join('');
}
function isHighSurrogate(codeUnit: number): boolean {
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
}
function isLowSurrogate(codeUnit: number): boolean {
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
}
function truncateBeforeDanglingSurrogate(
details: string,
cutPoint: number,
): number {
const beforeCut = details.charCodeAt(cutPoint - 1);
const atCut = details.charCodeAt(cutPoint);
if (isHighSurrogate(beforeCut) && isLowSurrogate(atCut)) {
return cutPoint - 1;
}
return cutPoint;
}
function capRememberErrorText(redacted: string): string {
if (redacted.length <= MAX_REMEMBER_ERROR_DETAILS_CHARS) return redacted;
const truncationSuffix = '... [truncated]';
const cutPoint = truncateBeforeDanglingSurrogate(
redacted,
MAX_REMEMBER_ERROR_DETAILS_CHARS - truncationSuffix.length,
);
return `${redacted.slice(0, cutPoint)}${truncationSuffix}`;
}
function redactAndCapRememberErrorText(normalized: string): string | undefined {
const redacted = redactLogCredentials(normalized).trim();
if (!redacted) return undefined;
return capRememberErrorText(redacted);
}
function sanitizeRememberErrorDetails(details: string): string | undefined {
// Keep credential normalization before redaction; auth schemes and token
// values may otherwise be split by invisible characters.
return redactAndCapRememberErrorText(
replaceControlChars(collapseCredentialSeparators(details)),
);
}
function sanitizeRememberErrorStack(stack: string): string | undefined {
return redactAndCapRememberErrorText(
replaceStackControlChars(collapseCredentialSeparatorsByStackLine(stack)),
);
}
export function extractRememberErrorDetails(err: unknown): string | undefined {
const raw = rawRememberErrorDetails(err, new WeakSet<object>(), 0);
if (!raw) return undefined;
return sanitizeRememberErrorDetails(raw);
}
export function extractRememberErrorStack(err: unknown): string | undefined {
if (!(err instanceof Error) || !err.stack) return undefined;
return sanitizeRememberErrorStack(err.stack);
}
export function shouldSuppressRememberErrorDetails(code: string): boolean {
return DETAIL_SUPPRESSED_REMEMBER_ERROR_CODES.has(code);
}
export function workspaceMemoryFailureDiagnostics(
err: unknown,
onExtractionError?: (
target: RememberErrorExtractionTarget,
err: unknown,
) => void,
): WorkspaceMemoryFailureDiagnostics {
let details: string | undefined;
let stack: string | undefined;
try {
details = extractRememberErrorDetails(err);
} catch (extractionErr) {
reportRememberErrorExtractionFailure(
onExtractionError,
'details',
extractionErr,
);
details = undefined;
}
try {
stack = extractRememberErrorStack(err);
} catch (extractionErr) {
reportRememberErrorExtractionFailure(
onExtractionError,
'stack',
extractionErr,
);
stack = undefined;
}
return {
...(details ? { details } : {}),
debugDetails: details ?? '<details unavailable>',
...(stack ? { stack } : {}),
};
}

View file

@ -23,6 +23,19 @@ import {
} from './workspace-remember.js';
import { MAX_REMEMBER_CONTENT_BYTES } from './workspace-memory-remember-constants.js';
const { mockDebugLogger } = vi.hoisted(() => ({
mockDebugLogger: {
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock('@qwen-code/qwen-code-core', () => ({
createDebugLogger: () => mockDebugLogger,
}));
type RecordedEvent = Omit<BridgeEvent, 'id' | 'v'>;
interface Deferred<T> {
@ -211,12 +224,13 @@ function buildApp(
tokenConfigured: true,
requireAuth: false,
},
lane = new WorkspaceRememberTaskLane(bridge),
) {
const app = express();
app.use(express.json({ limit: '1mb' }));
mountWorkspaceMemoryRememberRoutes(app, {
bridge,
lane: new WorkspaceRememberTaskLane(bridge),
lane,
mutate: createMutationGate(auth),
parseClientId: (req, res) => {
const raw = req.get('x-qwen-client-id');
@ -863,13 +877,78 @@ describe('workspace memory remember routes', () => {
expect(bridge.dreamCalls).toBe(0);
});
it('falls back to kind-specific codes when enqueue code extraction throws', async () => {
mockDebugLogger.warn.mockClear();
const bridge = buildBridgeStub({});
const lane = new WorkspaceRememberTaskLane(bridge);
const err = new Proxy(
{},
{
get() {
throw new Error('code getter failed');
},
},
);
vi.spyOn(lane, 'enqueue').mockImplementation(() => {
throw err;
});
vi.spyOn(lane, 'enqueueForget').mockImplementation(() => {
throw err;
});
vi.spyOn(lane, 'enqueueDream').mockImplementation(() => {
throw err;
});
const app = buildApp(bridge, undefined, lane);
await request(app)
.post('/workspace/memory/remember')
.send({ content: 'remember me' })
.expect(500)
.expect((res) => {
expect(res.body).toEqual({
error: 'Workspace memory remember failed.',
code: 'remember_failed',
});
});
await request(app)
.post('/workspace/memory/forget')
.send({ query: 'old preference' })
.expect(500)
.expect((res) => {
expect(res.body).toEqual({
error: 'Workspace memory forget failed.',
code: 'forget_failed',
});
});
await request(app)
.post('/workspace/memory/dream')
.send({})
.expect(500)
.expect((res) => {
expect(res.body).toEqual({
error: 'Workspace memory dream failed.',
code: 'dream_failed',
});
});
expect(mockDebugLogger.warn).toHaveBeenCalledTimes(3);
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
'Failed to extract workspace memory error code:',
{ extractionError: 'code getter failed' },
);
});
it('records bridge failures with stable public error codes', async () => {
const bridge = buildBridgeStub({
rememberImpl: vi
.fn()
.mockRejectedValueOnce({ code: 'remember_path_escape' })
.mockRejectedValueOnce(
Object.assign(new Error('agent wrote /tmp/outside'), {
code: 'remember_path_escape',
}),
)
.mockRejectedValueOnce({
data: { errorKind: 'managed_memory_unavailable' },
message: 'internal managed memory config path',
})
.mockRejectedValueOnce({
data: { errorKind: 'remember_timeout' },
@ -890,6 +969,7 @@ describe('workspace memory remember routes', () => {
expect(res.body.error).toEqual({
code: 'remember_path_escape',
message: 'Remember agent touched a path outside managed memory.',
details: 'agent wrote /tmp/outside',
});
});
@ -926,7 +1006,96 @@ describe('workspace memory remember routes', () => {
});
});
it('logs sanitized details for task-lane failures', async () => {
mockDebugLogger.error.mockClear();
const bridge = buildBridgeStub({
rememberImpl: vi
.fn()
.mockRejectedValue(
new Error('Authorization: Bearer secret-token-value'),
),
});
const app = buildApp(bridge);
const post = await request(app)
.post('/workspace/memory/remember')
.send({ content: 'secret' })
.expect(202);
await waitFor(() => bridge.rememberCalls.length === 1);
await request(app)
.get(`/workspace/memory/remember/${post.body.taskId}`)
.expect(200)
.expect((res) => {
expect(res.body.status).toBe('failed');
expect(res.body.error).toEqual({
code: 'remember_failed',
message: 'Workspace memory remember failed.',
details: 'Authorization: <redacted>',
});
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember task failed:',
expect.objectContaining({
taskId: post.body.taskId,
code: 'remember_failed',
details: 'Authorization: <redacted>',
stack: expect.stringContaining('Authorization: <redacted>'),
}),
);
expect(JSON.stringify(mockDebugLogger.error.mock.calls)).not.toContain(
'secret-token-value',
);
});
it('falls back when task-lane error code extraction throws', async () => {
mockDebugLogger.error.mockClear();
mockDebugLogger.warn.mockClear();
const err = new Proxy(
{},
{
get() {
throw new Error('code getter failed');
},
},
);
const bridge = buildBridgeStub({
rememberImpl: vi.fn().mockRejectedValue(err),
});
const app = buildApp(bridge);
const post = await request(app)
.post('/workspace/memory/remember')
.send({ content: 'proxy failure' })
.expect(202);
await waitFor(() => bridge.rememberCalls.length === 1);
await request(app)
.get(`/workspace/memory/remember/${post.body.taskId}`)
.expect(200)
.expect((res) => {
expect(res.body.status).toBe('failed');
expect(res.body.error).toEqual({
code: 'remember_failed',
message: 'Workspace memory remember failed.',
});
});
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
'Failed to extract workspace memory error code:',
{ extractionError: 'code getter failed' },
);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember task failed:',
expect.objectContaining({
taskId: post.body.taskId,
code: 'remember_failed',
details: '<details unavailable>',
}),
);
});
it('records forget and dream failures with kind-specific error codes', async () => {
mockDebugLogger.error.mockClear();
const bridge = buildBridgeStub({
forgetImpl: vi.fn().mockRejectedValue(new Error('forget failed')),
dreamImpl: vi.fn().mockRejectedValue(new Error('dream failed')),
@ -946,8 +1115,18 @@ describe('workspace memory remember routes', () => {
expect(res.body.error).toEqual({
code: 'forget_failed',
message: 'Workspace memory forget failed.',
details: 'forget failed',
});
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory forget task failed:',
expect.objectContaining({
taskId: forgetPost.body.taskId,
code: 'forget_failed',
details: 'forget failed',
stack: expect.stringContaining('forget failed'),
}),
);
const dreamPost = await request(app)
.post('/workspace/memory/dream')
@ -962,7 +1141,17 @@ describe('workspace memory remember routes', () => {
expect(res.body.error).toEqual({
code: 'dream_failed',
message: 'Workspace memory dream failed.',
details: 'dream failed',
});
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory dream task failed:',
expect.objectContaining({
taskId: dreamPost.body.taskId,
code: 'dream_failed',
details: 'dream failed',
stack: expect.stringContaining('dream failed'),
}),
);
});
});

View file

@ -15,7 +15,12 @@ import type {
BridgeWorkspaceMemoryRememberContextMode,
BridgeWorkspaceMemoryRememberResult,
} from './acp-session-bridge.js';
import { extractRememberErrorCode } from './workspace-remember-errors.js';
import {
createWorkspaceMemoryExtractionErrorLogger,
shouldSuppressRememberErrorDetails,
workspaceMemoryFailureCode,
workspaceMemoryFailureDiagnostics,
} from './workspace-remember-errors.js';
import { MAX_REMEMBER_CONTENT_BYTES } from './workspace-memory-remember-constants.js';
import {
formatWorkspaceMemoryDreamSummary,
@ -43,6 +48,7 @@ interface WorkspaceMemoryTaskBaseSnapshot {
error?: {
code: string;
message: string;
details?: string;
};
}
@ -179,6 +185,25 @@ export function publicErrorStatus(code: string): number {
return 500;
}
function createTaskError(
code: string,
kind: WorkspaceMemoryTaskKind,
details?: string,
): WorkspaceMemoryTaskBaseSnapshot['error'] {
const error: WorkspaceMemoryTaskBaseSnapshot['error'] = {
code,
message: publicErrorMessage(code, kind),
};
if (shouldSuppressRememberErrorDetails(code)) return error;
return {
...error,
...(details ? { details } : {}),
};
}
const logWorkspaceMemoryExtractionError =
createWorkspaceMemoryExtractionErrorLogger(debugLogger);
export class WorkspaceRememberTaskLane {
private static readonly MAX_TASKS = 1000;
private static readonly TERMINAL_TASK_TTL_MS = 5 * 60_000;
@ -331,17 +356,23 @@ export class WorkspaceRememberTaskLane {
};
task.updatedAt = nowIso();
} catch (err) {
const code = extractRememberErrorCode(err);
debugLogger.error(
'Workspace memory remember task failed:',
{ taskId: task.taskId },
const code = workspaceMemoryFailureCode(
err,
'remember_failed',
logWorkspaceMemoryExtractionError,
);
task.status = 'failed';
task.error = {
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
logWorkspaceMemoryExtractionError,
);
debugLogger.error('Workspace memory remember task failed:', {
taskId: task.taskId,
code,
message: publicErrorMessage(code, task.kind),
};
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
task.status = 'failed';
task.error = createTaskError(code, task.kind, diagnostics.details);
task.updatedAt = nowIso();
}
try {
@ -397,17 +428,23 @@ export class WorkspaceRememberTaskLane {
};
task.updatedAt = nowIso();
} catch (err) {
const code = extractRememberErrorCode(err, 'forget_failed');
debugLogger.error(
'Workspace memory forget task failed:',
{ taskId: task.taskId },
const code = workspaceMemoryFailureCode(
err,
'forget_failed',
logWorkspaceMemoryExtractionError,
);
task.status = 'failed';
task.error = {
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
logWorkspaceMemoryExtractionError,
);
debugLogger.error('Workspace memory forget task failed:', {
taskId: task.taskId,
code,
message: publicErrorMessage(code, task.kind),
};
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
task.status = 'failed';
task.error = createTaskError(code, task.kind, diagnostics.details);
task.updatedAt = nowIso();
}
try {
@ -459,17 +496,23 @@ export class WorkspaceRememberTaskLane {
};
task.updatedAt = nowIso();
} catch (err) {
const code = extractRememberErrorCode(err, 'dream_failed');
debugLogger.error(
'Workspace memory dream task failed:',
{ taskId: task.taskId },
const code = workspaceMemoryFailureCode(
err,
'dream_failed',
logWorkspaceMemoryExtractionError,
);
task.status = 'failed';
task.error = {
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
logWorkspaceMemoryExtractionError,
);
debugLogger.error('Workspace memory dream task failed:', {
taskId: task.taskId,
code,
message: publicErrorMessage(code, task.kind),
};
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
task.status = 'failed';
task.error = createTaskError(code, task.kind, diagnostics.details);
task.updatedAt = nowIso();
}
try {
@ -610,7 +653,11 @@ export function mountWorkspaceMemoryRememberRoutes(
...(originatorClientId ? { originatorClientId } : {}),
});
} catch (err) {
const code = extractRememberErrorCode(err);
const code = workspaceMemoryFailureCode(
err,
'remember_failed',
logWorkspaceMemoryExtractionError,
);
res.status(publicErrorStatus(code)).json({
error: publicErrorMessage(code, 'remember'),
code,
@ -678,7 +725,11 @@ export function mountWorkspaceMemoryRememberRoutes(
});
res.status(202).json(task);
} catch (err) {
const code = extractRememberErrorCode(err, 'forget_failed');
const code = workspaceMemoryFailureCode(
err,
'forget_failed',
logWorkspaceMemoryExtractionError,
);
res.status(publicErrorStatus(code)).json({
error: publicErrorMessage(code, 'forget'),
code,
@ -723,7 +774,11 @@ export function mountWorkspaceMemoryRememberRoutes(
});
res.status(202).json(task);
} catch (err) {
const code = extractRememberErrorCode(err, 'dream_failed');
const code = workspaceMemoryFailureCode(
err,
'dream_failed',
logWorkspaceMemoryExtractionError,
);
res.status(publicErrorStatus(code)).json({
error: publicErrorMessage(code, 'dream'),
code,

View file

@ -21,4 +21,8 @@ describe('memoryCommand', () => {
dialog: 'memory',
});
});
it('does not advertise unsupported subcommands', () => {
expect(memoryCommand.argumentHint).toBeUndefined();
});
});

View file

@ -13,7 +13,6 @@ export const memoryCommand: SlashCommand = {
get description() {
return t('Open the memory manager.');
},
argumentHint: 'show|add|refresh',
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
action: async () => ({

View file

@ -7,6 +7,16 @@
import { act } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render } from 'ink-testing-library';
import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
AUTO_MEMORY_INDEX_FILENAME,
clearAutoMemoryRootCache,
getAutoMemoryRoot,
} from '@qwen-code/qwen-code-core';
import { MemoryDialog } from './MemoryDialog.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
@ -29,16 +39,88 @@ vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const fsMock = {
access: vi.fn(),
mkdir: vi.fn(),
readFile: vi.fn(() => Promise.reject(new Error('not found'))),
writeFile: vi.fn(),
};
return {
...actual,
...fsMock,
default: { ...actual, ...fsMock },
};
});
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
const { EventEmitter } = await import('node:events');
const spawnMock = vi.fn(() => {
const child = new EventEmitter() as EventEmitter & {
unref: ReturnType<typeof vi.fn>;
};
child.unref = vi.fn();
queueMicrotask(() => child.emit('spawn'));
return child;
});
return {
...actual,
spawn: spawnMock,
default: { ...actual, spawn: spawnMock },
};
});
const mockedUseConfig = vi.mocked(useConfig);
const mockedUseSettings = vi.mocked(useSettings);
const mockedUseLaunchEditor = vi.mocked(useLaunchEditor);
const mockedUseKeypress = vi.mocked(useKeypress);
const mockedSpawn = vi.mocked(spawn);
const mockedFs = vi.mocked(fs);
const originalPlatform = process.platform;
type MockSpawnChild = EventEmitter & { unref: ReturnType<typeof vi.fn> };
const folderOpenCommandByPlatform: Partial<Record<NodeJS.Platform, string>> = {
darwin: 'open',
win32: 'explorer',
};
function createMockSpawnChild(
event: 'spawn' | 'error' = 'spawn',
error?: Error,
): MockSpawnChild {
const child = new EventEmitter() as MockSpawnChild;
child.unref = vi.fn();
queueMicrotask(() => {
child.emit(event, error);
});
return child;
}
function stubPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true,
});
}
function expectedFolderOpenCommand(platform = process.platform): string {
return folderOpenCommandByPlatform[platform] ?? 'xdg-open';
}
describe('MemoryDialog', () => {
let setAutoSkillEnabled: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('DISPLAY', ':99');
vi.stubEnv('QWEN_HOME', path.join(os.homedir(), '.qwen'));
vi.stubEnv(
'QWEN_CODE_MEMORY_BASE_DIR',
path.join(os.homedir(), '.qwen-memory-test'),
);
clearAutoMemoryRootCache();
setAutoSkillEnabled = vi.fn();
mockedUseConfig.mockReturnValue({
@ -51,6 +133,7 @@ describe('MemoryDialog', () => {
getManagedAutoMemoryEnabled: vi.fn(() => false),
getManagedAutoDreamEnabled: vi.fn(() => false),
getAutoSkillEnabled: vi.fn(() => false),
isManagedMemoryAvailable: vi.fn(() => true),
setAutoSkillEnabled,
} as never);
@ -69,21 +152,185 @@ describe('MemoryDialog', () => {
});
afterEach(() => {
stubPlatform(originalPlatform);
clearAutoMemoryRootCache();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('moves selection with down arrow key events', () => {
it('renders managed memory folders without advertising QWEN.md', () => {
const { lastFrame } = render(<MemoryDialog onClose={vi.fn()} />);
expect(lastFrame()).toContain(' 1. User memory');
expect(lastFrame()).toContain('2. Project memory');
expect(lastFrame()).toContain('/memories');
expect(lastFrame()).toContain('/memory');
expect(lastFrame()).not.toContain('QWEN.md');
expect(lastFrame()).not.toContain('Open auto-memory folder');
});
it('opens managed memory folders instead of launching an editor', async () => {
const onClose = vi.fn();
const launchEditor = vi.fn();
mockedUseLaunchEditor.mockReturnValue(launchEditor);
render(<MemoryDialog onClose={onClose} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: 'return' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(mockedSpawn).toHaveBeenCalledWith(
expectedFolderOpenCommand(),
[path.join(os.homedir(), '.qwen-memory-test', 'memories')],
expect.objectContaining({ detached: true, stdio: 'ignore' }),
);
expect(mockedFs.mkdir).toHaveBeenCalledWith(
path.join(os.homedir(), '.qwen-memory-test', 'memories'),
{ recursive: true },
);
expect(
(mockedSpawn.mock.results[0]?.value as MockSpawnChild).unref,
).toHaveBeenCalled();
expect(launchEditor).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it.each(['darwin', 'win32'] as const)(
'opens managed memory folders on %s without display variables',
async (platform) => {
stubPlatform(platform);
vi.stubEnv('DISPLAY', '');
vi.stubEnv('WAYLAND_DISPLAY', '');
vi.stubEnv('MIR_SOCKET', '');
const onClose = vi.fn();
const launchEditor = vi.fn();
mockedUseLaunchEditor.mockReturnValue(launchEditor);
render(<MemoryDialog onClose={onClose} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: 'return' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(mockedSpawn).toHaveBeenCalledWith(
expectedFolderOpenCommand(platform),
[path.join(os.homedir(), '.qwen-memory-test', 'memories')],
expect.objectContaining({ detached: true, stdio: 'ignore' }),
);
expect(launchEditor).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
},
);
it('opens the project managed memory folder after moving selection down', async () => {
const onClose = vi.fn();
const launchEditor = vi.fn();
mockedUseLaunchEditor.mockReturnValue(launchEditor);
const { lastFrame } = render(<MemoryDialog onClose={onClose} />);
expect(lastFrame()).toContain(' 1. User memory');
act(() => {
keypressHandler({ name: 'down' } as never);
mockedUseKeypress.mock.calls.at(-1)![0]({ name: 'down' } as never);
});
expect(lastFrame()).toContain(' 2. Project memory');
await act(async () => {
mockedUseKeypress.mock.calls.at(-1)![0]({ name: 'return' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(mockedSpawn).toHaveBeenCalledWith(
expectedFolderOpenCommand(),
[getAutoMemoryRoot('/tmp/project')],
expect.objectContaining({ detached: true, stdio: 'ignore' }),
);
expect(launchEditor).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it('opens the second visible item with its numeric shortcut', async () => {
const onClose = vi.fn();
render(<MemoryDialog onClose={onClose} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: '2', sequence: '2' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(mockedSpawn).toHaveBeenCalledWith(
expectedFolderOpenCommand(),
[getAutoMemoryRoot('/tmp/project')],
expect.objectContaining({ detached: true, stdio: 'ignore' }),
);
expect(onClose).toHaveBeenCalled();
});
it('opens the managed memory index in an editor on headless Linux', async () => {
stubPlatform('linux');
vi.stubEnv('DISPLAY', '');
vi.stubEnv('WAYLAND_DISPLAY', '');
vi.stubEnv('MIR_SOCKET', '');
const onClose = vi.fn();
const launchEditor = vi.fn();
mockedUseLaunchEditor.mockReturnValue(launchEditor);
render(<MemoryDialog onClose={onClose} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: 'return' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedFs.access).toHaveBeenCalledWith(
path.join(
os.homedir(),
'.qwen-memory-test',
'memories',
AUTO_MEMORY_INDEX_FILENAME,
),
);
expect(launchEditor).toHaveBeenCalledWith(
path.join(
os.homedir(),
'.qwen-memory-test',
'memories',
AUTO_MEMORY_INDEX_FILENAME,
),
);
expect(onClose).toHaveBeenCalled();
});
it('shows an error when opening a managed memory folder fails', async () => {
const onClose = vi.fn();
mockedSpawn.mockImplementationOnce(
() => createMockSpawnChild('error', new Error('ENOENT')) as never,
);
const { lastFrame } = render(<MemoryDialog onClose={onClose} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: 'return' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(lastFrame()).toContain('ENOENT');
expect(onClose).not.toHaveBeenCalled();
});
it('moves selection with Ctrl+N/P readline aliases', () => {
@ -272,6 +519,67 @@ describe('MemoryDialog', () => {
expect(lastFrame()).toContain(' Confirm auto-skills before saving: off');
});
it('keeps QWEN.md editor entries when managed memory is unavailable', async () => {
const launchEditor = vi.fn();
mockedUseLaunchEditor.mockReturnValue(launchEditor);
mockedUseConfig.mockReturnValue({
getWorkingDir: vi.fn(() => '/tmp/project'),
getProjectRoot: vi.fn(() => '/tmp/project'),
getBareMode: vi.fn(() => true),
isSafeMode: vi.fn(() => false),
getManagedAutoMemoryEnabled: vi.fn(() => false),
getManagedAutoDreamEnabled: vi.fn(() => false),
getAutoSkillEnabled: vi.fn(() => false),
isManagedMemoryAvailable: vi.fn(() => false),
} as never);
const { lastFrame } = render(<MemoryDialog onClose={vi.fn()} />);
expect(lastFrame()).toContain(' 1. User memory');
expect(lastFrame()).toContain('Saved in ~/.qwen/QWEN.md');
expect(lastFrame()).toContain('2. Project memory');
expect(lastFrame()).toContain('Saved in QWEN.md');
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: 'return' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(launchEditor).toHaveBeenCalledWith(
expect.stringMatching(/\/\.qwen\/QWEN\.md$/),
);
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('opens the project QWEN.md editor entry when managed memory is unavailable', async () => {
const launchEditor = vi.fn();
mockedUseLaunchEditor.mockReturnValue(launchEditor);
mockedUseConfig.mockReturnValue({
getWorkingDir: vi.fn(() => '/tmp/project'),
getProjectRoot: vi.fn(() => '/tmp/project'),
getBareMode: vi.fn(() => true),
isSafeMode: vi.fn(() => false),
getManagedAutoMemoryEnabled: vi.fn(() => false),
getManagedAutoDreamEnabled: vi.fn(() => false),
getAutoSkillEnabled: vi.fn(() => false),
isManagedMemoryAvailable: vi.fn(() => false),
} as never);
render(<MemoryDialog onClose={vi.fn()} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: '2', sequence: '2' } as never);
await Promise.resolve();
await Promise.resolve();
});
expect(launchEditor).toHaveBeenCalledWith('/tmp/project/QWEN.md');
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('reflects the persisted value when the dialog is reopened (remounted)', () => {
// Emulate LoadedSettings: setValue writes through to the merged view,
// exactly like the real saveSettings + recomputeMerged path.

View file

@ -9,12 +9,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { spawn } from 'node:child_process';
import {
getAllGeminiMdFilenames,
Storage,
getAutoMemoryRoot,
getAutoMemoryProjectStateDir,
getUserAutoMemoryRoot,
AUTO_MEMORY_INDEX_FILENAME,
} from '@qwen-code/qwen-code-core';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
@ -26,7 +28,9 @@ import { theme } from '../semantic-colors.js';
import { formatRelativeTime } from '../utils/formatters.js';
import { t } from '../../i18n/index.js';
type MemoryDialogTarget = 'project' | 'global' | 'managed';
type MemoryDialogTarget = 'project' | 'global';
type MemoryDialogAction = 'file' | 'folder';
const DISPLAY_ENV_VARS = ['DISPLAY', 'WAYLAND_DISPLAY', 'MIR_SOCKET'] as const;
interface MemoryDialogProps {
onClose: () => void;
@ -35,6 +39,7 @@ interface MemoryDialogProps {
interface DialogItem {
label: string;
value: MemoryDialogTarget;
action: MemoryDialogAction;
description?: string;
}
@ -55,7 +60,7 @@ async function resolvePreferredMemoryFile(
return path.join(dir, fallbackFilename);
}
function openFolderPath(folderPath: string): void {
async function openFolderPath(folderPath: string): Promise<void> {
let command = 'xdg-open';
switch (process.platform) {
@ -70,21 +75,27 @@ function openFolderPath(folderPath: string): void {
break;
}
const needsShell =
process.platform === 'win32' &&
(command.endsWith('.cmd') || command.endsWith('.bat'));
const result = spawnSync(command, [folderPath], {
stdio: 'inherit',
shell: needsShell,
const child = spawn(command, [folderPath], {
detached: true,
stdio: 'ignore',
});
if (result.error) {
throw result.error;
}
if (typeof result.status === 'number' && result.status !== 0) {
throw new Error(`Folder opener exited with status ${result.status}`);
child.unref();
await new Promise<void>((resolve, reject) => {
child.once('error', reject);
// Exit codes are intentionally not observed: the folder opener is
// fire-and-forget, and waiting for exit can block until the file manager
// closes.
child.once('spawn', () => resolve());
});
}
function shouldOpenFolderPath(): boolean {
if (process.platform === 'darwin' || process.platform === 'win32') {
return true;
}
return DISPLAY_ENV_VARS.some((key) => Boolean(process.env[key]));
}
async function ensureFileExists(filePath: string): Promise<void> {
@ -155,17 +166,40 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
() => getAutoMemoryRoot(config.getProjectRoot()),
[config],
);
const managedUserMemoryPath = useMemo(() => getUserAutoMemoryRoot(), []);
const memoryStatePath = useMemo(
() => getAutoMemoryProjectStateDir(config.getProjectRoot()),
[config],
);
const items = useMemo<DialogItem[]>(
() => [
const items = useMemo<DialogItem[]>(() => {
if (config.isManagedMemoryAvailable()) {
return [
{
label: t('User memory'),
value: 'global',
action: 'folder',
description: t('Saved in {{path}}', {
path: formatDisplayPath(managedUserMemoryPath),
}),
},
{
label: t('Project memory'),
value: 'project',
action: 'folder',
description: t('Saved in {{path}}', {
path: formatDisplayPath(managedMemoryPath),
}),
},
];
}
return [
{
label: t('User memory'),
value: 'global',
action: 'file',
description: t('Saved in {{path}}', {
path: formatDisplayPath(globalMemoryPath),
}),
@ -173,19 +207,21 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
{
label: t('Project memory'),
value: 'project',
action: 'file',
description: t('Saved in {{path}}', {
path:
path.relative(config.getWorkingDir(), projectMemoryPath) ||
path.basename(projectMemoryPath),
}),
},
{
label: t('Open auto-memory folder'),
value: 'managed',
},
],
[config, globalMemoryPath, projectMemoryPath],
);
];
}, [
config,
globalMemoryPath,
managedMemoryPath,
managedUserMemoryPath,
projectMemoryPath,
]);
// Load lastDreamAt from meta.json
useEffect(() => {
@ -219,8 +255,21 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
}, [lastDreamAt]);
const resolveTargetPath = useCallback(
async (target: MemoryDialogTarget): Promise<string> => {
switch (target) {
async (item: DialogItem): Promise<string> => {
if (item.action === 'folder') {
switch (item.value) {
case 'global':
return managedUserMemoryPath;
case 'project':
return managedMemoryPath;
default: {
const _exhaustive: never = item.value;
return _exhaustive;
}
}
}
switch (item.value) {
case 'project':
return resolvePreferredMemoryFile(
config.getWorkingDir(),
@ -231,23 +280,29 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
Storage.getGlobalQwenDir(),
getAllGeminiMdFilenames()[0] ?? 'QWEN.md',
);
case 'managed':
return managedMemoryPath;
default:
return managedMemoryPath;
default: {
const _exhaustive: never = item.value;
return _exhaustive;
}
}
},
[config, managedMemoryPath],
[config, managedMemoryPath, managedUserMemoryPath],
);
const handleSelect = useCallback(
async (target: MemoryDialogTarget) => {
async (item: DialogItem) => {
try {
setError(null);
const targetPath = await resolveTargetPath(target);
if (target === 'managed') {
const targetPath = await resolveTargetPath(item);
if (item.action === 'folder') {
await fs.mkdir(targetPath, { recursive: true });
openFolderPath(targetPath);
if (shouldOpenFolderPath()) {
await openFolderPath(targetPath);
} else {
const indexPath = path.join(targetPath, AUTO_MEMORY_INDEX_FILENAME);
await ensureFileExists(indexPath);
await launchEditor(indexPath);
}
} else {
await ensureFileExists(targetPath);
await launchEditor(targetPath);
@ -395,15 +450,18 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
}
if (key.name === 'return') {
void handleSelect(items[highlightedIndex]?.value ?? 'project');
const selectedItem = items[highlightedIndex] ?? items[0];
if (selectedItem) {
void handleSelect(selectedItem);
}
return;
}
if (key.sequence && /^[1-3]$/.test(key.sequence)) {
if (key.sequence && /^[1-9]$/.test(key.sequence)) {
const nextIndex = Number(key.sequence) - 1;
if (items[nextIndex]) {
setHighlightedIndex(nextIndex);
void handleSelect(items[nextIndex].value);
void handleSelect(items[nextIndex]);
}
}
},
@ -483,7 +541,7 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
const isSelected =
focusedSection === 'list' && index === highlightedIndex;
return (
<Box key={item.value} flexDirection="row">
<Box key={`${item.value}-${item.action}`} flexDirection="row">
<Text color={isSelected ? theme.status.success : undefined}>
{isSelected ? ' ' : ' '}
{index + 1}. {item.label}

View file

@ -601,7 +601,6 @@ describe('ToolSearchTool', () => {
const setToolsSpy = vi.fn().mockResolvedValue(undefined);
vi.spyOn(config, 'getGeminiClient').mockReturnValue({
setTools: setToolsSpy,
refreshStartupContextReminder: vi.fn().mockResolvedValue(undefined),
} as never);
const tool = new ToolSearchTool(config);
@ -646,7 +645,6 @@ describe('ToolSearchTool', () => {
const setToolsSpy = vi.fn().mockResolvedValue(undefined);
vi.spyOn(config, 'getGeminiClient').mockReturnValue({
setTools: setToolsSpy,
refreshStartupContextReminder: vi.fn().mockResolvedValue(undefined),
} as never);
const tool = new ToolSearchTool(config);

View file

@ -1086,6 +1086,7 @@ export interface DaemonWorkspaceMemoryRememberTask {
error?: {
code: string;
message: string;
details?: string;
};
}
@ -1116,6 +1117,7 @@ export interface DaemonWorkspaceMemoryForgetTask {
error?: {
code: string;
message: string;
details?: string;
};
}
@ -1138,6 +1140,7 @@ export interface DaemonWorkspaceMemoryDreamTask {
error?: {
code: string;
message: string;
details?: string;
};
}

View file

@ -33,6 +33,12 @@ const routeStep =
workflow.match(
/- name: 'Decide phases'[\s\S]*?(?=\n[ ]{2}# ==========)/,
)?.[0] ?? '';
const reviewScanJob =
workflow.match(/\n {2}review-scan:[\s\S]*?(?=\n[ ]{2}# ==========)/)?.[0] ??
'';
const issueAutofixJob =
workflow.match(/\n {2}issue-autofix:[\s\S]*?(?=\n[ ]{2}# ==========)/)?.[0] ??
'';
const publishPrStep =
workflow.match(
/- name: 'Publish PR'[\s\S]*?(?=\n[ ]{6}- name: 'Withdraw claim on failure')/,
@ -192,6 +198,76 @@ describe('qwen-autofix workflow', () => {
expect(workflow).toContain('.[0:10] | map(. + {autofixTier: 1})');
});
it('runs scheduled autofix as a 10-minute single-target worker', () => {
expect(workflow).toContain("cron: '*/10 * * * *'");
expect(workflow).not.toContain("cron: '0 0,12 * * *'");
expect(workflow).not.toContain("cron: '0 4,8,16,20 * * *'");
expect(workflow).toContain(
"pull_request_review:\n types:\n - 'submitted'",
);
expect(workflow).toContain(
'AUTOFIX_BOT: "${{ vars.AUTOFIX_BOT_LOGIN || \'qwen-code-dev-bot\' }}"',
);
expect(workflow).toContain("MAX_ROUNDS: '5'");
expect(workflow).toContain("MAX_OPEN_AUTOFIX_PRS: '5'");
expect(reviewScanJob).toContain('isCrossRepository');
expect(reviewScanJob).toContain('not an open in-repo main-targeting PR');
expect(reviewScanJob).toContain('.isCrossRepository != true');
expect(reviewScanJob).toContain('break # one PR per scheduled scan');
expect(reviewScanJob).toContain('statusCheckRollup');
expect(reviewScanJob).toContain('HAS_PENDING_CHECKS');
expect(reviewScanJob).toContain('N_FAILED_CHECKS');
expect(reviewScanJob).toContain('.status // .state // ""');
expect(reviewScanJob).toContain('.conclusion // .state // ""');
expect(reviewScanJob).toContain('.workflowName // ""');
expect(reviewScanJob).toContain('startswith("review-address")');
expect(
reviewScanJob.match(/startswith\("review-address"\)/g) ?? [],
).toHaveLength(2);
expect(reviewScanJob).toContain('"${N_FAILED_CHECKS}" -eq 0');
expect(reviewScanJob).toContain('${N_FAILED_CHECKS} failed check(s) new');
expect(reviewScanJob).toContain('.completedAt // .updatedAt // ""');
expect(reviewScanJob.indexOf('EFF_WM="${PUSH_WM}"')).toBeLessThan(
reviewScanJob.indexOf('N_FAILED_CHECKS='),
);
expect(reviewScanJob).toContain('echo "targets=[]" >> "${GITHUB_OUTPUT}"');
expect(reviewScanJob).toContain(
'PR has pending checks; skipping until the current verification finishes',
);
});
it('falls back to existing issue backlog only when review has no target', () => {
expect(issueAutofixJob).toContain("needs: ['route', 'review-scan']");
expect(issueAutofixJob).toContain('always()');
expect(issueAutofixJob).toContain("needs.review-scan.result == 'success'");
expect(issueAutofixJob).toContain(
"github.event_name != 'schedule' || (needs.review-scan.result == 'success' && needs.review-scan.outputs.has_targets != 'true')",
);
expect(findCandidateIssuesStep).toContain('OPEN_AUTOFIX_PR_COUNT');
expect(findCandidateIssuesStep).toContain('MAX_OPEN_AUTOFIX_PRS');
expect(findCandidateIssuesStep).toContain('isCrossRepository');
expect(findCandidateIssuesStep).toContain(
'open autofix PR(s) already exist; WIP limit is ${MAX_OPEN_AUTOFIX_PRS}',
);
});
it('routes submitted review events only for trusted in-repo bot PRs', () => {
expect(routeStep).toContain('PR_AUTHOR');
expect(routeStep).toContain('PR_NUMBER_EVENT');
expect(routeStep).toContain(
'if [[ "${EVENT_NAME}" == \'pull_request_review\' ]]; then',
);
expect(routeStep).toContain('"${PR_AUTHOR}" != "${AUTOFIX_BOT}"');
expect(routeStep).toContain('"${PR_HEAD_REPO}" != "${REPO}"');
expect(routeStep).toContain('"${PR_BASE_REF}" != "main"');
expect(routeStep).toContain(
'ROUTE_PR="$(sanitize_number "${PR_NUMBER_EVENT}")',
);
expect(routeStep).toContain(
"review event ignored: PR author '${PR_AUTHOR}' is not ${AUTOFIX_BOT}",
);
});
it('keeps label-triggered issue routing guarded and diagnosable', () => {
expect(workflow).toContain("issues:\n types:\n - 'labeled'");
expect(workflow).toContain(
@ -336,7 +412,28 @@ describe('qwen-autofix workflow', () => {
expect(workflow).toContain(
'repos/${REPO}/issues/${PR}/comments" --paginate > "${WORKDIR}/ic.json"',
);
expect(prepareBranchAndFeedbackStep).toContain(
'2> /dev/null || echo \'[]\' > "${WORKDIR}/checks.json"',
);
expect(workflow).toContain('## Issue-level comments');
expect(workflow).toContain('## Failed checks');
expect(workflow).toContain('checks.json');
expect(workflow).toContain(
'.[3] | map(select((.conclusion // .state // "")',
);
expect(
prepareBranchAndFeedbackStep.match(/startswith\("review-address"\)/g) ??
[],
).toHaveLength(2);
expect(prepareBranchAndFeedbackStep).toContain(
'gsub("[^A-Za-z0-9 _./()-]"; "") | .[0:80]',
);
expect(prepareBranchAndFeedbackStep).not.toContain(
'.detailsUrl // .targetUrl',
);
expect(prepareBranchAndFeedbackStep).not.toContain(
'.name // .context // "?"',
);
// NEWEST watermark must consider issue-level comment timestamps.
expect(workflow).toContain('.[2] | map(select((.created_at // "")');
// Permission API failures in the review-trigger path must be logged.
@ -399,7 +496,7 @@ describe('qwen-autofix workflow', () => {
'($p + (.number | tostring)) as $branch',
);
expect(findCandidateIssuesStep).toContain(
'first($prs[] | select((.headRefName // "") == $branch)',
'first($prs[] | select((.isCrossRepository != true) and ((.headRefName // "") == $branch))',
);
expect(findCandidateIssuesStep).toContain('existingAutofixPr');
expect(findCandidateIssuesStep).toContain('annotated-candidates.json');