mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
Merge branch 'main' into autofix/issue-6554
This commit is contained in:
commit
db5dbb5921
19 changed files with 2510 additions and 178 deletions
137
.github/workflows/qwen-autofix.yml
vendored
137
.github/workflows/qwen-autofix.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -21,4 +21,8 @@ describe('memoryCommand', () => {
|
|||
dialog: 'memory',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not advertise unsupported subcommands', () => {
|
||||
expect(memoryCommand.argumentHint).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => ({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('› 2. Project memory');
|
||||
|
||||
await act(async () => {
|
||||
mockedUseKeypress.mock.calls.at(-1);
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue