Compare commits

..

55 Commits

Author SHA1 Message Date
Yzx
19707cce1d fix(cron): avoid gateway restart on setup timeout (#96396)
* fix(cron): avoid gateway restart on setup timeout

* fix(cron): avoid gateway restart on setup timeout

---------

Co-authored-by: Radek Sienkiewicz <mail@velvetshark.com>
2026-06-25 18:11:33 +02:00
Ayaan Zaidi
a3b4e8102f test(telegram): fold draft preview surrogate clamp coverage 2026-06-25 09:10:07 -07:00
杨浩宇0668001029
4bd68aef65 fix(telegram): keep draft preview chunks surrogate-safe 2026-06-25 09:10:07 -07:00
Ayaan Zaidi
8bc069f76f fix(outbound): preserve narrowed delivery target type 2026-06-25 08:37:00 -07:00
Ayaan Zaidi
1adb119ba0 refactor(outbound): distill reserved target delivery cleanup 2026-06-25 08:37:00 -07:00
zhang-guiping
57c07d7f3b refactor: centralize reserved target error checks
Keep reserved-target detection behavior unchanged while routing callers through a shared helper so future changes stay localized.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 08:37:00 -07:00
张贵萍0668001030
3c8ff0d1c3 fix(outbound): require exact reserved directory matches 2026-06-25 08:37:00 -07:00
张贵萍0668001030
3a03d1e70b fix(cron): preserve reserved directory targets 2026-06-25 08:37:00 -07:00
张贵萍0668001030
9047b1cfa1 fix(outbound): preserve reserved directory target on route miss 2026-06-25 08:37:00 -07:00
张贵萍0668001030
ba004b3547 test(outbound): align Telegram resolver fixtures with chat capabilities 2026-06-25 08:37:00 -07:00
张贵萍0668001030
3092b4fd0d fix(outbound): fail closed heartbeat reserved Telegram misses 2026-06-25 08:37:00 -07:00
张贵萍0668001030
116758e69a fix(outbound): satisfy target resolver lint 2026-06-25 08:37:00 -07:00
张贵萍0668001030
cd3793185b fix(outbound): preserve reserved Telegram directory targets 2026-06-25 08:37:00 -07:00
zhang-guiping
5fccf06b5f fix(outbound): defer reserved-literal errors to async session-route resolver
In resolveAgentDeliveryPlanWithSessionRoute, reserved-literal errors from the
sync outbound target check are no longer treated as fatal. Instead, the path
proceeds to resolveOutboundSessionRoute which calls resolveMessagingTarget,
already fixed to do directory-first lookup before rejecting reserved literals.

This preserves configured Telegram directory entries named like reserved words
(current, self, this, me) through the explicit agent/gateway delivery path.

Update docs to reflect directory-first ordering.
2026-06-25 08:37:00 -07:00
zhang-guiping
bbf494955d fix(outbound): preserve configured directory entries before reserved-literal rejection in resolveMessagingTarget
Move the reserved-literal check from before directory lookup to after directory
miss, so configured Telegram groups/channels whose directory key is a reserved
word (current, self, this, me) still resolve through the directory before
failing closed. The reserved check now runs only after the directory returns no
match and before plugin fallback resolution.

Update the regression test to verify directory-first ordering: a configured
directory entry named current resolves successfully, and a directory miss with
a reserved literal fails with the descriptive error.
2026-06-25 08:37:00 -07:00
zhang-guiping
f12ade0082 fix(outbound): skip sync reserved-literal rejection for heartbeat mode
The sync reserved-literal check in resolveOutboundTargetWithPlugin was
suppressing heartbeat routes to directory entries whose names match
reserved literals (e.g., a Telegram group named "current"). Skip the
check for heartbeat mode so the async resolveChannelTarget →
resolveMessagingTarget path can do directory-first lookup before
deciding.
2026-06-25 08:37:00 -07:00
张贵萍
56baf9d079 fix(outbound): reject reserved Telegram targets 2026-06-25 08:37:00 -07:00
Ayaan Zaidi
dc12b998da fix(media): scope UUID filename restore to media store 2026-06-25 08:35:41 -07:00
Narahari Raghava
cf512f639b fix(media): strip internal UUID suffix from outbound media filenames
Closes #96538
2026-06-25 08:35:41 -07:00
Ayaan Zaidi
29670c13f6 refactor(status): reuse runtime authority decision 2026-06-25 08:31:54 -07:00
zhang-guiping
bead84f0ee fix(status): route usage to session-selected model 2026-06-25 08:31:54 -07:00
Vincent Koc
497d53d821 fix(sdk): tighten wildcard surface budget 2026-06-25 23:30:17 +08:00
yetval
446d98d601 fix(trajectory): export legacy v1 sessions without entry timestamps
readSessionBranch filtered out every entry lacking a string or number
timestamp. Sessions written before entry timestamps existed (version 1)
have ids and parentIds synthesized by the legacy migration but no entry
timestamp, so all entries were dropped and the exported bundle reported
transcriptEventCount 0. The transcript event builder already defaults a
missing timestamp via normalizeTimestamp, so the filter clause was both
wrong and redundant. Drop it; entry identity plus the canonical-entry
check is what the branch walk needs.
2026-06-25 08:29:25 -07:00
Gio Della-Libera
82a6a57330 Doctor: expose session artifact findings (#95976)
* feat(doctor): expose session artifact findings

* fix(doctor): make session artifact findings advisory
2026-06-25 08:27:25 -07:00
Ayaan Zaidi
01ce03c5b1 fix(gateway): preserve webchat send guard 2026-06-25 08:26:12 -07:00
黑承亮0668000844
5881dc8ac3 fix(gateway): use normalizeMessageChannel for send validation to support plugin channels
Fixes #92094
2026-06-25 08:26:12 -07:00
openclaw-clownfish[bot]
31a0f97dd9 fix(clownfish): repair validation for repair-94016-live-pr-inventory-20260617t082059-003-20260617a (2) 2026-06-25 08:25:09 -07:00
openclaw-clownfish[bot]
ace22feb3f fix(clownfish): address review for repair-94016-live-pr-inventory-20260617t082059-003-20260617a (1) 2026-06-25 08:25:09 -07:00
openclaw-clownfish[bot]
ecd29fe572 fix(gateway): resume channel after pending task recovery 2026-06-25 08:25:09 -07:00
openclaw-clownfish[bot]
6039da3ed6 fix(gateway): resume channel after pending task recovery 2026-06-25 08:25:09 -07:00
sheyanmin
8b4be2fdd4 fix: recover channel after stop timeout in health monitor
When a channel stop times out (e.g. during a Telegram API outage),
the channel enters recoveryStopTimedOut state. The health monitor's
subsequent start call would set restartPending and return without
actually starting the channel.

If the stuck stop never completes, the channel stays in limbo forever
with the health monitor retrying every cycle but never recovering.

Fix: when the health monitor retries recovery (recoveryStartRequested
already set), clean up the stuck task state and allow the channel to
start normally.

Closes #94008
2026-06-25 08:25:09 -07:00
Ayaan Zaidi
210ea659f7 fix(outbound): prevent partial-send recovery replay 2026-06-25 08:16:56 -07:00
rosenlo
c0a61f5351 test(outbound): add drain no-replay guard for unknown_after_send; clarify fallback intent
- Add integration test: after mid-batch failure with send evidence the
  resulting unknown_after_send entry is NOT replayed by reconnect drain
  when no adapter reconciliation is available ('refusing blind replay').
  Pins the drain contract so any regression that re-enables blind replay
  is caught end-to-end against a real SQLite queue.
- Add comment in deliver.ts fallback branch: failDelivery inside the
  markQueuedPlatformOutcomeUnknown catch is a last-resort DB-write-error
  path, not an indication that failDelivery is correct with send evidence.
2026-06-25 08:16:56 -07:00
rosenlo
7f2c04ce11 fix(test): remove unused import and unnecessary type assertions in queue integration test 2026-06-25 08:16:56 -07:00
rosenlo
f9e0dce731 test(outbound): add real-queue integration test for unknown_after_send on mid-batch failure
Complement the unit test (which mocks delivery-queue) with an integration
test that uses the real SQLite delivery queue (no mock of ./delivery-queue.js)
and the real deliverOutboundPayloads code path.

Verifies at the queue layer:
- mid-batch failure with send evidence (first payload succeeds, second
  throws, queuePolicy=required) -> queue entry recovery_state advances to
  unknown_after_send, retryCount stays 0, no lastError. This is the patch
  path: drain will route the entry through reconcileUnknownQueuedDelivery
  instead of leaving it in send_attempt_started for blind replay.
- no send evidence (sole payload fails immediately) -> failDelivery path:
  retryCount bumped, recovery_state stays send_attempt_started. Patch does
  not affect this path.

Negative control confirmed: with deliver.ts reverted to v2026.6.8 original
(no patch), the mid-batch test fails with recovery_state=send_attempt_started
(the root-cause state), while the no-evidence test still passes. This
reproduces the patch's code path and proves the fix at the real-queue layer.
2026-06-25 08:16:56 -07:00
rosenlo
71422a9a5a fix(outbound): advance queue entry to unknown_after_send on mid-batch failure with send evidence
When a required-mode batch send fails mid-batch after an earlier payload
already succeeded, the wrapper catch in deliverOutboundPayloadsWithQueueCleanup
called failDelivery. failDelivery only bumps retryCount/lastError; it does
not advance recoveryState, so the entry stayed in send_attempt_started (set
earlier by markDeliveryPlatformSendAttemptStarted via onPlatformSendStart).

On the next Telegram reconnect, drainQueuedEntry sees send_attempt_started
and calls reconcileUnknownQueuedDelivery. When adapter reconciliation
misreports not_sent (the message was actually sent, per the outbound send
ok / messageId evidence), the entry is replayed and the user receives a
duplicate.

Fix: when the error carries send evidence (OutboundDeliveryError with
sentBeforeError === true and platformSendStarted === true), call
markQueuedPlatformOutcomeUnknown instead of failDelivery. This advances the
entry to unknown_after_send, which drain already routes through
reconcileUnknownQueuedDelivery, preserving the entry for adapter
reconciliation rather than leaving it in send_attempt_started for replay.

When there is no send evidence (sentBeforeError === false), failDelivery
remains correct: nothing reached the channel, so retrying is safe.

This is a third duplicate path distinct from #89812 (mirror best-effort)
and #92274 (subagent-announce-delivery retry); it is the outbound/deliver
wrapper catch, which neither prior fix covers.

Tests:
- regression: two payloads, first succeeds, second throws; asserts
  markDeliveryPlatformOutcomeUnknown called, failDelivery/ackDelivery not.
- guard: no send evidence; failDelivery still called.
2026-06-25 08:16:56 -07:00
linhongkuan
2e6e17f7c5 fix(media-generation): preserve trimmed default model flag (#96430) 2026-06-25 20:46:47 +08:00
linhongkuan
1ba1fecaa6 fix(acp-core): clear stale active run lookups (#96427) 2026-06-25 20:44:07 +08:00
Shakker
4ecb45bf77 fix: narrow test config path 2026-06-25 10:40:38 +01:00
Shakker
0757cad597 fix: narrow cron path env cleanup 2026-06-25 10:07:09 +01:00
Shakker
21b21583cc test: isolate reply media state env 2026-06-25 10:02:41 +01:00
Shakker
c8c4490b17 fix: scope embedded image state env 2026-06-25 09:59:32 +01:00
Shakker
d693b70bfc test: preserve daemon coverage env scope 2026-06-25 09:56:16 +01:00
Shakker
2b8c089b76 fix: guard current turn state env 2026-06-25 09:53:17 +01:00
Shakker
1d1c2f4f72 test: stabilize allowlist config env 2026-06-25 09:50:50 +01:00
Shakker
3ce398712a fix: preserve exec env test cleanup 2026-06-25 09:48:15 +01:00
Shakker
3c2a3d9d2b test: centralize tool manager agent env 2026-06-25 09:45:37 +01:00
Shakker
33d7a2a3f7 fix: route session history config env 2026-06-25 09:42:46 +01:00
Vincent Koc
94ae918d8f perf(plugins): reuse installed manifest realpaths (#96710)
Co-authored-by: sheyanmin <she.yanmin@xydigit.com>
2026-06-25 16:41:26 +08:00
David
af906225fa fix(git-hooks): skip sequencer pre-commit formatting (#95842)
* fix(git-hooks): skip sequencer pre-commit formatting

* chore: rerun CI

* fix(git-hooks): skip revert sequencer formatting

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-25 16:40:12 +08:00
Shakker
08b7fddf80 test: centralize shell snapshot env 2026-06-25 09:37:55 +01:00
Wynne668
d7dff3cbf4 fix(document-extract): render PDF image fallback per page so multi-page scans don't starve later pages (#96390)
* fix(document-extract): render PDF image fallback per page so multi-page scans don't starve later pages

clawpdf's mode:"images" extract applies a single maxPixels budget across
every page, so the first page consumes it and later pages collapse to ~1x1
PNGs that vision OCR models reject. Render each selected page in its own
extract() call so the pixel budget resets per page and every page yields a
usable image.

* fix(document-extract): preserve aggregate PDF render budget

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-25 16:37:47 +08:00
Vincent Koc
42d0a1267e perf(gateway): cache transcript field regexes (#96707)
Co-authored-by: Yongan Zhang <374456248@qq.com>
2026-06-25 16:36:44 +08:00
David
99f56cd548 fix(discord): keep audio voice replies threaded (#95978)
* fix(discord): keep audio voice replies threaded

* chore: retrigger CI after base recovery
2026-06-25 16:36:23 +08:00
Shakker
e6a2f61e94 fix: route persisted result config env 2026-06-25 09:29:46 +01:00
93 changed files with 2747 additions and 325 deletions

View File

@@ -1843,7 +1843,7 @@ jobs:
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"

View File

@@ -73,7 +73,7 @@ jobs:
- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
@@ -102,7 +102,7 @@ jobs:
steps.comment_filter.outputs.is_command == 'true' &&
env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true'
}}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}

View File

@@ -29,7 +29,7 @@ jobs:
submodules: false
- name: Setup Java
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: temurin
java-version: "21"

View File

@@ -57,7 +57,7 @@ jobs:
- name: Create autoscrub app token
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@@ -69,7 +69,7 @@ jobs:
id: app-token-fallback
continue-on-error: true
if: steps.app-token.outcome == 'failure'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}

View File

@@ -149,7 +149,7 @@ jobs:
- name: Run Codex docs agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
env:
DOCS_AGENT_BASE_SHA: ${{ steps.gate.outputs.review_base_sha }}
DOCS_AGENT_HEAD_SHA: ${{ steps.gate.outputs.review_head_sha }}

View File

@@ -260,7 +260,7 @@ jobs:
run: pnpm build
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: "1.26.x"
cache: false

View File

@@ -250,7 +250,7 @@ jobs:
run: pnpm build
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: "1.26.x"
cache: false

View File

@@ -190,7 +190,7 @@ jobs:
mantis-slack-pnpm-${{ runner.os }}-${{ env.NODE_VERSION }}-
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: "1.26.x"
cache: false

View File

@@ -362,7 +362,7 @@ jobs:
install-bun: "true"
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: "1.26.x"
cache: false
@@ -445,7 +445,7 @@ jobs:
sudo chown -R codex:codex "$GITHUB_WORKSPACE"
- name: Run Codex Mantis Telegram agent
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
env:
BASELINE_REF: ${{ needs.resolve_request.outputs.baseline_ref }}
BASELINE_SHA: ${{ needs.validate_refs.outputs.baseline_revision }}

View File

@@ -337,7 +337,7 @@ jobs:
mantis-telegram-pnpm-${{ runner.os }}-${{ env.NODE_VERSION }}-
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: "1.26.x"
cache: false

View File

@@ -275,7 +275,7 @@ jobs:
fi
- name: Run Codex maturity scorecard agent
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
env:
MATURITY_EVIDENCE_DIR: .artifacts/maturity-evidence
MATURITY_SCORES_PATH: qa/maturity-scores.yaml

View File

@@ -129,7 +129,7 @@ jobs:
- name: Run Codex test performance agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
with:
openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/test-performance-agent.md

View File

@@ -115,7 +115,7 @@ jobs:
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"

View File

@@ -737,6 +737,10 @@ outbound host generic and use the messaging adapter surface for provider rules:
should be treated as `direct`, `group`, or `channel` before directory lookup.
- `messaging.targetResolver.looksLikeId(raw, normalized)` tells core whether an
input should skip straight to id-like resolution instead of directory search.
- `messaging.targetResolver.reservedLiterals` lists bare words that are
channel/session references for that provider. Resolution preserves configured
directory entries before rejecting reserved literals, then fails closed on a
directory miss.
- `messaging.targetResolver.resolveTarget(...)` is the plugin fallback when
core needs a final provider-owned resolution after normalization or after a
directory miss.

View File

@@ -739,7 +739,7 @@ Write colocated tests in `src/channel.test.ts`:
describeMessageTool and action discovery
</Card>
<Card title="Target resolution" icon="crosshair" href="/plugins/architecture-internals#channel-target-resolution">
inferTargetChatType, looksLikeId, resolveTarget
inferTargetChatType, looksLikeId, reservedLiterals, resolveTarget
</Card>
<Card title="Runtime helpers" icon="settings" href="/plugins/sdk-runtime">
TTS, STT, media, subagent via api.runtime

View File

@@ -345,7 +345,7 @@ describe("discordOutbound", () => {
2,
);
expect(messageOptions.accountId).toBe("default");
expect(messageOptions.replyTo).toBeUndefined();
expect(messageOptions.replyTo).toBe("reply-1");
const mediaCall = mockCall(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 1);
expect(mediaCall[0]).toBe("channel:123456");
@@ -353,7 +353,7 @@ describe("discordOutbound", () => {
const mediaOptions = mockObjectArg(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 1, 2);
expect(mediaOptions.accountId).toBe("default");
expect(mediaOptions.mediaUrl).toBe("https://example.com/extra.png");
expect(mediaOptions.replyTo).toBeUndefined();
expect(mediaOptions.replyTo).toBe("reply-1");
expect(result).toEqual({
channel: "discord",
messageId: "msg-1",
@@ -361,6 +361,31 @@ describe("discordOutbound", () => {
});
});
it("keeps captured replyTo on audioAsVoice sends when replyToMode is batched", async () => {
await discordOutbound.sendPayload?.({
cfg: {},
to: "channel:123456",
text: "",
payload: {
text: "voice note",
mediaUrls: ["https://example.com/voice.ogg", "https://example.com/extra.png"],
audioAsVoice: true,
},
accountId: "default",
replyToId: "reply-1",
replyToMode: "batched",
});
expect(
mockObjectArg(hoisted.sendVoiceMessageDiscordMock, "sendVoiceMessageDiscord", 0, 2).replyTo,
).toBe("reply-1");
expect(
hoisted.sendMessageDiscordMock.mock.calls.map(
(call) => (call[2] as { replyTo?: unknown } | undefined)?.replyTo,
),
).toEqual(["reply-1", "reply-1"]);
});
it("keeps replyToId on every internal audioAsVoice send when replyToMode is all", async () => {
await discordOutbound.sendPayload?.({
cfg: {},

View File

@@ -84,13 +84,15 @@ export async function sendDiscordOutboundPayload(params: {
const sendContext = await createDiscordPayloadSendContext(ctx);
if (payload.audioAsVoice && mediaUrls.length > 0) {
// audioAsVoice emits one logical Discord reply across voice/text/media sends.
// Capture before helper calls consume implicit single-use reply targets.
const voiceReplyTo = sendContext.resolveReplyTo();
let lastResult = await sendContext.withRetry(
async () =>
await sendContext.sendVoice(
sendContext.target,
mediaUrls[0],
resolveDiscordDeliveryOptions(ctx, sendContext),
),
await sendContext.sendVoice(sendContext.target, mediaUrls[0], {
...resolveDiscordDeliveryOptions(ctx, sendContext),
replyTo: voiceReplyTo,
}),
);
if (payload.text?.trim()) {
lastResult = await sendContext.withRetry(
@@ -98,6 +100,7 @@ export async function sendDiscordOutboundPayload(params: {
await sendContext.send(sendContext.target, payload.text, {
verbose: false,
...resolveDiscordFormattedDeliveryOptions(ctx, sendContext),
replyTo: voiceReplyTo,
}),
);
}
@@ -107,6 +110,7 @@ export async function sendDiscordOutboundPayload(params: {
await sendContext.send(sendContext.target, "", {
verbose: false,
...resolveDiscordMediaDeliveryOptions(ctx, sendContext, mediaUrl),
replyTo: voiceReplyTo,
}),
);
}

View File

@@ -55,20 +55,35 @@ describe("PDF document extractor", () => {
});
});
it("extracts text first and renders fallback images through clawpdf", async () => {
pdfDocument.extract.mockResolvedValueOnce({ text: "", images: [] }).mockResolvedValueOnce({
text: "",
images: [
{
type: "image",
bytes: Uint8Array.from(Buffer.from("png")),
mimeType: "image/png",
page: 1,
width: 10,
height: 10,
},
],
});
it("extracts text first and renders each fallback page with its own pixel budget", async () => {
pdfDocument.extract
.mockResolvedValueOnce({ text: "", images: [] })
.mockResolvedValueOnce({
text: "",
images: [
{
type: "image",
bytes: Uint8Array.from(Buffer.from("png1")),
mimeType: "image/png",
page: 1,
width: 5,
height: 10,
},
],
})
.mockResolvedValueOnce({
text: "",
images: [
{
type: "image",
bytes: Uint8Array.from(Buffer.from("png2")),
mimeType: "image/png",
page: 2,
width: 5,
height: 10,
},
],
});
const extractor = createPdfDocumentExtractor();
const result = await extractor.extract(request());
@@ -82,18 +97,24 @@ describe("PDF document extractor", () => {
maxPages: 2,
maxTextChars: 200_000,
});
// Each page renders in its own extract() call, with the aggregate pixel cap
// allocated across selected pages so later pages are not starved.
expect(pdfDocument.extract).toHaveBeenNthCalledWith(2, {
mode: "images",
maxPages: 2,
image: {
maxDimension: 10_000,
maxPixels: 100,
forms: true,
},
pages: [1],
image: { maxDimension: 10_000, maxPixels: 50, forms: true },
});
expect(pdfDocument.extract).toHaveBeenNthCalledWith(3, {
mode: "images",
pages: [2],
image: { maxDimension: 10_000, maxPixels: 50, forms: true },
});
expect(result).toEqual({
text: "",
images: [{ type: "image", data: "cG5n", mimeType: "image/png" }],
images: [
{ type: "image", data: "cG5nMQ==", mimeType: "image/png" },
{ type: "image", data: "cG5nMg==", mimeType: "image/png" },
],
});
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
});
@@ -131,8 +152,9 @@ describe("PDF document extractor", () => {
expect(pdfDocument.destroy).not.toHaveBeenCalled();
});
it("filters selected pages before passing them to clawpdf", async () => {
it("filters selected pages and renders them one page per image call", async () => {
pdfDocument.extract
.mockResolvedValueOnce({ text: "", images: [] })
.mockResolvedValueOnce({ text: "", images: [] })
.mockResolvedValueOnce({ text: "", images: [] });
const extractor = createPdfDocumentExtractor();
@@ -141,11 +163,15 @@ describe("PDF document extractor", () => {
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ pages: [2, 1] }),
expect.objectContaining({ mode: "text", pages: [2, 1] }),
);
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ pages: [2, 1] }),
expect.objectContaining({ mode: "images", pages: [2] }),
);
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ mode: "images", pages: [1] }),
);
});

View File

@@ -83,17 +83,38 @@ async function extractPdfContent(
return { text, images: [] };
}
// clawpdf's image render budget (maxPixels) is shared across every page in one
// extract() call: the first page consumes it and later pages collapse to 1x1
// PNGs that vision models reject. Render each page separately, allocating the
// remaining aggregate budget across pages that still need rendering.
const imagePages =
pages ?? Array.from({ length: Math.min(pdf.pageCount, request.maxPages) }, (_, i) => i + 1);
try {
const imageResult = await pdf.extract({
mode: "images",
...pageSelection,
image: {
maxDimension: MAX_RENDER_DIMENSION,
maxPixels: request.maxPixels,
forms: true,
},
});
return { text, images: imageResult.images.map(toDocumentImage) };
const images: DocumentExtractedImage[] = [];
let remainingPixels = request.maxPixels;
for (let index = 0; index < imagePages.length; index += 1) {
if (remainingPixels <= 0) {
break;
}
const pagesRemaining = imagePages.length - index;
const maxPixelsPerPage = Math.max(1, Math.ceil(remainingPixels / pagesRemaining));
const pageNumber = imagePages[index];
const imageResult = await pdf.extract({
mode: "images",
pages: [pageNumber],
image: {
maxDimension: MAX_RENDER_DIMENSION,
maxPixels: maxPixelsPerPage,
forms: true,
},
});
for (const image of imageResult.images) {
images.push(toDocumentImage(image));
remainingPixels -= image.width * image.height;
}
}
return { text, images };
} catch (err) {
request.onImageExtractionError?.(err);
return { text, images: [] };

View File

@@ -833,6 +833,7 @@ export const telegramPlugin = createChatChannelPlugin({
targetResolver: {
looksLikeId: looksLikeTelegramTargetId,
hint: "<chatId>",
reservedLiterals: ["current", "self", "this", "me"],
},
},
resolver: {

View File

@@ -807,16 +807,16 @@ describe("createTelegramDraftStream", () => {
expectNthPreviewSend(api, 2, "foo bar baz qux");
});
it("clamps a first oversized non-final preview", async () => {
it("clamps a first oversized non-final preview on a UTF-16 boundary", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, { maxChars: 10 });
stream.update("1234567890ABCDEFGHIJ");
stream.update("123456789😀tail");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expectNthPreviewSend(api, 1, "1234567890");
expect(stream.lastDeliveredText?.()).toBe("1234567890");
expectNthPreviewSend(api, 1, "123456789");
expect(stream.lastDeliveredText?.()).toBe("123456789");
});
it("finalizes overflow that was hidden by a clamped non-final preview", async () => {

View File

@@ -5,6 +5,7 @@ import {
takeMessageIdAfterStop,
} from "openclaw/plugin-sdk/channel-outbound";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js";
import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js";
import {
@@ -169,7 +170,7 @@ function findTelegramDraftChunkLength(
high = mid - 1;
}
}
return best;
return sliceUtf16Safe(text, 0, best).length;
}
export function createTelegramDraftStream(params: {

View File

@@ -16,6 +16,18 @@ if [[ ! -f "$FILTER_FILES" ]]; then
exit 1
fi
GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)"
if [[ -n "$GIT_DIR" ]] && \
{ [[ -f "$GIT_DIR/MERGE_HEAD" ]] || \
[[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ]] || \
[[ -f "$GIT_DIR/REVERT_HEAD" ]] || \
[[ -f "$GIT_DIR/REBASE_HEAD" ]] || \
[[ -d "$GIT_DIR/rebase-merge" ]] || \
[[ -d "$GIT_DIR/rebase-apply" ]]; }; then
# Sequencer commits stage the operation result, not just the user's local edits.
exit 0
fi
# Security: avoid option-injection from malicious file names (e.g. "--all", "--force").
# Robustness: NUL-delimited file list handles spaces/newlines safely.
# Compatibility: use read loops instead of `mapfile` so this runs on macOS Bash 3.x.

View File

@@ -34,6 +34,19 @@ describe("acp session manager", () => {
expect(store.getSessionByRunId("run-1")).toBeUndefined();
});
it("removes stale run lookup entries when rebinding an active run", () => {
const session = store.createSession({
sessionKey: "acp:rebind",
cwd: "/tmp",
});
store.setActiveRun(session.sessionId, "run-old", new AbortController());
store.setActiveRun(session.sessionId, "run-new", new AbortController());
expect(store.getSessionByRunId("run-old")).toBeUndefined();
expect(store.getSessionByRunId("run-new")?.sessionId).toBe(session.sessionId);
});
it("deletes sessions and aborts active runs on close", () => {
const session = store.createSession({
sessionId: "close-me",

View File

@@ -150,6 +150,9 @@ export function createInMemorySessionStore(options: AcpSessionStoreOptions = {})
if (!session) {
return;
}
if (session.activeRunId && session.activeRunId !== runId) {
runIdToSessionId.delete(session.activeRunId);
}
session.activeRunId = runId;
session.abortController = abortController;
runIdToSessionId.set(runId, sessionId);

View File

@@ -55,4 +55,27 @@ describe("media-generation catalog", () => {
}),
).toEqual(["video-default", "video-pro"]);
});
it("marks a trimmed default model as the catalog default", () => {
expect(
synthesizeMediaGenerationCatalogEntries({
kind: "video_generation",
provider: {
id: "example",
defaultModel: " video-default ",
models: ["video-default"],
capabilities: {},
},
}),
).toEqual([
{
kind: "video_generation",
provider: "example",
model: "video-default",
source: "static",
default: true,
capabilities: {},
},
]);
});
});

View File

@@ -51,6 +51,7 @@ export function synthesizeMediaGenerationCatalogEntries<TCapabilities>(params: {
provider: MediaGenerationCatalogProvider<TCapabilities>;
modes?: readonly string[];
}): Array<MediaGenerationCatalogEntry<TCapabilities>> {
const defaultModel = uniqueTrimmedStrings([params.provider.defaultModel])[0];
return uniqueModels(params.provider).map((model) => {
const entry: MediaGenerationCatalogEntry<TCapabilities> = {
kind: params.kind,
@@ -62,7 +63,7 @@ export function synthesizeMediaGenerationCatalogEntries<TCapabilities>(params: {
if (params.provider.label) {
entry.label = params.provider.label;
}
if (model === params.provider.defaultModel) {
if (model === defaultModel) {
entry.default = true;
}
if (params.modes) {

View File

@@ -210,7 +210,7 @@ try {
),
publicWildcardReexports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS",
215,
214,
),
};
publicDeprecatedExportsByEntrypointBudget = readEntrypointBudgetEnv(

View File

@@ -6,6 +6,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it, vi } from "vitest";
import { resolvePreferredOpenClawTmpDir } from "../../../infra/tmp-openclaw-dir.js";
import { captureEnv, setTestEnvValue } from "../../../test-utils/env.js";
import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js";
import { createUnsafeMountedSandbox } from "../../test-helpers/unsafe-mounted-sandbox.js";
import {
@@ -420,7 +421,8 @@ describe("loadImageFromRef", () => {
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(inboundDir, { recursive: true });
await fs.writeFile(path.join(inboundDir, mediaId), Buffer.from(TINY_PNG_BASE64, "base64"));
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
try {
const image = await loadImageFromRef(
@@ -437,7 +439,7 @@ describe("loadImageFromRef", () => {
expect(image?.mimeType).toBe("image/png");
expect(image?.data).toBe(TINY_PNG_BASE64);
} finally {
vi.unstubAllEnvs();
envSnapshot.restore();
await fs.rm(stateDir, { recursive: true, force: true });
}
});
@@ -670,7 +672,8 @@ describe("detectAndLoadPromptImages", () => {
const imagePath = path.join(inboundDir, "signal-replay.png");
const pngB64 = TINY_PNG_BASE64;
await fs.writeFile(imagePath, Buffer.from(pngB64, "base64"));
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
try {
const result = await detectAndLoadPromptImages({
@@ -685,7 +688,7 @@ describe("detectAndLoadPromptImages", () => {
expect(result.skippedCount).toBe(0);
expect(result.images).toHaveLength(1);
} finally {
vi.unstubAllEnvs();
envSnapshot.restore();
await fs.rm(stateDir, { recursive: true, force: true });
}
});

View File

@@ -10,6 +10,7 @@ import {
resetGlobalHookRunner,
} from "../plugins/hook-runner-global.js";
import { loadOpenClawPlugins } from "../plugins/loader.js";
import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
@@ -117,9 +118,9 @@ afterEach(() => {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir;
}
if (originalConfigPath === undefined) {
delete process.env.OPENCLAW_CONFIG_PATH;
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
} else {
process.env.OPENCLAW_CONFIG_PATH = originalConfigPath;
setTestEnvValue("OPENCLAW_CONFIG_PATH", originalConfigPath);
}
for (const dir of tempDirs) {
fs.rmSync(dir, { force: true, recursive: true });
@@ -259,9 +260,10 @@ describe("tool_result_persist hook", () => {
it("keeps sensitive parent keys when custom value patterns match the key probe", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-redact-config-"));
tempDirs.push(tempDir);
process.env.OPENCLAW_CONFIG_PATH = path.join(tempDir, "openclaw.json");
const configPath = path.join(tempDir, "openclaw.json");
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
fs.writeFileSync(
process.env.OPENCLAW_CONFIG_PATH,
configPath,
JSON.stringify({ logging: { redactPatterns: ["/[a-z0-9]{30,}/g"] } }),
"utf-8",
);

View File

@@ -4,7 +4,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import {
maybeWrapCommandWithShellSnapshot,
resetShellSnapshotCacheForTests,
@@ -40,9 +40,9 @@ function setSnapshotStateForTest(
options: { home?: string; zdotdir?: string } = {},
): void {
// Snapshot tests mutate trusted process env, not per-command untrusted env.
process.env.OPENCLAW_STATE_DIR = stateDir;
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
if (options.home) {
process.env.HOME = options.home;
setTestEnvValue("HOME", options.home);
}
if (options.zdotdir) {
process.env.ZDOTDIR = options.zdotdir;
@@ -91,7 +91,7 @@ describe("exec shell snapshots", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-disabled-home-"));
tempDirs.push(stateDir, home);
setSnapshotStateForTest(stateDir, { home });
process.env[EXEC_SHELL_SNAPSHOT_ENV] = "0";
setTestEnvValue(EXEC_SHELL_SNAPSHOT_ENV, "0");
const command = "echo unchanged";
const wrapped = await maybeWrapCommandWithShellSnapshot({
command,

View File

@@ -5,6 +5,7 @@ import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { callGateway as gatewayCall } from "../../gateway/call.js";
import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
type CallGatewayRequest = Parameters<typeof gatewayCall>[0];
@@ -18,7 +19,7 @@ function useLoggingConfig(name: string, logging: Record<string, unknown>): void
}
const configPath = path.join(tempDir, name);
fs.writeFileSync(configPath, `${JSON.stringify({ logging })}\n`, "utf8");
process.env.OPENCLAW_CONFIG_PATH = configPath;
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
}
function createHistoryToolWithMessage(content: string) {
@@ -50,9 +51,9 @@ describe("sessions_history redaction", () => {
afterAll(() => {
if (previousConfigPath === undefined) {
delete process.env.OPENCLAW_CONFIG_PATH;
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
} else {
process.env.OPENCLAW_CONFIG_PATH = previousConfigPath;
setTestEnvValue("OPENCLAW_CONFIG_PATH", previousConfigPath);
}
if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });

View File

@@ -2,6 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
const spawnSyncMock = vi.hoisted(() => vi.fn());
@@ -21,7 +22,7 @@ let tempAgentDir: string | undefined;
beforeEach(() => {
originalAgentDir = process.env.OPENCLAW_AGENT_DIR;
tempAgentDir = mkdtempSync(join(tmpdir(), "openclaw-tools-manager-"));
process.env.OPENCLAW_AGENT_DIR = tempAgentDir;
setTestEnvValue("OPENCLAW_AGENT_DIR", tempAgentDir);
fetchWithSsrFGuardMock.mockReset();
spawnSyncMock.mockReturnValue({
error: new Error("ENOENT"),
@@ -35,9 +36,9 @@ afterEach(() => {
vi.clearAllMocks();
vi.resetModules();
if (originalAgentDir === undefined) {
delete process.env.OPENCLAW_AGENT_DIR;
deleteTestEnvValue("OPENCLAW_AGENT_DIR");
} else {
process.env.OPENCLAW_AGENT_DIR = originalAgentDir;
setTestEnvValue("OPENCLAW_AGENT_DIR", originalAgentDir);
}
if (tempAgentDir) {
rmSync(tempAgentDir, { recursive: true, force: true });

View File

@@ -78,12 +78,17 @@ export function resolveSelectedAndActiveModel(params: {
selectedProvider: string;
selectedModel: string;
sessionEntry?: Pick<SessionEntry, "modelProvider" | "model">;
parseSelectedProvider?: boolean;
}): {
selected: ModelRef;
active: ModelRef;
activeDiffers: boolean;
} {
const selected = normalizeModelRef(params.selectedModel, params.selectedProvider);
const selected = normalizeModelRef(
params.selectedModel,
params.selectedProvider,
params.parseSelectedProvider,
);
const runtimeModel = normalizeOptionalString(params.sessionEntry?.model);
const runtimeProvider = normalizeOptionalString(params.sessionEntry?.modelProvider);

View File

@@ -17,6 +17,7 @@ import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import type { HandleCommandsParams } from "./commands-types.js";
import type { ConfigSnapshotMock } from "./commands.test-harness.js";
@@ -256,15 +257,15 @@ async function withTempConfigPath<T>(
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-allowlist-config-"));
const configPath = path.join(dir, "openclaw.json");
const previous = process.env.OPENCLAW_CONFIG_PATH;
process.env.OPENCLAW_CONFIG_PATH = configPath;
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
await fs.writeFile(configPath, JSON.stringify(initialConfig, null, 2), "utf-8");
try {
return await run(configPath);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_CONFIG_PATH;
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
} else {
process.env.OPENCLAW_CONFIG_PATH = previous;
setTestEnvValue("OPENCLAW_CONFIG_PATH", previous);
}
await fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}

View File

@@ -1249,6 +1249,155 @@ describe("buildStatusReply subagent summary", () => {
});
});
it("uses active fallback provider usage for legacy fallback notices", async () => {
const fallbackModel: ModelDefinitionConfig = {
id: "MiniMax-M2.7",
name: "MiniMax M2.7",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 32_000,
};
const selectedModel: ModelDefinitionConfig = {
id: "mimo-v2-flash",
name: "MiMo V2 Flash",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_048_576,
maxTokens: 32_000,
};
providerUsageMock.loadProviderUsageSummary.mockImplementation(async (options) => ({
updatedAt: Date.now(),
providers:
options?.providers?.includes("minimax") === true
? [
{
provider: "minimax",
displayName: "MiniMax",
windows: [{ label: "day", usedPercent: 20 }],
},
]
: [],
}));
const text = await buildStatusText({
cfg: {
...baseCfg,
models: {
providers: {
"minimax-portal": {
baseUrl: "https://api.minimax.test/v1",
models: [fallbackModel],
},
xiaomi: {
baseUrl: "https://api.xiaomi.test/v1",
models: [selectedModel],
},
},
},
},
sessionEntry: {
sessionId: "sess-status-legacy-fallback-usage",
updatedAt: 0,
providerOverride: "xiaomi",
modelOverride: "mimo-v2-flash",
modelProvider: "minimax-portal",
model: "MiniMax-M2.7",
fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash",
fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7",
fallbackNoticeReason: "model not allowed",
totalTokens: 49_000,
totalTokensFresh: true,
contextTokens: 1_048_576,
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
sessionScope: "per-sender",
statusChannel: "mobilechat",
provider: "xiaomi",
model: "mimo-v2-flash",
contextTokens: 1_048_576,
resolvedFastMode: false,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
defaultGroupActivation: () => "mention",
modelAuthOverride: "api-key",
activeModelAuthOverride: "api-key",
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.7");
expect(normalized).toContain("Context: 49k/200k");
expect(normalized).toContain("Usage: day 80% left");
expect(providerUsageMock.loadProviderUsageSummary).toHaveBeenCalledWith(
expect.objectContaining({ providers: ["minimax"] }),
);
});
it("uses live runtime context for unresolved active fallback notices", async () => {
const selectedModel: ModelDefinitionConfig = {
id: "mimo-v2-flash",
name: "MiMo V2 Flash",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_048_576,
maxTokens: 32_000,
};
const text = await buildStatusText({
cfg: {
...baseCfg,
models: {
providers: {
xiaomi: {
baseUrl: "https://api.xiaomi.test/v1",
models: [selectedModel],
},
},
},
},
sessionEntry: {
sessionId: "sess-status-unresolved-fallback-context",
updatedAt: 0,
providerOverride: "xiaomi",
modelOverride: "mimo-v2-flash",
modelProvider: "custom-runtime",
model: "unknown-fallback-model",
fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash",
fallbackNoticeActiveModel: "custom-runtime/unknown-fallback-model",
fallbackNoticeReason: "model not allowed",
totalTokens: 49_000,
totalTokensFresh: true,
contextTokens: 1_048_576,
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
sessionScope: "per-sender",
statusChannel: "mobilechat",
provider: "xiaomi",
model: "mimo-v2-flash",
contextTokens: 123_456,
resolvedFastMode: false,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
defaultGroupActivation: () => "mention",
modelAuthOverride: "api-key",
activeModelAuthOverride: "api-key",
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Fallback: custom-runtime/unknown-fallback-model");
expect(normalized).toContain("Context: 49k/123k");
expect(normalized).not.toContain("Context: 49k/1.0m");
});
it("shows DeepSeek balance summaries in /status output", async () => {
providerUsageMock.loadProviderUsageSummary.mockResolvedValue({
updatedAt: Date.now(),
@@ -1297,6 +1446,241 @@ describe("buildStatusReply subagent summary", () => {
expect(providerUsageCall[0]?.providers).toEqual(["deepseek"]);
});
it("uses the session-selected model provider for /status usage", async () => {
const usageResetBase = Math.floor(Date.now() / 1000);
providerUsageMock.loadProviderUsageSummary.mockImplementation(
async ({ providers = [] } = {}) => ({
updatedAt: Date.now(),
providers: providers.map((provider) =>
provider === "openai"
? {
provider: "openai",
displayName: "OpenAI",
windows: [
{
label: "5h",
usedPercent: 9,
resetAt: (usageResetBase + 60 * 60) * 1000,
},
],
}
: {
provider,
displayName: "DeepSeek",
windows: [],
summary: "Balance ¥42.50",
},
),
}),
);
const text = await buildStatusText({
cfg: {
...baseCfg,
agents: {
defaults: {
model: "deepseek/deepseek-v4-flash",
},
},
},
sessionEntry: {
sessionId: "sess-status-session-selected-usage",
updatedAt: 0,
providerOverride: "openai",
modelOverride: "gpt-5.5",
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
sessionScope: "per-sender",
statusChannel: "telegram",
provider: "deepseek",
model: "deepseek-v4-flash",
contextTokens: 1_000_000,
resolvedFastMode: false,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
defaultGroupActivation: () => "mention",
modelAuthOverride: "oauth (openai:status)",
activeModelAuthOverride: "oauth (openai:status)",
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Model: openai/gpt-5.5");
expect(normalized).toContain("pinned session; config primary deepseek/deepseek-v4-flash");
expect(normalized).toContain("clear /model default");
expect(normalized).toContain("Usage: 5h 91% left");
expect(normalized).not.toContain("Usage: Balance ¥42.50");
expect(providerUsageMock.loadProviderUsageSummary).toHaveBeenCalledWith(
expect.objectContaining({ providers: ["openai"] }),
);
});
it("uses the session-selected provider for /status usage when runtime state is stale", async () => {
const usageResetBase = Math.floor(Date.now() / 1000);
providerUsageMock.loadProviderUsageSummary.mockImplementation(
async ({ providers = [] } = {}) => ({
updatedAt: Date.now(),
providers: providers.map((provider) =>
provider === "openai"
? {
provider: "openai",
displayName: "OpenAI",
windows: [
{
label: "5h",
usedPercent: 9,
resetAt: (usageResetBase + 60 * 60) * 1000,
},
],
}
: {
provider,
displayName: "DeepSeek",
windows: [],
summary: "Balance ¥42.50",
},
),
}),
);
const text = await buildStatusText({
cfg: {
...baseCfg,
agents: {
defaults: {
model: "deepseek/deepseek-v4-flash",
},
},
},
sessionEntry: {
sessionId: "sess-status-stale-runtime-selected-usage",
updatedAt: 0,
providerOverride: "openai",
modelOverride: "gpt-5.5",
modelOverrideSource: "user",
modelProvider: "deepseek",
model: "deepseek-v4-flash",
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
sessionScope: "per-sender",
statusChannel: "telegram",
provider: "deepseek",
model: "deepseek-v4-flash",
contextTokens: 1_000_000,
resolvedFastMode: false,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
defaultGroupActivation: () => "mention",
modelAuthOverride: "oauth (openai:status)",
activeModelAuthOverride: "api-key",
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Model: openai/gpt-5.5");
expect(normalized).toContain("pinned session; config primary deepseek/deepseek-v4-flash");
expect(normalized).toContain("clear /model default");
expect(normalized).toContain("Usage: 5h 91% left");
expect(normalized).not.toContain("Usage: Balance ¥42.50");
expect(providerUsageMock.loadProviderUsageSummary).toHaveBeenCalledWith(
expect.objectContaining({ providers: ["openai"] }),
);
});
it("uses provider-qualified model overrides for /status usage lookup", async () => {
await withTempHome(
async (dir) => {
saveStatusTestAuthProfile({ dir, profileId: "openai:status", provider: "openai" });
const usageResetBase = Math.floor(Date.now() / 1000);
providerUsageMock.loadProviderUsageSummary.mockImplementation(
async ({ providers = [] } = {}) => ({
updatedAt: Date.now(),
providers: providers.map((provider) =>
provider === "openai"
? {
provider: "openai",
displayName: "OpenAI",
windows: [
{
label: "5h",
usedPercent: 9,
resetAt: (usageResetBase + 60 * 60) * 1000,
},
],
}
: {
provider,
displayName: "DeepSeek",
windows: [],
summary: "Balance ¥42.50",
},
),
}),
);
const text = await buildStatusText({
cfg: {
...baseCfg,
models: {
providers: {
openai: {
baseUrl: "https://chatgpt.com/backend-api/codex",
models: [{ ...codexStatusModel, contextWindow: 258_000, contextTokens: 258_000 }],
},
},
},
agents: {
defaults: {
model: "deepseek/deepseek-v4-flash",
},
},
auth: {
order: {
openai: ["openai:status"],
},
},
},
sessionEntry: {
sessionId: "sess-status-qualified-session-selected-usage",
updatedAt: 0,
modelOverride: "openai/gpt-5.5",
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
sessionScope: "per-sender",
statusChannel: "telegram",
provider: "deepseek",
model: "deepseek-v4-flash",
contextTokens: 1_000_000,
resolvedFastMode: false,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
defaultGroupActivation: () => "mention",
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Model: openai/gpt-5.5");
expect(normalized).toContain("pinned session; config primary deepseek/deepseek-v4-flash");
expect(normalized).toContain("clear /model default");
expect(normalized).toContain("oauth (openai:status)");
expect(normalized).toContain("Context: ?/258k");
expect(normalized).toContain("Usage: 5h 91% left");
expect(normalized).not.toContain("Usage: Balance ¥42.50");
expect(providerUsageMock.loadProviderUsageSummary).toHaveBeenCalledWith(
expect.objectContaining({ providers: ["openai"] }),
);
},
{ env: { OPENAI_API_KEY: undefined } },
);
});
it("uses Codex OAuth auth labels for explicit OpenAI OpenClaw auth order", async () => {
await withTempHome(
async (dir) => {

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withTempDir } from "../../test-helpers/temp-dir.js";
import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
import type { MsgContext } from "../templating.js";
import { resolveCurrentTurnImages } from "./current-turn-images.js";
@@ -11,9 +12,9 @@ const originalStateDirEnv = process.env.OPENCLAW_STATE_DIR;
function restoreProcessState() {
if (originalStateDirEnv === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
deleteTestEnvValue("OPENCLAW_STATE_DIR");
} else {
process.env.OPENCLAW_STATE_DIR = originalStateDirEnv;
setTestEnvValue("OPENCLAW_STATE_DIR", originalStateDirEnv);
}
}
@@ -33,7 +34,7 @@ describe("resolveCurrentTurnImages", () => {
await fs.mkdir(path.dirname(attachmentPath), { recursive: true });
await fs.mkdir(cwd, { recursive: true });
await fs.writeFile(attachmentPath, imageBytes);
process.env.OPENCLAW_STATE_DIR = stateDir;
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
vi.spyOn(process, "cwd").mockReturnValue(cwd);
const result = await resolveCurrentTurnImages({

View File

@@ -2,12 +2,14 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv, setTestEnvValue } from "../../test-utils/env.js";
import { getReplyPayloadMetadata, setReplyPayloadMetadata } from "../reply-payload.js";
const ensureSandboxWorkspaceForSession = vi.hoisted(() => vi.fn());
const resolveOutboundAttachmentFromUrl = vi.hoisted(() => vi.fn());
const resolveAgentScopedOutboundMediaAccess = vi.hoisted(() => vi.fn());
const stateDirEnvSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
vi.mock("../../agents/sandbox.js", () => ({
ensureSandboxWorkspaceForSession,
@@ -86,7 +88,10 @@ describe("createReplyMediaPathNormalizer", () => {
localRoots: workspaceDir ? [workspaceDir] : undefined,
readFile: async () => Buffer.from("image"),
}));
vi.unstubAllEnvs();
});
afterEach(() => {
stateDirEnvSnapshot.restore();
});
it("stages workspace-relative media through shared outbound attachment loading", async () => {
@@ -355,7 +360,7 @@ describe("createReplyMediaPathNormalizer", () => {
});
it("keeps managed generated media under the shared media root", async () => {
vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw");
setTestEnvValue("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw");
const normalize = createReplyMediaPathNormalizer({
cfg: {},
sessionKey: "session-key",
@@ -377,7 +382,7 @@ describe("createReplyMediaPathNormalizer", () => {
workspaceDir: "/tmp/sandboxes/session-1",
containerWorkdir: "/workspace",
});
vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw");
setTestEnvValue("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw");
const normalize = createReplyMediaPathNormalizer({
cfg: {},
sessionKey: "session-key",
@@ -406,7 +411,7 @@ describe("createReplyMediaPathNormalizer", () => {
await fs.mkdir(path.dirname(symlinkPath), { recursive: true });
await fs.writeFile(outsideFile, "secret", "utf8");
await fs.symlink(outsideFile, symlinkPath);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
const normalize = createReplyMediaPathNormalizer({
cfg: {},
sessionKey: "session-key",

View File

@@ -92,6 +92,14 @@ export function expectChannelSurfaceContract(params: {
expect(typeof messaging.targetResolver.hint).toBe("string");
expect(messaging.targetResolver.hint.trim()).not.toBe("");
}
if (messaging.targetResolver.reservedLiterals !== undefined) {
expect(Array.isArray(messaging.targetResolver.reservedLiterals)).toBe(true);
expect(
messaging.targetResolver.reservedLiterals.every(
(value) => typeof value === "string" && value.trim(),
),
).toBe(true);
}
if (messaging.targetResolver.resolveTarget) {
expect(typeof messaging.targetResolver.resolveTarget).toBe("function");
}

View File

@@ -608,6 +608,8 @@ export type ChannelMessagingAdapter = {
targetResolver?: {
looksLikeId?: (raw: string, normalized?: string) => boolean;
hint?: string;
/** Bare words that are command/session references for this channel, not literal destinations. */
reservedLiterals?: readonly string[];
/**
* Plugin-owned fallback for explicit/native targets or post-directory-miss
* resolution. This should complement directory lookup, not duplicate it.

View File

@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { registerDaemonCli } from "./daemon-cli/register.js";
const probeGatewayStatus = vi.fn(async (..._args: unknown[]) => ({ ok: true }));
@@ -191,10 +191,10 @@ describe("daemon-cli coverage", () => {
"OPENCLAW_GATEWAY_PORT",
"OPENCLAW_PROFILE",
]);
process.env.OPENCLAW_STATE_DIR = tmpDir;
process.env.OPENCLAW_CONFIG_PATH = path.join(tmpDir, "openclaw.json");
delete process.env.OPENCLAW_GATEWAY_PORT;
delete process.env.OPENCLAW_PROFILE;
setTestEnvValue("OPENCLAW_STATE_DIR", tmpDir);
setTestEnvValue("OPENCLAW_CONFIG_PATH", path.join(tmpDir, "openclaw.json"));
deleteTestEnvValue("OPENCLAW_GATEWAY_PORT");
deleteTestEnvValue("OPENCLAW_PROFILE");
serviceReadCommand.mockResolvedValue(null);
resolveGatewayProbeAuthSafeWithSecretInputs.mockClear();
findExtraGatewayServices.mockClear();

View File

@@ -15,8 +15,11 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
}));
import {
detectSessionSnapshotHealthIssues,
noteSessionSnapshotHealth,
scanSessionStoreForStaleRuntimeSnapshotPaths,
sessionSnapshotIssueToHealthFinding,
sessionSnapshotIssueToRepairEffect,
} from "./doctor-session-snapshots.js";
function sessionEntry(patch: Partial<SessionEntry>): SessionEntry {
@@ -66,6 +69,23 @@ async function writeSessionStore(
await fs.writeFile(storePath, JSON.stringify(store, null, 2));
}
function readMainSessionEntry(raw: string): SessionEntry {
const parsed = JSON.parse(raw) as Record<string, SessionEntry>;
const entry = parsed["agent:main"];
if (!entry) {
throw new Error("expected agent:main session entry");
}
return entry;
}
function readMainSkillsSnapshot(raw: string): NonNullable<SessionEntry["skillsSnapshot"]> {
const snapshot = readMainSessionEntry(raw).skillsSnapshot;
if (!snapshot) {
throw new Error("expected agent:main skills snapshot");
}
return snapshot;
}
describe("doctor session snapshot stale runtime metadata", () => {
let root = "";
let bundledSkillsDir = "";
@@ -135,6 +155,57 @@ describe("doctor session snapshot stale runtime metadata", () => {
]);
});
it("maps stale snapshot paths to structured findings and dry-run effects", async () => {
const stalePath = path.join(
root,
"old-runtime",
"node_modules",
"openclaw",
"skills",
"doctor",
"SKILL.md",
);
const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json");
await writeSessionStore(storePath, {
"agent:main": sessionEntry({
skillsSnapshot: {
prompt: skillPrompt(stalePath),
skills: [{ name: "doctor" }],
},
}),
});
const [issue] = await detectSessionSnapshotHealthIssues({
storePaths: [storePath],
bundledSkillsDir,
});
if (!issue) {
throw new Error("expected session snapshot health issue");
}
expect(issue).toMatchObject({
storePath,
sessionKey: "agent:main",
field: "skillsSnapshot.prompt",
cachedPath: stalePath,
expectedPath: path.join(bundledSkillsDir, "doctor", "SKILL.md"),
});
expect(sessionSnapshotIssueToHealthFinding(issue)).toMatchObject({
checkId: "core/doctor/session-snapshots",
severity: "info",
path: storePath,
target: stalePath,
requirement: expect.stringContaining(bundledSkillsDir),
fixHint: expect.stringContaining("openclaw doctor --fix"),
});
expect(sessionSnapshotIssueToRepairEffect(issue)).toEqual({
kind: "file",
action: "would-rewrite-session-snapshot-path",
target: storePath,
dryRunSafe: false,
});
});
it("expands home-relative cached bundled skill locations before classifying them", () => {
const homeDir = path.join(root, "home");
const stalePath = "~/old-runtime/node_modules/openclaw/skills/doctor/SKILL.md";
@@ -456,8 +527,9 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
});
const raw = await fs.readFile(storePath, "utf-8");
expect(raw).not.toContain(stalePath);
expect(raw).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
const snapshot = readMainSkillsSnapshot(raw);
expect(snapshot.prompt).not.toContain(stalePath);
expect(snapshot.prompt).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
expect(note).toHaveBeenCalledTimes(1);
const [message] = note.mock.calls[0] as [string, string];
expect(message).toContain("Repaired");
@@ -535,9 +607,13 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
const raw = await fs.readFile(storePath, "utf-8");
const expectedBaseDir = path.dirname(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
expect(raw).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
expect(raw).toContain(expectedBaseDir);
expect(raw).not.toContain(path.join(root, "old-runtime"));
const expectedPath = path.join(bundledSkillsDir, "doctor", "SKILL.md");
const snapshot = readMainSkillsSnapshot(raw);
const skill = snapshot.resolvedSkills?.[0];
expect(skill?.filePath).toBe(expectedPath);
expect(skill?.baseDir).toBe(expectedBaseDir);
expect(skill?.sourceInfo.path).toBe(expectedPath);
expect(skill?.sourceInfo.baseDir).toBe(expectedBaseDir);
expect(note).toHaveBeenCalledTimes(1);
const [message] = note.mock.calls[0] as [string, string];
expect(message).toContain("Repaired");
@@ -576,9 +652,12 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
});
const raw = await fs.readFile(storePath, "utf-8");
expect(raw).toContain(currentPath);
expect(raw).toContain(path.dirname(currentPath));
expect(raw).not.toContain(path.join(root, "old-runtime"));
const snapshot = readMainSkillsSnapshot(raw);
const repairedSkill = snapshot.resolvedSkills?.[0];
expect(repairedSkill?.filePath).toBe(currentPath);
expect(repairedSkill?.baseDir).toBe(path.dirname(currentPath));
expect(repairedSkill?.sourceInfo.path).toBe(currentPath);
expect(repairedSkill?.sourceInfo.baseDir).toBe(path.dirname(currentPath));
expect(note).toHaveBeenCalledTimes(1);
const [message] = note.mock.calls[0] as [string, string];
expect(message).toContain("Repaired");
@@ -743,7 +822,8 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
expect(backupFiles.length).toBe(1);
const backupContent = await fs.readFile(path.join(dir, backupFiles[0]), "utf-8");
expect(backupContent).toContain(stalePath);
const backupSnapshot = readMainSkillsSnapshot(backupContent);
expect(backupSnapshot.prompt).toContain(stalePath);
});
it("is idempotent — second repair finds nothing", async () => {

View File

@@ -12,11 +12,14 @@ import {
import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions/targets.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
import { expandHomePrefix } from "../infra/home-dir.js";
import { writeTextAtomic } from "../infra/json-files.js";
import { resolveBundledSkillsDir } from "../skills/loading/bundled-dir.js";
import { shortenHomePath } from "../utils.js";
const SESSION_SNAPSHOTS_CHECK_ID = "core/doctor/session-snapshots";
type SnapshotPathSource =
| "skillsSnapshot.prompt"
| "skillsSnapshot.resolvedSkills"
@@ -34,6 +37,10 @@ type StaleSessionSnapshotPathFinding = {
expectedPath: string;
};
export type SessionSnapshotHealthIssue = StaleSessionSnapshotPathFinding & {
storePath: string;
};
function decodeXmlText(value: string): string {
return value
.replace(/&lt;/g, "<")
@@ -286,6 +293,72 @@ function loadSessionStoreForSnapshotScan(storePath: string): Record<string, Sess
return store;
}
export async function detectSessionSnapshotHealthIssues(params?: {
storePaths?: string[];
bundledSkillsDir?: string;
cfg?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): Promise<SessionSnapshotHealthIssue[]> {
const bundledSkillsDir = params?.bundledSkillsDir ?? resolveBundledSkillsDir();
if (!bundledSkillsDir) {
return [];
}
const storePaths =
params?.storePaths ??
resolveSessionStorePaths({ cfg: params?.cfg, env: params?.env }) ??
(await listSessionStorePaths(resolveStateDir(params?.env)));
const issues: SessionSnapshotHealthIssue[] = [];
for (const storePath of storePaths) {
let store: Record<string, SessionEntry>;
try {
store = loadSessionStoreForSnapshotScan(storePath);
} catch {
continue;
}
const findings = scanSessionStoreForStaleRuntimeSnapshotPaths({
store,
bundledSkillsDir,
env: params?.env,
});
for (const finding of findings) {
issues.push({
sessionKey: finding.sessionKey,
field: finding.field,
cachedPath: finding.cachedPath,
expectedPath: finding.expectedPath,
storePath,
});
}
}
return issues;
}
export function sessionSnapshotIssueToHealthFinding(
issue: SessionSnapshotHealthIssue,
): HealthFinding {
return {
checkId: SESSION_SNAPSHOTS_CHECK_ID,
severity: "info",
message: `${issue.sessionKey} cached session metadata references an inactive runtime root that can be cleaned up.`,
path: issue.storePath,
target: issue.cachedPath,
requirement: `Current bundled skill path: ${issue.expectedPath}`,
fixHint:
"To clean up the advisory artifact, run `openclaw doctor --fix` to rewrite stale cached session metadata paths, or start a fresh session after confirming history can be retired.",
};
}
export function sessionSnapshotIssueToRepairEffect(
issue: SessionSnapshotHealthIssue,
): HealthRepairEffect {
return {
kind: "file",
action: "would-rewrite-session-snapshot-path",
target: issue.storePath,
dryRunSafe: false,
};
}
/** Replaces stale paths in raw, JSON-escaped, and XML-escaped prompt text. */
function replaceStalePathsInText(text: string, finding: StaleSessionSnapshotPathFinding): string {
const jsonEscaped = JSON.stringify(finding.cachedPath).slice(1, -1);

View File

@@ -12,8 +12,11 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
}));
import {
detectSessionTranscriptHealthIssues,
noteSessionTranscriptHealth,
repairBrokenSessionTranscriptFile,
sessionTranscriptIssueToHealthFinding,
sessionTranscriptIssueToRepairEffect,
} from "./doctor-session-transcripts.js";
function countNonEmptyLines(value: string): number {
@@ -150,6 +153,44 @@ describe("doctor session transcript repair", () => {
expect(countNonEmptyLines(await fs.readFile(filePath, "utf-8"))).toBe(3);
});
it("maps affected transcripts to structured findings and dry-run effects", async () => {
const filePath = await writeTranscript([
{ type: "session", version: 3, id: "session-1", timestamp: "2026-04-25T00:00:00Z" },
{
type: "message",
id: "legacy-assistant",
parentId: null,
message: {
role: "assistant",
provider: "openai-codex",
api: "openai-codex-responses",
content: [{ type: "text", text: "hello" }],
},
},
]);
const sessionsDir = path.dirname(filePath);
const [issue] = await detectSessionTranscriptHealthIssues({ sessionDirs: [sessionsDir] });
if (!issue) {
throw new Error("expected session transcript health issue");
}
expect(issue?.filePath).toBe(filePath);
expect(sessionTranscriptIssueToHealthFinding(issue)).toMatchObject({
checkId: "core/doctor/session-transcripts",
severity: "info",
path: filePath,
fixHint: expect.stringContaining("openclaw doctor --fix"),
});
expect(sessionTranscriptIssueToRepairEffect(issue)).toEqual({
kind: "file",
action: "would-rewrite-session-transcript",
target: filePath,
dryRunSafe: false,
});
expect(await fs.readFile(filePath, "utf-8")).toContain("openai-codex");
});
it("repairs supported current-version linear transcripts", async () => {
const filePath = await writeTranscript([
{ type: "session", version: 3, id: "session-linear", timestamp: "2026-06-15T00:00:00Z" },

View File

@@ -16,8 +16,11 @@ import {
scanSessionTranscriptTree,
selectSessionTranscriptTreePathNodes,
} from "../config/sessions/transcript-tree.js";
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
import { shortenHomePath } from "../utils.js";
const SESSION_TRANSCRIPTS_CHECK_ID = "core/doctor/session-transcripts";
type TranscriptEntry = Record<string, unknown> & {
id?: unknown;
parentId?: unknown;
@@ -36,6 +39,10 @@ type TranscriptRepairResult = {
reason?: string;
};
export type SessionTranscriptHealthIssue = TranscriptRepairResult & {
broken: true;
};
type ActiveTranscriptPath = {
entries: TranscriptEntry[];
entriesToPersist: TranscriptEntry[];
@@ -372,6 +379,57 @@ async function listSessionTranscriptFiles(sessionDirs: string[]): Promise<string
return files.toSorted((a, b) => a.localeCompare(b));
}
export async function detectSessionTranscriptHealthIssues(params?: {
sessionDirs?: string[];
}): Promise<SessionTranscriptHealthIssue[]> {
let sessionDirs = params?.sessionDirs;
try {
sessionDirs ??= await resolveAgentSessionDirs(resolveStateDir(process.env));
} catch {
return [];
}
const files = await listSessionTranscriptFiles(sessionDirs);
const issues: SessionTranscriptHealthIssue[] = [];
for (const filePath of files) {
const result = await repairBrokenSessionTranscriptFile({ filePath, shouldRepair: false });
if (result.broken) {
issues.push(result as SessionTranscriptHealthIssue);
}
}
return issues;
}
export function sessionTranscriptIssueToHealthFinding(
issue: SessionTranscriptHealthIssue,
): HealthFinding {
const metadata =
issue.legacyOpenAICodexEntries > 0
? ` ${issue.legacyOpenAICodexEntries} legacy OpenAI Codex metadata entr${
issue.legacyOpenAICodexEntries === 1 ? "y" : "ies"
}`
: "";
return {
checkId: SESSION_TRANSCRIPTS_CHECK_ID,
severity: "info",
message: `Session transcript has legacy branch or provider metadata that can be cleaned up.${metadata}`,
path: issue.filePath,
fixHint:
"To clean up the advisory artifact, run `openclaw doctor --fix` to rewrite affected transcripts to their active branch.",
};
}
export function sessionTranscriptIssueToRepairEffect(
issue: SessionTranscriptHealthIssue,
): HealthRepairEffect {
return {
kind: "file",
action: "would-rewrite-session-transcript",
target: issue.filePath,
dryRunSafe: false,
};
}
/** Scans session transcript files and reports or repairs legacy/broken transcript state. */
export async function noteSessionTranscriptHealth(params?: {
shouldRepair?: boolean;
@@ -386,14 +444,14 @@ export async function noteSessionTranscriptHealth(params?: {
return;
}
const files = await listSessionTranscriptFiles(sessionDirs);
if (files.length === 0) {
return;
}
const results: TranscriptRepairResult[] = [];
for (const filePath of files) {
results.push(await repairBrokenSessionTranscriptFile({ filePath, shouldRepair }));
if (shouldRepair) {
const files = await listSessionTranscriptFiles(sessionDirs);
for (const filePath of files) {
results.push(await repairBrokenSessionTranscriptFile({ filePath, shouldRepair }));
}
} else {
results.push(...(await detectSessionTranscriptHealthIssues({ sessionDirs })));
}
const broken = results.filter((result) => result.broken);
if (broken.length === 0) {

View File

@@ -182,7 +182,7 @@ describe("cron activeJobIds — manual-run mark/clear", () => {
}
});
it("requests one setup-timeout restart when concurrent manual runs both stall before runner start", async () => {
it("sends one setup-timeout notification when concurrent manual runs both stall before runner start", async () => {
vi.useFakeTimers();
const now = Date.parse("2025-12-13T17:00:00.000Z");
vi.setSystemTime(now);

View File

@@ -1,6 +1,9 @@
// Isolated agent delivery target tests cover target resolution for cron runs.
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type {
ChannelDirectoryEntry,
ChannelOutboundAdapter,
} from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import {
@@ -741,6 +744,57 @@ describe("resolveDeliveryTarget", () => {
expect(result.threadId).toBeUndefined();
});
it("resolves cron reserved explicit targets through directory entries", async () => {
setMainSessionEntry(undefined);
const listGroups = vi.fn(async () => [
{
kind: "group",
id: "-1002458651455",
name: "current",
handle: "@current",
} satisfies ChannelDirectoryEntry,
]);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: {
...createOutboundTestPlugin({
id: "telegram",
outbound: createStubOutbound("Telegram"),
capabilities: { chatTypes: ["direct", "group", "channel"] },
messaging: {
...telegramMessagingForTest,
normalizeTarget: normalizeTelegramTargetForDeliveryTest,
targetResolver: {
reservedLiterals: ["current", "self", "this", "me"],
hint: "<chatId>",
},
},
}),
directory: { listGroups },
},
},
]),
);
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "telegram",
to: "current",
});
expect(result.ok).toBe(true);
expect(result.to).toBe("-1002458651455");
expect(result.threadId).toBeUndefined();
expect(listGroups).toHaveBeenCalledWith(
expect.objectContaining({
accountId: undefined,
query: "current",
}),
);
});
it("uses canonical route targets even when the route has no thread", async () => {
setMainSessionEntry(undefined);
setActivePluginRegistry(

View File

@@ -11,6 +11,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { stripTargetProviderPrefix } from "../../infra/outbound/channel-target-prefix.js";
import type { OutboundSessionRoute } from "../../infra/outbound/outbound-session.js";
import { isReservedTargetLiteralError } from "../../infra/outbound/target-errors.js";
import type { ResolvedMessagingTarget } from "../../infra/outbound/target-resolver.js";
import { tryResolveLoadedOutboundTarget } from "../../infra/outbound/targets-loaded.js";
import { resolveSessionDeliveryTarget } from "../../infra/outbound/targets-session.js";
@@ -349,17 +350,20 @@ export async function resolveDeliveryTarget(
allowFrom: effectiveAllowFrom,
});
if (!docked.ok) {
return {
ok: false,
channel,
to: undefined,
accountId,
threadId: explicitThreadId,
mode,
error: docked.error,
};
if (!toCandidate || !isReservedTargetLiteralError(docked.error)) {
return {
ok: false,
channel,
to: undefined,
accountId,
threadId: explicitThreadId,
mode,
error: docked.error,
};
}
} else {
toCandidate = docked.to;
}
toCandidate = docked.to;
const targetResolution = await deliveryTargetRuntime.resolveChannelTargetForDelivery({
cfg,
channel,

View File

@@ -236,7 +236,7 @@ export function createMockCronStateForJobs(params: {
stopped: false,
restartRecoveryPending: false,
activeManualRunJobIds: new Set<string>(),
manualSetupTimeoutRestartNotified: false,
manualSetupTimeoutNotified: false,
timer: null,
storeLoadedAtMs: nowMs,
op: Promise.resolve(),

View File

@@ -86,7 +86,7 @@ function clearManualCronJobActive(
state.activeManualRunJobIds.delete(jobId);
clearCronJobActive(jobId, activeJobMarker);
if (state.activeManualRunJobIds.size === 0) {
state.manualSetupTimeoutRestartNotified = false;
state.manualSetupTimeoutNotified = false;
}
}
@@ -98,11 +98,11 @@ function maybeNotifyManualIsolatedSetupTimeout(
isolatedAgentSetupTimeout?: IsolatedAgentSetupTimeoutSignal;
},
): boolean {
if (!result.isolatedAgentSetupTimeout || state.manualSetupTimeoutRestartNotified) {
if (!result.isolatedAgentSetupTimeout || state.manualSetupTimeoutNotified) {
return false;
}
const notified = maybeNotifyIsolatedAgentSetupTimeout(state, result);
state.manualSetupTimeoutRestartNotified ||= notified;
state.manualSetupTimeoutNotified ||= notified;
return notified;
}

View File

@@ -197,7 +197,7 @@ export type CronServiceState = {
stopped: boolean;
restartRecoveryPending: boolean;
activeManualRunJobIds: Set<string>;
manualSetupTimeoutRestartNotified: boolean;
manualSetupTimeoutNotified: boolean;
/** Serializes mutating service operations so store writes and timers stay ordered. */
op: Promise<unknown>;
warnedDisabled: boolean;
@@ -221,7 +221,7 @@ export function createCronServiceState(deps: CronServiceDeps): CronServiceState
stopped: false,
restartRecoveryPending: false,
activeManualRunJobIds: new Set<string>(),
manualSetupTimeoutRestartNotified: false,
manualSetupTimeoutNotified: false,
op: Promise.resolve(),
warnedDisabled: false,
warnedInvalidPersistedJobKeys: new Set<string>(),

View File

@@ -1301,7 +1301,7 @@ describe("cron service timer regressions", () => {
}
});
it("notifies setup-timeout restart after startup catch-up finalization", async () => {
it("notifies setup timeout after startup catch-up finalization", async () => {
vi.useFakeTimers();
try {
const store = timerRegressionFixtures.makeStorePath();
@@ -1926,7 +1926,7 @@ describe("cron service timer regressions", () => {
expect(jobs.find((job) => job.id === second.id)?.state.lastStatus).toBe("ok");
});
it("requests one setup-timeout restart when a concurrent cron batch stalls before runners start", async () => {
it("sends one setup-timeout notification when a concurrent cron batch stalls before runners start", async () => {
vi.useFakeTimers();
try {
const store = timerRegressionFixtures.makeStorePath();
@@ -1990,7 +1990,7 @@ describe("cron service timer regressions", () => {
}
});
it("requests setup-timeout restart after a prior serial cron job completes", async () => {
it("sends setup-timeout notification after a prior serial cron job completes", async () => {
vi.useFakeTimers();
try {
const store = timerRegressionFixtures.makeStorePath();
@@ -2058,7 +2058,7 @@ describe("cron service timer regressions", () => {
}
});
it("requests setup-timeout restart when manual and scheduled runs both stall", async () => {
it("sends setup-timeout notification when manual and scheduled runs both stall", async () => {
vi.useFakeTimers();
try {
const store = timerRegressionFixtures.makeStorePath();
@@ -2128,7 +2128,7 @@ describe("cron service timer regressions", () => {
}
});
it("suppresses scheduled rearm after manual setup-timeout restart request", async () => {
it("rearms scheduled jobs after manual setup timeout notification", async () => {
vi.useFakeTimers();
try {
const store = timerRegressionFixtures.makeStorePath();
@@ -2179,8 +2179,8 @@ describe("cron service timer regressions", () => {
await vi.advanceTimersByTimeAsync(1);
expect(onIsolatedAgentSetupTimeout).toHaveBeenCalledTimes(1);
expect(state.restartRecoveryPending).toBe(true);
expect(state.timer).toBeNull();
expect(state.restartRecoveryPending).toBe(false);
expect(state.timer).not.toBeNull();
expect(scheduledStarted).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
@@ -2352,7 +2352,7 @@ describe("cron service timer regressions", () => {
).toBe(replacementReservationMs);
});
it("stops an active scheduled batch from claiming more jobs after manual setup-timeout recovery", async () => {
it("continues an active scheduled batch after manual setup-timeout notification", async () => {
vi.useFakeTimers();
try {
const store = timerRegressionFixtures.makeStorePath();
@@ -2419,14 +2419,14 @@ describe("cron service timer regressions", () => {
await vi.advanceTimersByTimeAsync(60_100);
now += 60_100;
await manualRun;
expect(state.restartRecoveryPending).toBe(true);
expect(state.restartRecoveryPending).toBe(false);
finishFirstScheduled.resolve();
await timerRun;
const second = requireJob(state, secondScheduledJob.id);
expect(onIsolatedAgentSetupTimeout).toHaveBeenCalledTimes(1);
expect(secondScheduledStarted).not.toHaveBeenCalled();
expect(secondScheduledStarted).toHaveBeenCalledWith(secondScheduledJob.id);
expect(second.state.runningAtMs).toBeUndefined();
} finally {
vi.useRealTimers();
@@ -2794,7 +2794,7 @@ describe("cron service timer regressions", () => {
}
});
it("does not request setup-timeout restart for cron-nested lane contention", async () => {
it("does not notify setup timeout for cron-nested lane contention", async () => {
vi.useFakeTimers();
try {
const store = timerRegressionFixtures.makeStorePath();
@@ -2854,7 +2854,7 @@ describe("cron service timer regressions", () => {
}
});
it("does not notify setup-timeout restart for custom-session cron waits", async () => {
it("does not notify setup timeout for custom-session cron waits", async () => {
vi.useFakeTimers();
try {
const store = timerRegressionFixtures.makeStorePath();

View File

@@ -344,7 +344,6 @@ export function maybeNotifyIsolatedAgentSetupTimeout(
if (!notified) {
return false;
}
state.restartRecoveryPending = true;
return true;
}

View File

@@ -7,6 +7,7 @@ import {
archiveLegacyCronStoreForMigration,
loadLegacyCronStoreForMigration,
} from "../commands/doctor/cron/legacy-store-migration.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import {
loadCronJobsStoreWithConfigJobs,
loadCronJobsStoreSync,
@@ -79,13 +80,15 @@ function requireRecord(value: unknown, label: string): Record<string, unknown> {
}
describe("resolveCronStorePath", () => {
const envSnapshot = captureEnv(["OPENCLAW_HOME", "HOME"]);
afterEach(() => {
vi.unstubAllEnvs();
envSnapshot.restore();
});
it("uses OPENCLAW_HOME for tilde expansion", () => {
vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home");
vi.stubEnv("HOME", "/home/other");
setTestEnvValue("OPENCLAW_HOME", "/srv/openclaw-home");
setTestEnvValue("HOME", "/home/other");
const result = resolveCronStorePath("~/cron/jobs.json");
expect(result).toBe(path.resolve("/srv/openclaw-home", "cron", "jobs.json"));

View File

@@ -1014,6 +1014,8 @@ describe("doctor health contributions", () => {
expect(contributionIds).toContain("core/doctor/sandbox/registry-files");
expect(contributionIds).toContain("core/doctor/gateway-services/extra");
expect(contributionIds).toContain("core/doctor/config-audit-scrub");
expect(contributionIds).toContain("core/doctor/session-transcripts");
expect(contributionIds).toContain("core/doctor/session-snapshots");
expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds);
});

View File

@@ -1298,11 +1298,71 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
createDoctorHealthContribution({
id: "doctor:session-transcripts",
label: "Session transcripts",
healthChecks: {
id: "core/doctor/session-transcripts",
description: "Legacy or branchy session transcript files are represented as findings.",
async detect() {
const { detectSessionTranscriptHealthIssues, sessionTranscriptIssueToHealthFinding } =
await import("../commands/doctor-session-transcripts.js");
return (await detectSessionTranscriptHealthIssues()).map(
sessionTranscriptIssueToHealthFinding,
);
},
async repair(ctx) {
const { detectSessionTranscriptHealthIssues, sessionTranscriptIssueToRepairEffect } =
await import("../commands/doctor-session-transcripts.js");
const effects = (await detectSessionTranscriptHealthIssues()).map(
sessionTranscriptIssueToRepairEffect,
);
if (ctx.dryRun === true) {
return { status: "repaired", changes: [], effects };
}
return {
status: "skipped",
reason: "legacy doctor session transcript contribution owns transcript rewrites",
changes: [],
effects,
};
},
},
run: runSessionTranscriptsHealth,
}),
createDoctorHealthContribution({
id: "doctor:session-snapshots",
label: "Session snapshots",
healthChecks: {
id: "core/doctor/session-snapshots",
description: "Stale cached session snapshot paths are represented as findings.",
async detect(ctx) {
const { detectSessionSnapshotHealthIssues, sessionSnapshotIssueToHealthFinding } =
await import("../commands/doctor-session-snapshots.js");
return (
await detectSessionSnapshotHealthIssues({
cfg: ctx.cfg,
env: process.env,
})
).map(sessionSnapshotIssueToHealthFinding);
},
async repair(ctx) {
const { detectSessionSnapshotHealthIssues, sessionSnapshotIssueToRepairEffect } =
await import("../commands/doctor-session-snapshots.js");
const effects = (
await detectSessionSnapshotHealthIssues({
cfg: ctx.cfg,
env: process.env,
})
).map(sessionSnapshotIssueToRepairEffect);
if (ctx.dryRun === true) {
return { status: "repaired", changes: [], effects };
}
return {
status: "skipped",
reason: "legacy doctor session snapshot contribution owns snapshot rewrites",
changes: [],
effects,
};
},
},
run: runSessionSnapshotsHealth,
}),
createDoctorHealthContribution({

View File

@@ -456,6 +456,34 @@ describe("channel-health-monitor", () => {
monitor.stop();
});
it("continues pending recovery on the next check without waiting for cooldown", async () => {
const account: Partial<ChannelAccountSnapshot> = disconnectedAccount(Date.now() - 300_000);
const manager = createSnapshotManager(
{
discord: {
default: account,
},
},
{
startChannel: vi.fn(async () => {
account.running = false;
account.connected = false;
account.restartPending = true;
account.reconnectAttempts = 0;
}),
},
);
const monitor = await startAndRunCheck(manager);
expect(manager.stopChannel).toHaveBeenCalledTimes(1);
expect(manager.startChannel).toHaveBeenCalledTimes(1);
await advanceHealthCheck();
expect(manager.stopChannel).toHaveBeenCalledTimes(1);
expect(manager.startChannel).toHaveBeenCalledTimes(2);
monitor.stop();
});
it("caps at 3 health-monitor restarts per channel per hour", async () => {
const manager = createSnapshotManager({
discord: {

View File

@@ -145,12 +145,20 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
restartsThisHour: [],
};
if (now - record.lastRestartAt <= cooldownMs) {
const continuingPendingRestart =
status.running !== true &&
status.restartPending === true &&
(status.reconnectAttempts ?? 0) === 0;
// A timed-out recovery stop uses the first start request to mark
// restartPending; the next monitor pass must finish that same recovery
// instead of waiting behind this monitor's fresh-restart cooldown.
if (!continuingPendingRestart && now - record.lastRestartAt <= cooldownMs) {
continue;
}
pruneOldRestarts(record, now);
if (record.restartsThisHour.length >= maxRestartsPerHour) {
if (!continuingPendingRestart && record.restartsThisHour.length >= maxRestartsPerHour) {
log.warn?.(
`[${channelId}:${accountId}] health-monitor: hit ${maxRestartsPerHour} restarts/hour limit, skipping`,
);
@@ -161,9 +169,11 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
log.info?.(`[${channelId}:${accountId}] health-monitor: restarting (reason: ${reason})`);
record.lastRestartAt = now;
record.restartsThisHour.push({ at: now });
restartRecords.set(key, record);
if (!continuingPendingRestart) {
record.lastRestartAt = now;
record.restartsThisHour.push({ at: now });
restartRecords.set(key, record);
}
try {
if (status.running) {

View File

@@ -511,6 +511,82 @@ describe("server-channels auto restart", () => {
expect(account?.lastError).toContain("channel stop timed out");
});
it("resumes startup on the second recovery pass while the stale task is still pending", async () => {
const startAccount = vi.fn(async ({ abortSignal }: { abortSignal: AbortSignal }) => {
abortSignal.addEventListener("abort", () => {}, { once: true });
await new Promise<void>(() => {});
});
installTestRegistry(
createTestPlugin({
startAccount,
}),
);
const manager = createManager();
await manager.startChannels();
const recoveryStopTask = manager.stopChannel("discord", DEFAULT_ACCOUNT_ID, {
manual: false,
});
await vi.advanceTimersByTimeAsync(5_000);
await recoveryStopTask;
await manager.startChannel("discord", DEFAULT_ACCOUNT_ID);
let account = manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
expect(startAccount).toHaveBeenCalledTimes(1);
expect(account?.running).toBe(false);
expect(account?.restartPending).toBe(true);
await manager.startChannel("discord", DEFAULT_ACCOUNT_ID);
account = manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
expect(startAccount).toHaveBeenCalledTimes(2);
expect(account?.running).toBe(true);
expect(account?.restartPending).toBe(false);
expect(account?.reconnectAttempts).toBe(0);
expect(account?.lastError).toBeNull();
});
it("keeps the second recovery task running when the stale task rejects", async () => {
const releaseFirstTask = createDeferred();
let startCount = 0;
const startAccount = vi.fn(async ({ abortSignal }: { abortSignal: AbortSignal }) => {
startCount += 1;
abortSignal.addEventListener("abort", () => {}, { once: true });
if (startCount === 1) {
await releaseFirstTask.promise;
throw new Error("late stale worker exit");
}
await new Promise<void>(() => {});
});
installTestRegistry(
createTestPlugin({
startAccount,
}),
);
const manager = createManager();
await manager.startChannels();
const recoveryStopTask = manager.stopChannel("discord", DEFAULT_ACCOUNT_ID, {
manual: false,
});
await vi.advanceTimersByTimeAsync(5_000);
await recoveryStopTask;
await manager.startChannel("discord", DEFAULT_ACCOUNT_ID);
await manager.startChannel("discord", DEFAULT_ACCOUNT_ID);
expect(startAccount).toHaveBeenCalledTimes(2);
releaseFirstTask.resolve();
await flushMicrotasks();
const account = manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
expect(startAccount).toHaveBeenCalledTimes(2);
expect(account?.running).toBe(true);
expect(account?.restartPending).toBe(false);
expect(account?.lastError).toBeNull();
expect(hoisted.sleepWithAbort).not.toHaveBeenCalled();
});
it("restarts immediately when recovery stop timeout settles with an error", async () => {
const rejectFirstTask = createDeferred();
let startCount = 0;

View File

@@ -449,6 +449,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
tasks: accountIds.map((id) => async () => {
const rKey = restartKey(channelId, id);
if (store.tasks.has(id)) {
let clearedTimedOutRecoveryTask = false;
if (recoveryStopTimedOut.has(rKey)) {
if (!preserveManualStop) {
manuallyStopped.delete(rKey);
@@ -456,10 +457,30 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
if (manuallyStopped.has(rKey)) {
return;
}
recoveryStartRequested.add(rKey);
setRuntime(channelId, id, { accountId: id, restartPending: true });
// When a previous stop timed out and the health monitor is
// requesting recovery again, clean up the stuck task so the
// channel can actually restart instead of staying in limbo.
if (recoveryStartRequested.has(rKey)) {
recoveryStopTimedOut.delete(rKey);
recoveryStartRequested.delete(rKey);
restartAttempts.delete(rKey);
store.aborts.delete(id);
store.tasks.delete(id);
clearedTimedOutRecoveryTask = true;
setRuntime(channelId, id, {
accountId: id,
restartPending: false,
reconnectAttempts: 0,
});
} else {
recoveryStartRequested.add(rKey);
setRuntime(channelId, id, { accountId: id, restartPending: true });
return;
}
}
if (!clearedTimedOutRecoveryTask) {
return;
}
return;
}
const existingStart = store.starting.get(id);
if (existingStart) {
@@ -607,7 +628,10 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
abortSignal: abort.signal,
log,
getStatus: () => getRuntime(channelId, id),
setStatus: (next) => setRuntimeFromTaskStatus(channelId, id, next, abort.signal),
setStatus: (next) =>
isCurrentTask()
? setRuntimeFromTaskStatus(channelId, id, next, abort.signal)
: getRuntime(channelId, id),
...(channelRuntimeForTask ? { channelRuntime: channelRuntimeForTask } : {}),
});
const routeRegistry = getPluginHttpRouteRegistry?.();
@@ -620,9 +644,11 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
}
await startAccountTask;
});
// Recovery can replace a timed-out task before the old promise settles.
// Only the task that still owns the store slot may write lifecycle state.
const trackedPromise = task
.then(() => {
if (abort.signal.aborted || manuallyStopped.has(rKey)) {
if (abort.signal.aborted || manuallyStopped.has(rKey) || !isCurrentTask()) {
return;
}
const message = "channel exited without an error";
@@ -630,17 +656,26 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
log.error?.(`[${id}] ${message}`);
})
.catch((err: unknown) => {
if (!isCurrentTask()) {
return;
}
const message = formatErrorMessage(err);
setRuntime(channelId, id, { accountId: id, lastError: message });
log.error?.(`[${id}] channel exited: ${message}`);
})
.then(async () => {
await cleanupTaskScopedApprovalRuntime("channel cleanup failed");
if (!isCurrentTask()) {
return;
}
setStoppedRuntime(channelId, id, {
lastStopAt: Date.now(),
});
})
.then(async () => {
if (!isCurrentTask()) {
return;
}
if (manuallyStopped.has(rKey)) {
recoveryStopTimedOut.delete(rKey);
recoveryStartRequested.delete(rKey);
@@ -731,6 +766,9 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
store.aborts.delete(id);
}
});
function isCurrentTask() {
return store.tasks.get(id) === trackedPromise;
}
handedOffTask = true;
store.tasks.set(id, trackedPromise);
} catch (error) {

View File

@@ -285,7 +285,7 @@ describe("buildGatewayCronService", () => {
});
});
it("requests a safe gateway restart when isolated cron setup times out", async () => {
it("backs off isolated cron setup timeout without gateway restart", async () => {
vi.useFakeTimers();
const cfg = createCronConfig("server-cron-isolated-setup-timeout");
loadConfigMock.mockReturnValue(cfg);
@@ -315,12 +315,7 @@ describe("buildGatewayCronService", () => {
const runResult = await runPromise;
expect(runResult).toEqual({ ok: true, ran: true });
expect(requestSafeGatewayRestartMock).toHaveBeenCalledTimes(1);
expect(requestSafeGatewayRestartMock).toHaveBeenCalledWith({
reason: "cron.isolated_agent_setup_timeout",
delayMs: 0,
preservePendingEmitHooks: true,
});
expect(requestSafeGatewayRestartMock).not.toHaveBeenCalled();
} finally {
state.cron.stop();
vi.useRealTimers();

View File

@@ -32,7 +32,6 @@ import { formatErrorMessage } from "../infra/errors.js";
import { resolveMainScopedEventSessionKey } from "../infra/event-session-routing.js";
import { runHeartbeatOnce } from "../infra/heartbeat-runner.js";
import { requestHeartbeat } from "../infra/heartbeat-wake.js";
import { requestSafeGatewayRestart } from "../infra/restart-coordinator.js";
import {
consumeSelectedSystemEventEntries,
enqueueSystemEventEntry,
@@ -547,23 +546,14 @@ export function buildGatewayCronService(params: {
}).catch(() => {});
},
onIsolatedAgentSetupTimeout: ({ job, error, timeoutMs }) => {
const restart = requestSafeGatewayRestart({
reason: "cron.isolated_agent_setup_timeout",
delayMs: 0,
preservePendingEmitHooks: true,
});
cronLogger.warn(
{
jobId: job.id,
jobName: job.name,
timeoutMs,
error,
restartStatus: restart.status,
restartCoalesced: restart.restart.coalesced,
restartSummary: restart.preflight.summary,
restartDelayMs: restart.restart.delayMs,
},
"cron: isolated agent setup timed out before runner start; requested safe gateway restart",
"cron: isolated agent setup timed out before runner start; backing off job without gateway restart",
);
},
sendCronFailureAlert: async ({ job, text, channel, to, mode, accountId }) =>

View File

@@ -2300,7 +2300,7 @@ export const agentHandlers: GatewayRequestHandlers = {
let resolvedTo = deliveryPlan.resolvedTo;
let effectivePlan = deliveryPlan;
let deliveryDowngradeReason: string | null = null;
let deliveryTargetResolutionError: Error | undefined;
let deliveryTargetResolutionError: Error | undefined = deliveryPlan.targetResolutionError;
if (wantsDelivery && resolvedChannel === INTERNAL_MESSAGE_CHANNEL) {
const cfgResolved = cfgForAgent ?? cfg;
@@ -2328,6 +2328,27 @@ export const agentHandlers: GatewayRequestHandlers = {
}
}
if (wantsDelivery && deliveryTargetResolutionError) {
if (!bestEffortDeliver) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, String(deliveryTargetResolutionError)),
);
return;
}
deliveryDowngradeReason = String(deliveryTargetResolutionError);
resolvedChannel = INTERNAL_MESSAGE_CHANNEL;
deliveryTargetMode = undefined;
resolvedTo = undefined;
effectivePlan = {
...deliveryPlan,
resolvedChannel,
resolvedTo,
deliveryTargetMode,
};
}
if (!resolvedTo && isDeliverableMessageChannel(resolvedChannel)) {
const cfgResolved = cfgForAgent ?? cfg;
const fallback = resolveAgentOutboundTarget({

View File

@@ -923,6 +923,37 @@ describe("gateway send mirroring", () => {
expect(response?.[2]?.message).toContain("Use `chat.send`");
});
it("accepts bundled channels before plugin registry normalization for message actions", async () => {
const { respond } = await runMessageActionRequest({
channel: "TELEGRAM",
action: "send",
params: { target: "123", message: "hi" },
idempotencyKey: "idem-telegram-message-action",
});
const call = lastDispatchChannelMessageActionCall();
expect(call?.channel).toBe("telegram");
expect(firstRespondCall(respond)[0]).toBe(true);
});
it("rejects unknown send channels without delivering", async () => {
mocks.getChannelPlugin.mockReturnValue(undefined);
const { respond } = await runSend({
to: "x",
message: "hi",
channel: "definitely-not-a-real-channel-xyz",
idempotencyKey: "idem-unknown-channel",
});
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
const response = firstRespondCall(respond);
expect(response?.[0]).toBe(false);
expect(response?.[2]?.message).toContain(
"unsupported channel: definitely-not-a-real-channel-xyz",
);
});
it("auto-picks the single configured channel for send", async () => {
mockDeliverySuccess("m-single-send");

View File

@@ -15,7 +15,6 @@ import {
} from "../../../packages/gateway-protocol/src/index.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { sendDurableMessageBatch } from "../../channels/message/runtime.js";
import { normalizeChannelId } from "../../channels/plugins/index.js";
import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js";
import { createOutboundSendDeps } from "../../cli/deps.js";
import {
@@ -49,6 +48,7 @@ import {
normalizeSessionKeyPreservingOpaquePeerIds,
parseThreadSessionSuffix,
} from "../../sessions/session-key-utils.js";
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
import { ADMIN_SCOPE } from "../operator-scopes.js";
import { resolveGatewayPluginConfig } from "../runtime-plugin-config.js";
import { formatForLog } from "../ws-log.js";
@@ -177,17 +177,16 @@ async function resolveRequestedChannel(params: {
}
> {
const channelInput = readStringValue(params.requestChannel);
const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null;
const normalizedChannel = channelInput ? normalizeMessageChannel(channelInput) : undefined;
if (params.rejectWebchatAsInternalOnly && normalizedChannel === INTERNAL_MESSAGE_CHANNEL) {
return {
error: errorShape(
ErrorCodes.INVALID_REQUEST,
"unsupported channel: webchat (internal-only). Use `chat.send` for WebChat UI messages or choose a deliverable channel.",
),
};
}
if (channelInput && !normalizedChannel) {
const normalizedInput = normalizeOptionalLowercaseString(channelInput) ?? "";
if (params.rejectWebchatAsInternalOnly && normalizedInput === "webchat") {
return {
error: errorShape(
ErrorCodes.INVALID_REQUEST,
"unsupported channel: webchat (internal-only). Use `chat.send` for WebChat UI messages or choose a deliverable channel.",
),
};
}
return {
error: errorShape(ErrorCodes.INVALID_REQUEST, params.unsupportedMessage(channelInput)),
};

View File

@@ -5,8 +5,34 @@ export function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
// Transcript readers repeatedly extract a fixed set of metadata fields from
// oversized JSONL prefixes. Keep the compiled regexes process-local instead of
// rebuilding them for every field on every oversized record.
const TRANSCRIPT_FIELD_REGEX_CACHE = new Map<
string,
{ stringRe: RegExp; nullRe: RegExp; numberRe: RegExp }
>();
function getTranscriptFieldRegexes(field: string): {
stringRe: RegExp;
nullRe: RegExp;
numberRe: RegExp;
} {
let cached = TRANSCRIPT_FIELD_REGEX_CACHE.get(field);
if (!cached) {
const escapedField = escapeRegExp(field);
cached = {
stringRe: new RegExp(`"${escapedField}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`),
nullRe: new RegExp(`"${escapedField}"\\s*:\\s*null`),
numberRe: new RegExp(`"${escapedField}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)`),
};
TRANSCRIPT_FIELD_REGEX_CACHE.set(field, cached);
}
return cached;
}
export function extractJsonStringFieldPrefix(prefix: string, field: string): string | undefined {
const match = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(prefix);
const match = getTranscriptFieldRegexes(field).stringRe.exec(prefix);
if (!match) {
return undefined;
}
@@ -22,16 +48,14 @@ export function extractJsonNullableStringFieldPrefix(
prefix: string,
field: string,
): string | null | undefined {
if (new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*null`).test(prefix)) {
if (getTranscriptFieldRegexes(field).nullRe.test(prefix)) {
return null;
}
return extractJsonStringFieldPrefix(prefix, field);
}
export function extractJsonNumberFieldPrefix(prefix: string, field: string): number | undefined {
const match = new RegExp(
`"${escapeRegExp(field)}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)`,
).exec(prefix);
const match = getTranscriptFieldRegexes(field).numberRe.exec(prefix);
if (!match) {
return undefined;
}

View File

@@ -1,5 +1,6 @@
// Tests OpenClaw execution environment construction.
import { describe, expect, it } from "vitest";
import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import {
ensureOpenClawExecMarkerOnProcess,
markOpenClawExecEnv,
@@ -38,16 +39,16 @@ describe("ensureOpenClawExecMarkerOnProcess", () => {
it("defaults to mutating process.env when no env object is provided", () => {
const previous = process.env[OPENCLAW_CLI_ENV_VAR];
delete process.env[OPENCLAW_CLI_ENV_VAR];
deleteTestEnvValue(OPENCLAW_CLI_ENV_VAR);
try {
expect(ensureOpenClawExecMarkerOnProcess()).toBe(process.env);
expect(process.env[OPENCLAW_CLI_ENV_VAR]).toBe(OPENCLAW_CLI_ENV_VALUE);
} finally {
if (previous === undefined) {
delete process.env[OPENCLAW_CLI_ENV_VAR];
deleteTestEnvValue(OPENCLAW_CLI_ENV_VAR);
} else {
process.env[OPENCLAW_CLI_ENV_VAR] = previous;
setTestEnvValue(OPENCLAW_CLI_ENV_VAR, previous);
}
}
});

View File

@@ -4,6 +4,15 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
resolveOutboundChannelPlugin: vi.fn<() => unknown>(() => null),
resolveChannelTarget: vi.fn<() => Promise<unknown>>(async () => ({
ok: true,
target: {
to: "+1999",
kind: "group",
source: "normalized",
resolutionSource: "normalized",
},
})),
resolveOutboundTarget: vi.fn<() => { ok: true; to: string } | { ok: false; error: Error }>(
() => ({ ok: true, to: "+1999" }),
),
@@ -82,11 +91,16 @@ vi.mock("./outbound-session.js", () => ({
resolveOutboundSessionRoute: mocks.resolveOutboundSessionRoute,
}));
vi.mock("./target-resolver.js", () => ({
resolveChannelTarget: mocks.resolveChannelTarget,
}));
vi.mock("../../utils/message-channel.js", () => ({
INTERNAL_MESSAGE_CHANNEL: "webchat",
isDeliverableMessageChannel: (channel: string) => ["directchat", "workspace"].includes(channel),
isDeliverableMessageChannel: (channel: string) =>
["directchat", "workspace", "telegram"].includes(channel),
isGatewayMessageChannel: (channel: string) =>
["directchat", "workspace", "webchat"].includes(channel),
["directchat", "workspace", "telegram", "webchat"].includes(channel),
normalizeMessageChannel: (value: string) => value.trim().toLowerCase(),
}));
@@ -106,7 +120,18 @@ beforeAll(async () => {
beforeEach(() => {
mocks.resolveOutboundChannelPlugin.mockReset();
mocks.resolveOutboundChannelPlugin.mockReturnValue(null);
mocks.resolveOutboundTarget.mockClear();
mocks.resolveChannelTarget.mockReset();
mocks.resolveChannelTarget.mockResolvedValue({
ok: true,
target: {
to: "+1999",
kind: "group",
source: "normalized",
resolutionSource: "normalized",
},
});
mocks.resolveOutboundTarget.mockReset();
mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "+1999" });
mocks.resolveOutboundSessionRoute.mockReset();
mocks.resolveOutboundSessionRoute.mockResolvedValue(null);
mocks.resolveSessionDeliveryTarget.mockClear();
@@ -313,6 +338,181 @@ describe("agent delivery helpers", () => {
expect(plan.resolvedTo).toBe("1470130713209602050");
});
it("resolves reserved explicit targets through directory-capable resolution before session routing", async () => {
mocks.resolveOutboundChannelPlugin.mockReturnValue({
messaging: { resolveOutboundSessionRoute: vi.fn(), targetResolver: {} },
});
mocks.resolveOutboundTarget.mockReturnValueOnce({
ok: false,
error: new Error('Reserved target "current" for Telegram'),
});
mocks.resolveChannelTarget.mockResolvedValueOnce({
ok: true,
target: {
to: "telegram:-1002458651455",
kind: "group",
source: "directory",
resolutionSource: "directory",
},
});
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
sessionKey: "agent:telegram:group:-1002458651455",
baseSessionKey: "agent:telegram:group:-1002458651455",
peer: { kind: "group", id: "-1002458651455" },
chatType: "group",
from: "telegram:group:-1002458651455",
to: "telegram:-1002458651455",
});
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
cfg: {} as OpenClawConfig,
agentId: "agent",
currentSessionKey: "agent:main",
sessionEntry: undefined,
requestedChannel: "telegram",
explicitTo: "current",
accountId: "work",
wantsDelivery: true,
});
expect(mocks.resolveChannelTarget).toHaveBeenCalledWith({
cfg: {},
channel: "telegram",
input: "current",
accountId: "work",
unknownTargetMode: "normalized",
plugin: {
messaging: { resolveOutboundSessionRoute: expect.any(Function), targetResolver: {} },
},
});
expect(mocks.resolveOutboundSessionRoute).toHaveBeenCalledWith({
cfg: {},
channel: "telegram",
agentId: "agent",
accountId: "work",
target: "telegram:-1002458651455",
resolvedTarget: {
to: "telegram:-1002458651455",
kind: "group",
source: "directory",
resolutionSource: "directory",
},
currentSessionKey: "agent:main",
threadId: undefined,
});
expect(plan.resolvedTo).toBe("telegram:-1002458651455");
expect(plan.targetResolutionError).toBeUndefined();
});
it("keeps reserved explicit target errors when directory-capable resolution misses", async () => {
const reservedError = new Error('Reserved target "current" for Telegram');
mocks.resolveOutboundChannelPlugin.mockReturnValue({
messaging: { resolveOutboundSessionRoute: vi.fn(), targetResolver: {} },
});
mocks.resolveOutboundTarget.mockReturnValueOnce({
ok: false,
error: reservedError,
});
mocks.resolveChannelTarget.mockResolvedValueOnce({
ok: false,
error: reservedError,
});
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
cfg: {} as OpenClawConfig,
agentId: "agent",
sessionEntry: undefined,
requestedChannel: "telegram",
explicitTo: "current",
accountId: undefined,
wantsDelivery: true,
});
expect(mocks.resolveChannelTarget).toHaveBeenCalledWith({
cfg: {},
channel: "telegram",
input: "current",
accountId: undefined,
unknownTargetMode: "normalized",
plugin: {
messaging: { resolveOutboundSessionRoute: expect.any(Function), targetResolver: {} },
},
});
expect(mocks.resolveOutboundSessionRoute).not.toHaveBeenCalled();
expect(plan.resolvedTo).toBe("current");
expect(plan.targetResolutionError).toBe(reservedError);
});
it("keeps directory-resolved reserved explicit targets when session-route canonicalization misses", async () => {
mocks.resolveOutboundChannelPlugin.mockReturnValue({
messaging: { resolveOutboundSessionRoute: vi.fn(), targetResolver: {} },
});
mocks.resolveOutboundTarget.mockReturnValueOnce({
ok: false,
error: new Error('Reserved target "current" for Telegram'),
});
mocks.resolveChannelTarget.mockResolvedValueOnce({
ok: true,
target: {
to: "telegram:-1002458651455",
kind: "group",
source: "directory",
resolutionSource: "directory",
},
});
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce(null);
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
cfg: {} as OpenClawConfig,
agentId: "agent",
currentSessionKey: "agent:main",
sessionEntry: undefined,
requestedChannel: "telegram",
explicitTo: "current",
accountId: "work",
wantsDelivery: true,
});
expect(mocks.resolveOutboundSessionRoute).toHaveBeenCalledWith({
cfg: {},
channel: "telegram",
agentId: "agent",
accountId: "work",
target: "telegram:-1002458651455",
resolvedTarget: {
to: "telegram:-1002458651455",
kind: "group",
source: "directory",
resolutionSource: "directory",
},
currentSessionKey: "agent:main",
threadId: undefined,
});
expect(plan.resolvedTo).toBe("telegram:-1002458651455");
expect(plan.targetResolutionError).toBeUndefined();
});
it("surfaces stored explicit target errors even when explicit validation is disabled", () => {
const targetResolutionError = new Error('reserved target "current"');
const resolved = resolveAgentOutboundTarget({
cfg: {} as OpenClawConfig,
plan: {
baseDelivery: { mode: "explicit" },
resolvedChannel: "workspace",
resolvedTo: "current",
deliveryTargetMode: "explicit",
targetResolutionError,
},
targetMode: "explicit",
validateExplicitTarget: false,
});
expect(mocks.resolveOutboundTarget).not.toHaveBeenCalled();
expect(resolved.resolvedTarget).toEqual({ ok: false, error: targetResolutionError });
expect(resolved.resolvedTo).toBeUndefined();
});
it("falls back to the original plan when session-route canonicalization fails", async () => {
mocks.resolveOutboundChannelPlugin.mockReturnValue({
messaging: { resolveOutboundSessionRoute: vi.fn() },

View File

@@ -15,6 +15,8 @@ import {
} from "../../utils/message-channel.js";
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
import { resolveOutboundSessionRoute } from "./outbound-session.js";
import { isReservedTargetLiteralError } from "./target-errors.js";
import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js";
import type { OutboundTargetResolution } from "./targets.js";
import {
resolveOutboundTarget,
@@ -29,6 +31,7 @@ export type AgentDeliveryPlan = {
resolvedAccountId?: string;
resolvedThreadId?: string | number;
deliveryTargetMode?: ChannelOutboundTargetMode;
targetResolutionError?: Error;
};
export function resolveAgentDeliveryPlan(params: {
@@ -143,27 +146,46 @@ export async function resolveAgentDeliveryPlanWithSessionRoute(
},
): Promise<AgentDeliveryPlan> {
const plan = resolveAgentDeliveryPlan(params);
if (
!params.wantsDelivery ||
!plan.resolvedTo ||
!isDeliverableMessageChannel(plan.resolvedChannel) ||
!resolveOutboundChannelPlugin({
channel: plan.resolvedChannel,
cfg: params.cfg,
allowBootstrap: true,
})?.messaging?.resolveOutboundSessionRoute
) {
const { resolvedChannel, resolvedTo } = plan;
if (!params.wantsDelivery || !resolvedTo || !isDeliverableMessageChannel(resolvedChannel)) {
return plan;
}
const plugin = resolveOutboundChannelPlugin({
channel: resolvedChannel,
cfg: params.cfg,
allowBootstrap: true,
});
if (!plugin?.messaging?.resolveOutboundSessionRoute) {
return plan;
}
const normalizedTarget = resolveOutboundTarget({
channel: plan.resolvedChannel,
to: plan.resolvedTo,
channel: resolvedChannel,
to: resolvedTo,
cfg: params.cfg,
accountId: plan.resolvedAccountId,
mode: plan.deliveryTargetMode ?? "explicit",
});
if (!normalizedTarget.ok) {
return plan;
let sessionRouteTarget: string;
let resolvedSessionRouteTarget: ResolvedMessagingTarget | undefined;
if (normalizedTarget.ok) {
sessionRouteTarget = normalizedTarget.to;
} else {
if (!isReservedTargetLiteralError(normalizedTarget.error)) {
return { ...plan, targetResolutionError: normalizedTarget.error };
}
const resolvedTarget = await resolveChannelTarget({
cfg: params.cfg,
channel: resolvedChannel as ChannelId,
input: resolvedTo,
accountId: plan.resolvedAccountId,
unknownTargetMode: "normalized",
plugin,
});
if (!resolvedTarget.ok) {
return { ...plan, targetResolutionError: resolvedTarget.error };
}
sessionRouteTarget = resolvedTarget.target.to;
resolvedSessionRouteTarget = resolvedTarget.target;
}
const explicitThreadId =
params.explicitThreadId != null && params.explicitThreadId !== ""
@@ -173,10 +195,11 @@ export async function resolveAgentDeliveryPlanWithSessionRoute(
try {
return await resolveOutboundSessionRoute({
cfg: params.cfg,
channel: plan.resolvedChannel as ChannelId,
channel: resolvedChannel as ChannelId,
agentId: params.agentId,
accountId: plan.resolvedAccountId,
target: normalizedTarget.to,
target: sessionRouteTarget,
...(resolvedSessionRouteTarget ? { resolvedTarget: resolvedSessionRouteTarget } : {}),
currentSessionKey: params.currentSessionKey,
threadId: plan.deliveryTargetMode === "explicit" ? explicitThreadId : plan.resolvedThreadId,
});
@@ -185,6 +208,14 @@ export async function resolveAgentDeliveryPlanWithSessionRoute(
}
})();
if (!route) {
if (resolvedSessionRouteTarget) {
return {
...plan,
resolvedTo: resolvedSessionRouteTarget.to,
resolvedThreadId:
plan.deliveryTargetMode === "explicit" ? explicitThreadId : plan.resolvedThreadId,
};
}
return plan;
}
return {
@@ -210,6 +241,13 @@ export function resolveAgentOutboundTarget(params: {
params.targetMode ??
params.plan.deliveryTargetMode ??
(params.plan.resolvedTo ? "explicit" : "implicit");
if (params.plan.targetResolutionError) {
return {
resolvedTarget: { ok: false, error: params.plan.targetResolutionError },
resolvedTo: undefined,
targetMode,
};
}
if (!isDeliverableMessageChannel(params.plan.resolvedChannel)) {
return {
resolvedTarget: null,

View File

@@ -0,0 +1,109 @@
import { describe, expect, it, vi, beforeAll, beforeEach } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { drainPendingDeliveries, type DeliverFn, loadPendingDeliveries } from "./delivery-queue.js";
import {
createRecoveryLog,
installDeliveryQueueTmpDirHooks,
} from "./delivery-queue.test-helpers.js";
let deliverOutboundPayloads: typeof import("./deliver.js").deliverOutboundPayloads;
async function drainMatrixReconnect(opts: { deliver: DeliverFn; stateDir: string }): Promise<void> {
await drainPendingDeliveries({
drainKey: "matrix:reconnect-test",
logLabel: "Matrix reconnect drain",
cfg: {} as OpenClawConfig,
log: createRecoveryLog(),
stateDir: opts.stateDir,
deliver: opts.deliver,
selectEntry: (entry) => ({ match: entry.channel === "matrix" }),
});
}
function createPartialSendFailure() {
return vi
.fn()
.mockResolvedValueOnce({ messageId: "m1" })
.mockRejectedValueOnce(new Error("second payload send failed"));
}
async function deliverPartialMatrixBatch(sendMatrix: ReturnType<typeof vi.fn>, tmpDir: string) {
process.env.OPENCLAW_STATE_DIR = tmpDir;
await expect(
deliverOutboundPayloads({
cfg: {} as OpenClawConfig,
channel: "matrix",
to: "!room:example",
payloads: [{ text: "first" }, { text: "second" }],
deps: { matrix: sendMatrix },
queuePolicy: "required",
}),
).rejects.toThrow("second payload send failed");
}
describe("deliverOutboundPayloads queue integration: mid-batch failure with send evidence", () => {
const fixtures = installDeliveryQueueTmpDirHooks();
let tmpDir: string;
beforeAll(async () => {
({ deliverOutboundPayloads } = await import("./deliver.js"));
});
beforeEach(() => {
tmpDir = fixtures.tmpDir();
});
it("advances queued entry to unknown_after_send when a later payload fails after an earlier one succeeded", async () => {
const sendMatrix = createPartialSendFailure();
await deliverPartialMatrixBatch(sendMatrix, tmpDir);
const entries = await loadPendingDeliveries(tmpDir);
expect(entries).toHaveLength(1);
const entry = entries[0];
expect(entry.recoveryState).toBe("unknown_after_send");
expect(entry.retryCount).toBe(0);
expect(entry.lastError).toBeUndefined();
expect(sendMatrix).toHaveBeenCalledTimes(2);
});
it("drain does not replay an unknown_after_send entry when no adapter reconciliation is available", async () => {
const sendMatrix = createPartialSendFailure();
await deliverPartialMatrixBatch(sendMatrix, tmpDir);
const beforeDrain = await loadPendingDeliveries(tmpDir);
expect(beforeDrain[0]?.recoveryState).toBe("unknown_after_send");
const deliver = vi.fn<DeliverFn>(async () => {});
await drainMatrixReconnect({ deliver, stateDir: tmpDir });
expect(deliver).not.toHaveBeenCalled();
expect(await loadPendingDeliveries(tmpDir)).toHaveLength(0);
});
it("leaves entry for retry in send_attempt_started when no send evidence exists", async () => {
process.env.OPENCLAW_STATE_DIR = tmpDir;
const sendMatrix = vi.fn().mockRejectedValueOnce(new Error("first payload send failed"));
await expect(
deliverOutboundPayloads({
cfg: {} as OpenClawConfig,
channel: "matrix",
to: "!room:example",
payloads: [{ text: "first" }],
deps: { matrix: sendMatrix },
queuePolicy: "required",
}),
).rejects.toThrow("first payload send failed");
const entries = await import("./delivery-queue.js").then((m) =>
m.loadPendingDeliveries(tmpDir),
);
expect(entries).toHaveLength(1);
const entry = entries[0];
expect(entry.retryCount).toBe(1);
expect(entry.recoveryState).toBe("send_attempt_started");
expect(entry.lastError).toContain("first payload send failed");
});
});

View File

@@ -1045,6 +1045,51 @@ describe("deliverOutboundPayloads", () => {
expect(queueMocks.ackDelivery).not.toHaveBeenCalled();
});
it("marks queued delivery as unknown-after-send (not failed) when a later payload fails after an earlier one succeeded", async () => {
const sendMatrix = vi
.fn()
.mockResolvedValueOnce({ messageId: "m1" })
.mockRejectedValueOnce(new Error("second payload send failed"));
await expect(
deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room:example",
payloads: [{ text: "first" }, { text: "second" }],
deps: { matrix: sendMatrix },
queuePolicy: "required",
}),
).rejects.toThrow("second payload send failed");
expect(sendMatrix).toHaveBeenCalledTimes(2);
expect(queueMocks.markDeliveryPlatformOutcomeUnknown).toHaveBeenCalledWith("mock-queue-id");
expect(queueMocks.failDelivery).not.toHaveBeenCalled();
expect(queueMocks.ackDelivery).not.toHaveBeenCalled();
});
it("still calls failDelivery when a payload fails before any send succeeded", async () => {
const sendMatrix = vi.fn().mockRejectedValueOnce(new Error("first payload send failed"));
await expect(
deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room:example",
payloads: [{ text: "first" }],
deps: { matrix: sendMatrix },
queuePolicy: "required",
}),
).rejects.toThrow("first payload send failed");
expect(queueMocks.failDelivery).toHaveBeenCalledWith(
"mock-queue-id",
expect.stringContaining("first payload send failed"),
);
expect(queueMocks.markDeliveryPlatformOutcomeUnknown).not.toHaveBeenCalled();
expect(queueMocks.ackDelivery).not.toHaveBeenCalled();
});
it("fails required delivery when the post-send unknown marker cannot be written", async () => {
queueMocks.markDeliveryPlatformOutcomeUnknown.mockRejectedValueOnce(
new Error("unknown marker offline"),

View File

@@ -1326,9 +1326,9 @@ async function deliverOutboundPayloadsWithQueueCleanup(
};
const queuePolicy = params.queuePolicy ?? "best_effort";
let platformResultsReturned = false;
let platformSendStarted = false;
try {
let platformSendStarted = false;
const results = await deliverOutboundPayloadsCore({
...wrappedParams,
...(queueId
@@ -1390,11 +1390,29 @@ async function deliverOutboundPayloadsWithQueueCleanup(
if (isDeliveryAbortError(err)) {
await ackDelivery(queueId).catch(() => {});
} else if (!platformResultsReturned) {
await failDelivery(queueId, formatErrorMessage(err)).catch((failErr: unknown) => {
log.warn(
`failed to mark queued delivery ${queueId} as failed: ${formatErrorMessage(failErr)}`,
);
});
const sendEvidence =
platformSendStarted && err instanceof OutboundDeliveryError && err.sentBeforeError;
if (sendEvidence) {
await markQueuedPlatformOutcomeUnknown({
queueId,
queuePolicy,
}).catch((markErr: unknown) => {
log.warn(
`failed to mark queued delivery ${queueId} as platform-outcome-unknown after mid-send error; falling back to fail: ${formatErrorMessage(markErr)}`,
);
return failDelivery(queueId, formatErrorMessage(err)).catch((failErr: unknown) => {
log.warn(
`failed to mark queued delivery ${queueId} as failed: ${formatErrorMessage(failErr)}`,
);
});
});
} else {
await failDelivery(queueId, formatErrorMessage(err)).catch((failErr: unknown) => {
log.warn(
`failed to mark queued delivery ${queueId} as failed: ${formatErrorMessage(failErr)}`,
);
});
}
}
}
throw err;

View File

@@ -389,15 +389,19 @@ async function drainQueuedEntry(opts: {
return "failed";
}
}
if (reconciliation?.status === "not_sent") {
const reconciliationProvedPreSendFailure =
reconciliation?.status === "not_sent" && entry.recoveryState === "send_attempt_started";
if (reconciliationProvedPreSendFailure) {
opts.log.info(
`Delivery entry ${entry.id} reconciled ${entry.recoveryState} as not sent; replaying`,
);
} else {
const errMsg =
reconciliation?.status === "unresolved" && reconciliation.error
? `delivery state is ${entry.recoveryState} and reconciliation is unresolved: ${reconciliation.error}`
: `delivery state is ${entry.recoveryState}; refusing blind replay without adapter reconciliation`;
let errMsg = `delivery state is ${entry.recoveryState}; refusing blind replay without adapter reconciliation`;
if (reconciliation?.status === "not_sent") {
errMsg = `delivery state is ${entry.recoveryState}; refusing full replay after post-send evidence`;
} else if (reconciliation?.status === "unresolved" && reconciliation.error) {
errMsg = `delivery state is ${entry.recoveryState} and reconciliation is unresolved: ${reconciliation.error}`;
}
opts.log.warn(`Delivery entry ${entry.id} ${errMsg}`);
opts.onFailed?.(entry, errMsg);
if (reconciliation?.status === "unresolved" && reconciliation.retryable === true) {

View File

@@ -328,7 +328,7 @@ describe("delivery-queue recovery", () => {
expect(await loadPendingDeliveries(tmpDir())).toHaveLength(0);
});
it("replays unknown-after-send entries only after adapter proves they were not sent", async () => {
it("moves unknown-after-send entries to failed when adapter reports not sent", async () => {
const id = await enqueueDelivery(
{ channel: "demo-channel-a", to: "+1", payloads: [{ text: "not sent" }] },
tmpDir(),
@@ -346,24 +346,19 @@ describe("delivery-queue recovery", () => {
});
const deliver = vi.fn().mockResolvedValue([]);
const { result } = await runRecovery({ deliver });
const log = createRecoveryLog();
const { result } = await runRecovery({ deliver, log });
expect(deliver).toHaveBeenCalledTimes(1);
const deliverInput = mockCallArg(deliver) as {
channel?: string;
to?: string;
skipQueue?: boolean;
};
expect(deliverInput.channel).toBe("demo-channel-a");
expect(deliverInput.to).toBe("+1");
expect(deliverInput.skipQueue).toBe(true);
expect(deliver).not.toHaveBeenCalled();
expect(result).toEqual({
recovered: 1,
failed: 0,
recovered: 0,
failed: 1,
skippedMaxRetries: 0,
deferredBackoff: 0,
});
expect(await loadPendingDeliveries(tmpDir())).toHaveLength(0);
expect(readOutboundQueueStatus(tmpDir(), id)).toBe("failed");
expectMockMessageContaining(log.warn, "refusing full replay after post-send evidence");
});
it("keeps retryable unresolved unknown-after-send entries on the queue without replaying", async () => {

View File

@@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest";
import {
ambiguousTargetError,
ambiguousTargetMessage,
isReservedTargetLiteralError,
missingTargetError,
missingTargetMessage,
reservedTargetLiteralError,
unknownTargetError,
unknownTargetMessage,
} from "./target-errors.js";
@@ -69,4 +71,13 @@ describe("target error helpers", () => {
"Hint: Use channel:123",
);
});
it("identifies reserved target literal errors", () => {
expect(isReservedTargetLiteralError(reservedTargetLiteralError("Telegram", "current"))).toBe(
true,
);
expect(isReservedTargetLiteralError(new Error('Unknown target "current" for Telegram.'))).toBe(
false,
);
});
});

View File

@@ -40,6 +40,18 @@ export function unknownTargetError(provider: string, raw: string, hint?: string)
return new Error(unknownTargetMessage(provider, raw, hint));
}
export function reservedTargetLiteralMessage(provider: string, raw: string, hint?: string): string {
return `Reserved target "${raw}" for ${provider} cannot be used as a literal destination. Provide an explicit id or handle.${formatTargetHint(hint, true)}`;
}
export function reservedTargetLiteralError(provider: string, raw: string, hint?: string): Error {
return new Error(reservedTargetLiteralMessage(provider, raw, hint));
}
export function isReservedTargetLiteralError(error: Error): boolean {
return error.message.includes("Reserved target");
}
function formatTargetHint(hint?: string, withLabel = false): string {
const normalized = hint?.trim();
if (!normalized) {

View File

@@ -32,6 +32,52 @@ function resolveChannelPluginForTargetRead(channelId: ChannelId): ChannelPlugin
return getLoadedChannelPluginForRead(channelId) ?? getChannelPlugin(channelId);
}
function normalizeTargetLiteral(value: string): string | undefined {
return normalizeOptionalLowercaseString(value);
}
function stripPluginTargetPrefix(raw: string, plugin: ChannelPlugin): string {
let target = raw.trim();
const prefixes = [plugin.id, ...(plugin.messaging?.targetPrefixes ?? [])]
.map((prefix) => normalizeTargetLiteral(String(prefix)))
.filter((prefix): prefix is string => Boolean(prefix));
while (target) {
const lowered = normalizeTargetLiteral(target) ?? "";
const prefix = prefixes.find((candidate) => lowered.startsWith(`${candidate}:`));
if (!prefix) {
return target;
}
target = target.slice(prefix.length + 1).trim();
}
return target;
}
export function resolveReservedTargetLiteral(params: {
raw?: string;
plugin?: ChannelPlugin;
}): string | undefined {
const raw = normalizeOptionalString(params.raw);
const plugin = params.plugin;
const reservedLiterals = plugin?.messaging?.targetResolver?.reservedLiterals;
if (!raw || !plugin || !reservedLiterals?.length) {
return undefined;
}
const stripped = stripPluginTargetPrefix(raw, plugin);
if (!stripped || /^[@#]/.test(stripped) || /^(channel|group|user):/i.test(stripped)) {
return undefined;
}
const normalized = normalizeTargetLiteral(stripped);
if (!normalized) {
return undefined;
}
const reserved = new Set(
reservedLiterals
.map(normalizeTargetLiteral)
.filter((literal): literal is string => Boolean(literal)),
);
return reserved.has(normalized) ? normalized : undefined;
}
function resetTargetNormalizerCacheForTests(): void {
targetNormalizerCacheByChannelId.clear();
}
@@ -224,10 +270,15 @@ export function buildTargetResolverSignature(
: "pinned";
const resolver = plugin?.messaging?.targetResolver;
const hint = resolver?.hint ?? "";
const reserved = (resolver?.reservedLiterals ?? [])
.map(normalizeTargetLiteral)
.filter((literal): literal is string => Boolean(literal))
.toSorted()
.join(",");
const looksLike = resolver?.looksLikeId;
// Function source is only a cheap invalidation hint; resolver behavior still belongs to the plugin.
const source = looksLike ? looksLike.toString() : "";
return hashSignature(`${registryScope}|${hint}|${source}`);
return hashSignature(`${registryScope}|${hint}|${reserved}|${source}`);
}
function hashSignature(value: string): string {

View File

@@ -130,6 +130,206 @@ describe("resolveMessagingTarget (directory fallback)", () => {
expect(mocks.listGroupsLive).toHaveBeenCalledTimes(1);
});
it("preserves configured directory entries before rejecting reserved literal targets", async () => {
mocks.getChannelPlugin.mockReturnValue({
...createChannelTestPluginBase({
id: "telegram",
label: "Telegram",
capabilities: { chatTypes: ["direct", "group", "channel"] },
}),
directory: {
listPeers: mocks.listPeers,
listPeersLive: mocks.listPeersLive,
listGroups: mocks.listGroups,
listGroupsLive: mocks.listGroupsLive,
},
messaging: {
targetResolver: {
reservedLiterals: ["current", "self", "this", "me"],
hint: "<chatId>",
resolveTarget: mocks.resolveTarget,
},
},
});
mocks.listGroups.mockResolvedValue([
{
kind: "group",
id: "-1002458651455",
name: "Current x jerry Channel",
handle: "@current",
} satisfies ChannelDirectoryEntry,
]);
const result = await resolveMessagingTarget({
cfg,
channel: "telegram",
input: "current",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.target.to).toBe("-1002458651455");
expect(result.target.source).toBe("directory");
}
expect(mocks.listGroups).toHaveBeenCalled();
expect(mocks.resolveTarget).not.toHaveBeenCalled();
});
it("keeps reserved literals on the directory path before id-like plugin normalization", async () => {
mocks.getChannelPlugin.mockReturnValue({
...createChannelTestPluginBase({
id: "telegram",
label: "Telegram",
capabilities: { chatTypes: ["direct", "group", "channel"] },
}),
directory: {
listPeers: mocks.listPeers,
listPeersLive: mocks.listPeersLive,
listGroups: mocks.listGroups,
listGroupsLive: mocks.listGroupsLive,
},
messaging: {
normalizeTarget: (raw: string) =>
raw === "current" || raw === "telegram:current" ? "telegram:@current" : raw,
targetResolver: {
looksLikeId: (raw: string) => raw === "current" || raw === "telegram:current",
reservedLiterals: ["current", "self", "this", "me"],
hint: "<chatId>",
resolveTarget: mocks.resolveTarget,
},
},
});
mocks.listGroups.mockResolvedValueOnce([
{ kind: "group", id: "room-1", name: "current" } satisfies ChannelDirectoryEntry,
]);
const hit = await resolveMessagingTarget({
cfg,
channel: "telegram",
input: "current",
});
expect(hit.ok).toBe(true);
if (hit.ok) {
expect(hit.target.to).toBe("room-1");
expect(hit.target.source).toBe("directory");
}
expect(mocks.resolveTarget).not.toHaveBeenCalled();
resetDirectoryCache();
mocks.listGroups.mockResolvedValueOnce([
{ kind: "group", id: "room-1", name: "current" } satisfies ChannelDirectoryEntry,
]);
const prefixedHit = await resolveMessagingTarget({
cfg,
channel: "telegram",
input: "telegram:current",
});
expect(prefixedHit.ok).toBe(true);
if (prefixedHit.ok) {
expect(prefixedHit.target.to).toBe("room-1");
expect(prefixedHit.target.source).toBe("directory");
}
resetDirectoryCache();
mocks.listGroups.mockResolvedValueOnce([]);
mocks.listGroupsLive.mockResolvedValueOnce([]);
const miss = await resolveMessagingTarget({
cfg,
channel: "telegram",
input: "current",
});
expect(miss.ok).toBe(false);
if (!miss.ok) {
expect(miss.error.message).toContain('Reserved target "current"');
expect(miss.error.message).toContain("Telegram");
}
expect(mocks.resolveTarget).not.toHaveBeenCalled();
});
it("rejects reserved literal targets after directory miss", async () => {
mocks.getChannelPlugin.mockReturnValue({
...createChannelTestPluginBase({
id: "telegram",
label: "Telegram",
capabilities: { chatTypes: ["direct", "group", "channel"] },
}),
directory: {
listPeers: mocks.listPeers,
listPeersLive: mocks.listPeersLive,
listGroups: mocks.listGroups,
listGroupsLive: mocks.listGroupsLive,
},
messaging: {
targetResolver: {
reservedLiterals: ["current", "self", "this", "me"],
hint: "<chatId>",
resolveTarget: mocks.resolveTarget,
},
},
});
mocks.listGroups.mockResolvedValue([]);
mocks.listGroupsLive.mockResolvedValue([]);
const result = await resolveMessagingTarget({
cfg,
channel: "telegram",
input: "current",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain('Reserved target "current"');
expect(result.error.message).toContain("Telegram");
}
expect(mocks.listGroups).toHaveBeenCalled();
expect(mocks.resolveTarget).not.toHaveBeenCalled();
});
it("requires exact directory matches before preserving reserved literal targets", async () => {
mocks.getChannelPlugin.mockReturnValue({
...createChannelTestPluginBase({
id: "telegram",
label: "Telegram",
capabilities: { chatTypes: ["direct", "group", "channel"] },
}),
directory: {
listPeers: mocks.listPeers,
listPeersLive: mocks.listPeersLive,
listGroups: mocks.listGroups,
listGroupsLive: mocks.listGroupsLive,
},
messaging: {
targetResolver: {
reservedLiterals: ["current", "self", "this", "me"],
hint: "<chatId>",
resolveTarget: mocks.resolveTarget,
},
},
});
mocks.listGroups.mockResolvedValue([
{ kind: "group", id: "memes-room", name: "memes" } satisfies ChannelDirectoryEntry,
]);
mocks.listGroupsLive.mockResolvedValue([]);
const result = await resolveMessagingTarget({
cfg,
channel: "telegram",
input: "me",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain('Reserved target "me"');
expect(result.error.message).toContain("Telegram");
}
expect(mocks.resolveTarget).not.toHaveBeenCalled();
});
it("does not reuse directory cache entries across prepared plugin runtimes", async () => {
const firstListGroups = vi
.fn()

View File

@@ -11,7 +11,11 @@ import type {
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { buildDirectoryCacheKey, DirectoryCache } from "./directory-cache.js";
import { ambiguousTargetError, unknownTargetError } from "./target-errors.js";
import {
ambiguousTargetError,
reservedTargetLiteralError,
unknownTargetError,
} from "./target-errors.js";
import { maybeResolveIdLikeTarget, type ResolvedIdLikeTarget } from "./target-id-resolution.js";
import {
buildTargetResolverSignature,
@@ -20,6 +24,7 @@ import {
normalizeChannelTargetInput,
normalizeTargetForProvider,
resolveNormalizedTargetInput,
resolveReservedTargetLiteral,
} from "./target-normalization.js";
/** Directory-backed destination kind used by outbound target resolution. */
@@ -91,9 +96,21 @@ function normalizeQuery(value: string): string {
return normalizeLowercaseStringOrEmpty(value);
}
function stripTargetPrefixes(value: string): string {
return value
.replace(/^(channel|user):/i, "")
function stripTargetPrefixes(value: string, channel?: ChannelId, plugin?: ChannelPlugin): string {
const providerPrefixes = [channel, plugin?.id, ...(plugin?.messaging?.targetPrefixes ?? [])]
.map((prefix) => prefix?.trim().toLowerCase() ?? "")
.filter(Boolean);
let target = value.trim();
while (target) {
const lowered = target.toLowerCase();
const prefix = providerPrefixes.find((candidate) => lowered.startsWith(`${candidate}:`));
if (!prefix) {
break;
}
target = target.slice(prefix.length + 1).trim();
}
return target
.replace(/^(channel|group|user):/i, "")
.replace(/^[@#]/, "")
.trim();
}
@@ -210,6 +227,7 @@ function matchesDirectoryEntry(params: {
entry: ChannelDirectoryEntry;
query: string;
plugin?: ChannelPlugin;
exactOnly?: boolean;
}): boolean {
const query = normalizeQuery(params.query);
if (!query) {
@@ -217,11 +235,19 @@ function matchesDirectoryEntry(params: {
}
const id = stripTargetPrefixes(
normalizeDirectoryEntryId(params.channel, params.entry, params.plugin),
params.channel,
params.plugin,
);
const name = params.entry.name ? stripTargetPrefixes(params.entry.name) : "";
const handle = params.entry.handle ? stripTargetPrefixes(params.entry.handle) : "";
const name = params.entry.name
? stripTargetPrefixes(params.entry.name, params.channel, params.plugin)
: "";
const handle = params.entry.handle
? stripTargetPrefixes(params.entry.handle, params.channel, params.plugin)
: "";
const candidates = [id, name, handle].map((value) => normalizeQuery(value)).filter(Boolean);
return candidates.some((value) => value === query || value.includes(query));
return candidates.some((value) =>
params.exactOnly ? value === query : value === query || value.includes(query),
);
}
function resolveMatch(params: {
@@ -229,6 +255,7 @@ function resolveMatch(params: {
entries: ChannelDirectoryEntry[];
query: string;
plugin?: ChannelPlugin;
exactOnly?: boolean;
}) {
const matches = params.entries.filter((entry) =>
matchesDirectoryEntry({
@@ -236,6 +263,7 @@ function resolveMatch(params: {
entry,
query: params.query,
plugin: params.plugin,
exactOnly: params.exactOnly,
}),
);
if (matches.length === 0) {
@@ -398,8 +426,10 @@ export async function resolveMessagingTarget(params: {
const kind = detectTargetKind(params.channel, raw, params.preferredKind, plugin);
const normalizedInput = resolveNormalizedTargetInput(params.channel, raw, plugin);
const normalized = normalizedInput?.normalized ?? raw;
const reservedLiteral = resolveReservedTargetLiteral({ raw, plugin });
if (
normalizedInput &&
!reservedLiteral &&
looksLikeTargetId({
channel: params.channel,
raw: normalizedInput.raw,
@@ -426,7 +456,7 @@ export async function resolveMessagingTarget(params: {
kind,
});
}
const query = stripTargetPrefixes(raw);
const query = stripTargetPrefixes(raw, params.channel, plugin);
const entries = await getDirectoryEntries({
cfg: params.cfg,
channel: params.channel,
@@ -437,7 +467,13 @@ export async function resolveMessagingTarget(params: {
preferLiveOnMiss: true,
plugin,
});
const match = resolveMatch({ channel: params.channel, entries, query, plugin });
const match = resolveMatch({
channel: params.channel,
entries,
query,
plugin,
exactOnly: Boolean(reservedLiteral),
});
if (match.kind === "single") {
const entry = match.entry;
return {
@@ -445,7 +481,8 @@ export async function resolveMessagingTarget(params: {
target: {
to: normalizeDirectoryEntryId(params.channel, entry, plugin),
kind,
display: entry.name ?? entry.handle ?? stripTargetPrefixes(entry.id),
display:
entry.name ?? entry.handle ?? stripTargetPrefixes(entry.id, params.channel, plugin),
source: "directory",
resolutionSource: "directory",
},
@@ -461,7 +498,8 @@ export async function resolveMessagingTarget(params: {
target: {
to: normalizeDirectoryEntryId(params.channel, best, plugin),
kind,
display: best.name ?? best.handle ?? stripTargetPrefixes(best.id),
display:
best.name ?? best.handle ?? stripTargetPrefixes(best.id, params.channel, plugin),
source: "directory",
resolutionSource: "directory",
},
@@ -474,6 +512,10 @@ export async function resolveMessagingTarget(params: {
candidates: match.entries,
};
}
// Directory misses are the fail-closed boundary for reserved literals.
if (reservedLiteral) {
return { ok: false, error: reservedTargetLiteralError(providerLabel, reservedLiteral, hint) };
}
const resolvedFallbackTarget = asResolvedMessagingTarget(
await maybeResolvePluginMessagingTarget({
cfg: params.cfg,

View File

@@ -8,7 +8,8 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel-constants.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import { validateTargetProviderPrefix } from "./channel-target-prefix.js";
import { missingTargetError } from "./target-errors.js";
import { missingTargetError, reservedTargetLiteralError } from "./target-errors.js";
import { resolveReservedTargetLiteral } from "./target-normalization.js";
/**
* Result of resolving a concrete outbound target for a channel send.
@@ -79,6 +80,21 @@ export function resolveOutboundTargetWithPlugin(params: {
if (targetPrefixError) {
return { ok: false, error: targetPrefixError };
}
const hint = plugin.messaging?.targetResolver?.hint;
// Heartbeats defer reserved literals to the async resolver so directory hits can win.
if (params.target.mode !== "heartbeat") {
const reservedLiteral = resolveReservedTargetLiteral({ raw: effectiveTo, plugin });
if (reservedLiteral) {
return {
ok: false,
error: reservedTargetLiteralError(
plugin.meta.label ?? params.target.channel,
reservedLiteral,
hint,
),
};
}
}
const resolveTarget = plugin.outbound?.resolveTarget;
if (resolveTarget) {
@@ -94,7 +110,6 @@ export function resolveOutboundTargetWithPlugin(params: {
if (effectiveTo) {
return { ok: true, to: effectiveTo };
}
const hint = plugin.messaging?.targetResolver?.hint;
return {
ok: false,
error: missingTargetError(plugin.meta.label ?? params.target.channel, hint),

View File

@@ -100,6 +100,73 @@ export function runResolveOutboundTargetCoreTests(): void {
}
});
it.each(["current", "telegram:current", "tg:self"])(
"rejects plugin-reserved literal target %s before direct outbound fallback",
(to) => {
setActivePluginRegistry(
createTargetsTestRegistry([
createTestChannelPlugin({
id: "telegram",
label: "Telegram",
outbound: {
deliveryMode: "direct",
sendText: async () => ({ channel: "telegram", messageId: "telegram-msg" }),
},
messaging: {
targetPrefixes: ["telegram", "tg"],
targetResolver: {
reservedLiterals: ["current", "self", "this", "me"],
hint: "<chatId>",
},
},
}),
]),
);
const res = resolveOutboundTarget({
channel: "telegram",
to,
mode: "explicit",
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.message).toContain("Reserved target");
expect(res.error.message).toContain("Telegram");
}
},
);
it("allows explicit handles that include the provider handle marker", () => {
setActivePluginRegistry(
createTargetsTestRegistry([
createTestChannelPlugin({
id: "telegram",
label: "Telegram",
outbound: {
deliveryMode: "direct",
sendText: async () => ({ channel: "telegram", messageId: "telegram-msg" }),
},
messaging: {
targetPrefixes: ["telegram", "tg"],
targetResolver: {
reservedLiterals: ["current", "self", "this", "me"],
hint: "<chatId>",
},
},
}),
]),
);
const res = resolveOutboundTarget({
channel: "telegram",
to: "telegram:@current",
mode: "explicit",
});
expect(res).toEqual({ ok: true, to: "telegram:@current" });
});
it("uses the plugin hint when a channel has outbound support but no target resolver", () => {
setActivePluginRegistry(
createTargetsTestRegistry([

View File

@@ -1194,6 +1194,117 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.reason).toBe("dm-blocked");
});
it("resolves heartbeat reserved targets through directory before session routing", async () => {
const listGroups = vi
.fn()
.mockResolvedValue([{ kind: "group", id: "-1002458651455", name: "current" }]);
const listGroupsLive = vi.fn().mockResolvedValue([]);
setActivePluginRegistry(
createTargetsTestRegistry([
{
...createTestChannelPlugin({
id: "telegram",
label: "Telegram",
outbound: {
deliveryMode: "direct",
resolveTarget: ({ to }) =>
to
? { ok: true as const, to: to.trim() }
: { ok: false as const, error: new Error("target required") },
},
messaging: {
targetPrefixes: ["telegram", "tg"],
targetResolver: {
reservedLiterals: ["current", "self", "this", "me"],
hint: "<chatId>",
},
resolveOutboundSessionRoute: ({ target, resolvedTarget }) => ({
sessionKey: `main:telegram:group:${target}`,
baseSessionKey: `main:telegram:group:${target}`,
peer: { kind: resolvedTarget?.kind === "user" ? "direct" : "group", id: target },
chatType: resolvedTarget?.kind === "user" ? "direct" : "group",
from: `telegram:group:${target}`,
to: target,
}),
},
}),
directory: {
listGroups,
listGroupsLive,
},
},
]),
);
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: {},
agentId: "main",
heartbeat: {
target: "telegram",
to: "current",
},
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("-1002458651455");
expect(listGroups).toHaveBeenCalled();
});
it("fails closed when a heartbeat reserved target misses the directory", async () => {
const listGroups = vi.fn().mockResolvedValue([]);
const listGroupsLive = vi.fn().mockResolvedValue([]);
setActivePluginRegistry(
createTargetsTestRegistry([
{
...createTestChannelPlugin({
id: "telegram",
label: "Telegram",
outbound: {
deliveryMode: "direct",
resolveTarget: ({ to }) =>
to
? { ok: true as const, to: to.trim() }
: { ok: false as const, error: new Error("target required") },
},
messaging: {
targetPrefixes: ["telegram", "tg"],
targetResolver: {
reservedLiterals: ["current", "self", "this", "me"],
hint: "<chatId>",
},
resolveOutboundSessionRoute: ({ target }) => ({
sessionKey: `main:telegram:group:${target}`,
baseSessionKey: `main:telegram:group:${target}`,
peer: { kind: "group", id: target },
chatType: "group",
from: `telegram:group:${target}`,
to: target,
}),
},
}),
directory: {
listGroups,
listGroupsLive,
},
},
]),
);
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: {},
agentId: "main",
heartbeat: {
target: "telegram",
to: "current",
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("no-target");
expect(listGroups).toHaveBeenCalled();
expect(listGroupsLive).toHaveBeenCalled();
});
it("keeps heartbeat route canonicalization best-effort when target resolution fails", async () => {
setActivePluginRegistry(
createTargetsTestRegistry([

View File

@@ -27,6 +27,7 @@ import {
resolveOutboundChannelPlugin,
} from "./channel-resolution.js";
import { resolveOutboundSessionRoute } from "./outbound-session.js";
import { isReservedTargetLiteralError } from "./target-errors.js";
import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js";
import {
resolveOutboundTargetWithPlugin,
@@ -361,6 +362,13 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: {
})();
if (targetResolution?.ok) {
routeResolvedTarget = targetResolution.target;
} else if (targetResolution && isReservedTargetLiteralError(targetResolution.error)) {
return buildNoHeartbeatDeliveryTarget({
reason: "no-target",
accountId: delivery.accountId,
lastChannel: delivery.lastChannel,
lastAccountId: delivery.lastAccountId,
});
}
if (routeResolvedTarget?.kind === "user" && heartbeat?.directPolicy === "block") {
return buildNoHeartbeatDeliveryTarget({

View File

@@ -573,6 +573,31 @@ describe("loadWebMedia", () => {
expect(result.fileName).toBe("fake.png");
});
it("strips internal media-store UUID suffix from outbound fileName", async () => {
const stagedName = "report---a1b2c3d4-5678-90ab-cdef-1234567890ab.png";
const mediaDir = path.join(stateDir, "media", "outbound");
const stagedFile = path.join(mediaDir, stagedName);
await fs.mkdir(mediaDir, { recursive: true });
await fs.writeFile(stagedFile, Buffer.from(TINY_PNG_BASE64, "base64"));
const result = await loadWebMedia(stagedFile, {
maxBytes: 1024 * 1024,
localRoots: [mediaDir],
});
expect(result.fileName).toBe("report.png");
});
it("preserves non-media-store filenames that match the UUID suffix shape", async () => {
const fileName = "report---a1b2c3d4-5678-90ab-cdef-1234567890ab.png";
const filePath = path.join(fixtureRoot, fileName);
await fs.writeFile(filePath, Buffer.from(TINY_PNG_BASE64, "base64"));
const result = await loadWebMedia(filePath, createLocalWebMediaOptions());
expect(result.fileName).toBe(fileName);
});
it("uses only the leaf filename from Windows-style sandbox-validated media paths", async () => {
const result = await loadWebMedia(String.raw`C:\workspace\captures\tiny.png`, {
maxBytes: 1024 * 1024,

View File

@@ -33,6 +33,7 @@ import {
readImageMetadataFromHeader,
readImageProbeFromHeader,
} from "./media-services.js";
import { extractOriginalFilename, getMediaDir } from "./store.js";
export { getDefaultLocalRoots, LocalMediaAccessError };
export type { LocalMediaAccessErrorCode };
@@ -284,6 +285,13 @@ function isPathInsideRoot(filePath: string | undefined, root: string): boolean {
);
}
function resolveLocalMediaFileName(filePath: string): string | undefined {
const fileName = basenameFromAnyPath(filePath) || undefined;
return fileName && isPathInsideRoot(filePath, getMediaDir())
? extractOriginalFilename(fileName)
: fileName;
}
function hasHtmlDocumentShape(text: string): boolean {
const sample = text.trimStart().slice(0, 8192);
return /^(?:<!doctype\s+html\b|<html\b)/iu.test(sample) || /<\/(?:html|body)>/iu.test(sample);
@@ -1074,7 +1082,7 @@ async function loadWebMediaInternal(
trustedGeneratedHtmlPath,
});
}
let fileName = basenameFromAnyPath(mediaUrl) || undefined;
let fileName = resolveLocalMediaFileName(mediaUrl);
if (fileName && !extnameFromAnyPath(fileName) && mime) {
const ext = extensionForMime(mime);
if (ext) {

View File

@@ -6,7 +6,7 @@ import {
readPersistedInstalledPluginIndex,
writePersistedInstalledPluginIndex,
} from "./installed-plugin-index-store.js";
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
import type { InstalledPluginIndex, InstalledPluginIndexRecord } from "./installed-plugin-index.js";
import {
loadPluginManifestRegistryForInstalledIndex,
resolveInstalledManifestRegistryIndexFingerprint,
@@ -151,6 +151,24 @@ function createIndexWithPackageJson(rootDir: string): InstalledPluginIndex {
};
}
function createIndexWithUnhashedPackageJson(rootDir: string): InstalledPluginIndex {
const index = createIndexWithFileSignatures(rootDir);
const packageJsonPath = writePackageManifest(rootDir, "Installed");
const record = index.plugins[0];
if (!record) {
throw new Error("expected index record");
}
record.packageJson = {
path: "package.json",
hash: "",
fileSignature: fileSignature(packageJsonPath),
};
return {
...index,
plugins: [record],
};
}
describe("loadPluginManifestRegistryForInstalledIndex", () => {
it("reuses frozen installed-index fingerprints when file signatures are persisted", () => {
const rootDir = makeTempDir();
@@ -180,6 +198,97 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => {
expect(second).not.toBe(first);
});
it("reuses package realpaths across mutable installed-index fingerprint builds", () => {
const rootDir = makeTempDir();
writePlugin(rootDir, "installed", "installed-");
const index = createIndexWithUnhashedPackageJson(rootDir);
const packageJsonPath = path.join(fs.realpathSync(rootDir), "package.json");
const realpathSpy = vi.spyOn(fs, "realpathSync");
let rootPathCalls: unknown[][];
let packageJsonPathCalls: unknown[][];
try {
resolveInstalledManifestRegistryIndexFingerprint(index);
resolveInstalledManifestRegistryIndexFingerprint(index);
rootPathCalls = realpathSpy.mock.calls.filter(([filePath]) => filePath === rootDir);
packageJsonPathCalls = realpathSpy.mock.calls.filter(
([filePath]) => filePath === packageJsonPath,
);
} finally {
realpathSpy.mockRestore();
}
expect(rootPathCalls).toHaveLength(1);
expect(packageJsonPathCalls).toHaveLength(1);
});
it("clears package realpath memoization with plugin metadata lifecycle caches", () => {
const rootDir = makeTempDir();
writePlugin(rootDir, "installed", "installed-");
const index = createIndexWithUnhashedPackageJson(rootDir);
const packageJsonPath = path.join(fs.realpathSync(rootDir), "package.json");
const realpathSpy = vi.spyOn(fs, "realpathSync");
let rootPathCalls: unknown[][];
let packageJsonPathCalls: unknown[][];
try {
resolveInstalledManifestRegistryIndexFingerprint(index);
clearPluginMetadataLifecycleCaches();
resolveInstalledManifestRegistryIndexFingerprint(index);
rootPathCalls = realpathSpy.mock.calls.filter(([filePath]) => filePath === rootDir);
packageJsonPathCalls = realpathSpy.mock.calls.filter(
([filePath]) => filePath === packageJsonPath,
);
} finally {
realpathSpy.mockRestore();
}
expect(rootPathCalls).toHaveLength(2);
expect(packageJsonPathCalls).toHaveLength(2);
});
it("bounds package realpath memoization across many fingerprint roots", () => {
const firstRootDir = makeTempDir();
writePlugin(firstRootDir, "installed", "installed-");
const firstIndex = createIndexWithUnhashedPackageJson(firstRootDir);
resolveInstalledManifestRegistryIndexFingerprint(firstIndex);
const records: InstalledPluginIndexRecord[] = [];
for (let index = 0; index < 300; index += 1) {
const rootDir = makeTempDir();
const pluginId = `installed-${index}`;
writePlugin(rootDir, pluginId, `${pluginId}-`);
const record = createIndexWithUnhashedPackageJson(rootDir).plugins[0];
if (!record) {
throw new Error("expected index record");
}
records.push({
...record,
pluginId,
manifestHash: `manifest-hash-${index}`,
});
}
resolveInstalledManifestRegistryIndexFingerprint({
...firstIndex,
plugins: records,
});
const packageJsonPath = path.join(fs.realpathSync(firstRootDir), "package.json");
const realpathSpy = vi.spyOn(fs, "realpathSync");
let rootPathCalls: unknown[][];
let packageJsonPathCalls: unknown[][];
try {
resolveInstalledManifestRegistryIndexFingerprint(firstIndex);
rootPathCalls = realpathSpy.mock.calls.filter(([filePath]) => filePath === firstRootDir);
packageJsonPathCalls = realpathSpy.mock.calls.filter(
([filePath]) => filePath === packageJsonPath,
);
} finally {
realpathSpy.mockRestore();
}
expect(rootPathCalls).toHaveLength(1);
expect(packageJsonPathCalls).toHaveLength(1);
});
it("does not cache shallow-frozen installed-index fingerprints with mutable nested records", () => {
const rootDir = makeTempDir();
writePlugin(rootDir, "installed", "installed-");

View File

@@ -32,8 +32,12 @@ import {
const installedManifestRegistryIndexFingerprintCache = new WeakMap<InstalledPluginIndex, string>();
const installedPackageJsonPathCache = new Map<string, string | null>();
const installedPackageMetadataCache = new Map<string, InstalledPackageMetadata>();
// Installed plugin metadata is process-stable between explicit lifecycle clears.
// Share realpaths across fingerprint builds to avoid repeated package boundary IO.
const installedManifestRegistryRealpathCache = new Map<string, string>();
const MAX_INSTALLED_PACKAGE_JSON_PATH_CACHE_ENTRIES = 256;
const MAX_INSTALLED_PACKAGE_METADATA_CACHE_ENTRIES = 256;
const MAX_INSTALLED_MANIFEST_REGISTRY_REALPATH_CACHE_ENTRIES = 512;
type InstalledPackageMetadata = {
packageManifest?: OpenClawPackageManifest;
@@ -44,6 +48,7 @@ type InstalledPackageMetadata = {
export function clearInstalledManifestRegistryProcessCaches(): void {
installedPackageJsonPathCache.clear();
installedPackageMetadataCache.clear();
installedManifestRegistryRealpathCache.clear();
}
registerPluginMetadataProcessMemoLifecycleClear(clearInstalledManifestRegistryProcessCaches);
@@ -169,6 +174,19 @@ function rememberInstalledPackageJsonPath(
return packageJsonPath;
}
function trimInstalledManifestRegistryRealpathCache(): void {
while (
installedManifestRegistryRealpathCache.size >
MAX_INSTALLED_MANIFEST_REGISTRY_REALPATH_CACHE_ENTRIES
) {
const oldest = installedManifestRegistryRealpathCache.keys().next().value;
if (oldest === undefined) {
break;
}
installedManifestRegistryRealpathCache.delete(oldest);
}
}
function buildInstalledPackageJsonPathCacheKey(
record: InstalledPluginIndexRecord,
): string | undefined {
@@ -196,7 +214,6 @@ function buildInstalledPackageMetadataCacheKey(params: {
}
function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) {
const realpathCache = new Map<string, string>();
return {
version: index.version,
hostContractVersion: index.hostContractVersion,
@@ -206,7 +223,11 @@ function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) {
installRecords: index.installRecords,
diagnostics: index.diagnostics,
plugins: index.plugins.map((record) => {
const packageJsonPath = resolvePackageJsonPath(record, realpathCache);
const packageJsonPath = resolvePackageJsonPath(
record,
installedManifestRegistryRealpathCache,
);
trimInstalledManifestRegistryRealpathCache();
const packageJsonFile = record.packageJson?.fileSignature
? packageJsonPath
? formatFileSignature(packageJsonPath, record.packageJson.fileSignature)

View File

@@ -593,11 +593,17 @@ export function buildStatusMessage(args: StatusArgs): string {
});
const selectedProvider = entry?.providerOverride ?? resolved.provider ?? DEFAULT_PROVIDER;
const selectedModel = entry?.modelOverride ?? resolved.model ?? DEFAULT_MODEL;
const parseSelectedProvider = Boolean(
entry?.modelOverride?.trim() && !entry?.providerOverride?.trim(),
);
const modelRefs = resolveSelectedAndActiveModel({
selectedProvider,
selectedModel,
sessionEntry: entry,
parseSelectedProvider,
});
const selectedLookupProvider = modelRefs.selected.provider || selectedProvider;
const selectedLookupModel = modelRefs.selected.model || selectedModel;
const initialFallbackState = resolveActiveFallbackState({
selectedModelRef: modelRefs.selected.label || "unknown",
activeModelRef: modelRefs.active.label || "unknown",
@@ -718,8 +724,8 @@ export function buildStatusMessage(args: StatusArgs): string {
const runtimeDiffersFromSelected = activeModelLabel !== (modelRefs.selected.label || "unknown");
const selectedContextTokens = resolveContextTokensForModel({
cfg: contextConfig,
provider: selectedProvider,
model: selectedModel,
provider: selectedLookupProvider,
model: selectedLookupModel,
allowAsyncLoad: false,
});
const explicitRuntimeContextTokens =
@@ -740,8 +746,8 @@ export function buildStatusMessage(args: StatusArgs): string {
const channelModelNote = resolveChannelModelNote({
config: args.config,
entry,
selectedProvider,
selectedModel,
selectedProvider: selectedLookupProvider,
selectedModel: selectedLookupModel,
parentSessionKey: args.parentSessionKey,
});
const persistedContextTokens =
@@ -1007,7 +1013,7 @@ export function buildStatusMessage(args: StatusArgs): string {
{ config: args.config },
);
const selectedAuthMode =
normalizeAuthMode(args.modelAuth) ?? resolveModelAuthMode(selectedProvider, args.config);
normalizeAuthMode(args.modelAuth) ?? resolveModelAuthMode(selectedLookupProvider, args.config);
const rawSelectedAuthLabelValue =
selectedAuthMode && selectedAuthMode !== "unknown"
? (args.modelAuth ?? selectedAuthMode)

View File

@@ -49,6 +49,7 @@ import {
formatTaskStatusDetail,
formatTaskStatusTitle,
} from "../tasks/task-status.js";
import { resolveActiveFallbackState } from "./fallback-notice-state.js";
import { formatCompactPluginHealthLine } from "./status-plugin-health.js";
import type { BuildStatusTextParams } from "./status-text.types.js";
@@ -352,27 +353,35 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
params.workspaceDir ??
sessionEntry?.spawnedWorkspaceDir ??
resolveAgentWorkspaceDir(cfg, statusAgentId);
const selectedProvider = sessionEntry?.providerOverride?.trim() ?? provider;
const selectedModel = sessionEntry?.modelOverride?.trim() ?? model;
const parseSelectedProvider = Boolean(
sessionEntry?.modelOverride?.trim() && !sessionEntry?.providerOverride?.trim(),
);
const modelRefs = resolveSelectedAndActiveModel({
selectedProvider: provider,
selectedModel: model,
selectedProvider,
selectedModel,
sessionEntry,
parseSelectedProvider,
});
const selectedLookupProvider = modelRefs.selected.provider || selectedProvider || provider;
const selectedLookupModel = modelRefs.selected.model || selectedModel || model;
const effectiveHarness =
params.resolvedHarness ??
(await resolveStatusHarnessId({
cfg,
provider,
model,
provider: selectedLookupProvider,
model: selectedLookupModel,
agentId: statusAgentId,
sessionKey,
sessionEntry,
}));
const selectedStatusProvider = resolveStatusRuntimeProvider({
provider,
provider: selectedLookupProvider,
effectiveHarness,
});
const selectedAuthProviders = listOpenAIAuthProfileProvidersForAgentRuntime({
provider,
provider: selectedLookupProvider,
harnessRuntime: effectiveHarness,
config: cfg,
});
@@ -415,6 +424,12 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
modelRefs.active.label,
{ config: cfg },
);
const fallbackState = resolveActiveFallbackState({
selectedModelRef: modelRefs.selected.label || "unknown",
activeModelRef: modelRefs.active.label || "unknown",
config: cfg,
state: sessionEntry,
});
if (
shouldPreferActiveRuntimeAliasAuthLabel({
runtimeAliasModelEquivalent,
@@ -426,11 +441,20 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
// labels differ; prefer the active auth label so status matches execution.
selectedModelAuth = activeModelAuth;
}
const usageAuthLabel = modelRefs.activeDiffers ? activeModelAuth : selectedModelAuth;
const activeRuntimeIsAuthoritative =
!modelRefs.activeDiffers ||
fallbackState.active ||
hasSessionAutoModelFallbackProvenance(sessionEntry) ||
runtimeAliasModelEquivalent;
const usageAuthLabel = activeRuntimeIsAuthoritative ? activeModelAuth : selectedModelAuth;
const usageStatusProvider = activeRuntimeIsAuthoritative
? activeStatusProvider
: selectedStatusProvider;
const usageProvider = activeRuntimeIsAuthoritative ? activeProvider : selectedLookupProvider;
const selectedUsageCredentialType = resolveUsageCredentialType(usageAuthLabel);
const useCodexSyntheticUsage =
shouldUseCodexSyntheticUsage({
provider: activeStatusProvider,
provider: usageStatusProvider,
effectiveHarness,
}) &&
(selectedUsageCredentialType === "oauth" || selectedUsageCredentialType === "token");
@@ -443,8 +467,8 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
: undefined;
const usageCredentialType = useCodexSyntheticUsage ? "token" : selectedUsageCredentialType;
const currentUsageProvider =
resolveUsageProviderId(activeStatusProvider, { credentialType: usageCredentialType }) ??
resolveUsageProviderId(activeProvider, { credentialType: usageCredentialType });
resolveUsageProviderId(usageStatusProvider, { credentialType: usageCredentialType }) ??
resolveUsageProviderId(usageProvider, { credentialType: usageCredentialType });
let usageLine: string | null = null;
if (
currentUsageProvider &&
@@ -590,24 +614,21 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
const selectedContextTokens = resolveStatusRuntimeContextTokens({
cfg,
provider: selectedStatusProvider,
model,
model: modelRefs.selected.model || selectedLookupModel,
});
const runtimeSnapshotHasFallbackProvenance =
!modelRefs.activeDiffers ||
hasSessionAutoModelFallbackProvenance(sessionEntry) ||
areRuntimeModelRefsEquivalent(modelRefs.active.label, modelRefs.selected.label, {
config: cfg,
});
const statusAgentContextTokens =
typeof contextTokens === "number" &&
contextTokens > 0 &&
(runtimeSnapshotHasFallbackProvenance ||
(activeRuntimeIsAuthoritative ||
contextTokens === configuredContextTokens ||
contextTokens === selectedContextTokens)
? contextTokens
: undefined;
const statusRuntimeContextTokens = runtimeSnapshotHasFallbackProvenance
? runtimeContextTokens
const statusRuntimeContextTokens = activeRuntimeIsAuthoritative
? (runtimeContextTokens ??
(fallbackState.active && typeof contextTokens === "number" && contextTokens > 0
? contextTokens
: undefined))
: undefined;
return buildStatusMessage({
config: cfg,

View File

@@ -1467,4 +1467,39 @@ describe("exportTrajectoryBundle", () => {
expect(tools).toContain("$WORKSPACE_DIR/docs");
expect(`${prompts}\n${artifacts}\n${systemPrompt}\n${tools}`).not.toContain(tmpDir);
});
it("exports the transcript for a legacy v1 session without entry timestamps", async () => {
const tmpDir = makeTempDir();
const sessionFile = path.join(tmpDir, "session.jsonl");
const outputDir = path.join(tmpDir, "bundle");
const header = {
type: "session",
version: 1,
id: "session-1",
cwd: tmpDir,
};
const userEntry = {
type: "message",
message: userMessage("hello"),
};
const assistantEntry = {
type: "message",
message: assistantMessage([{ type: "text", text: "done" }]),
};
fs.writeFileSync(
sessionFile,
`${[header, userEntry, assistantEntry].map((entry) => JSON.stringify(entry)).join("\n")}\n`,
"utf8",
);
const bundle = await exportTrajectoryBundle({
outputDir,
sessionFile,
sessionId: "session-1",
workspaceDir: tmpDir,
});
expect(bundle.manifest.transcriptEventCount).toBe(2);
expect(eventTypes(bundle.events)).toEqual(["user.message", "assistant.message"]);
});
});

View File

@@ -185,9 +185,7 @@ async function readSessionBranch(filePath: string): Promise<{
(entry): entry is SessionEntry =>
entry.type !== "session" &&
isCanonicalSessionTranscriptEntry(entry) &&
typeof (entry as { id?: unknown }).id === "string" &&
(typeof (entry as { timestamp?: unknown }).timestamp === "string" ||
typeof (entry as { timestamp?: unknown }).timestamp === "number"),
typeof (entry as { id?: unknown }).id === "string",
);
const tree = scanSessionTranscriptTree(fileEntries);
if (!tree.hasLeafUpdate) {

View File

@@ -1,6 +1,6 @@
// Git hook tests validate pre-commit hook behavior and scripts.
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { cleanupTempDirs, makeTempRepoRoot } from "./helpers/temp-repo.js";
@@ -39,8 +39,8 @@ const runFailure = (
const failure = error as Error & { status?: number; stderr?: string; stdout?: string };
return {
status: failure.status ?? 1,
stderr: String(failure.stderr ?? ""),
stdout: String(failure.stdout ?? ""),
stderr: failure.stderr ?? "",
stdout: failure.stdout ?? "",
};
}
throw error;
@@ -83,6 +83,34 @@ function installPreCommitFixture(dir: string): string {
return fakeBinDir;
}
function installFormattingRecorder(dir: string): string {
const logPath = path.join(dir, "hook-tool.log");
writeFileSync(
path.join(dir, "scripts", "pre-commit", "filter-staged-files.mjs"),
`const files = process.argv.slice(3).filter((arg) => arg !== "--");
for (const file of files) {
if (file.endsWith(".ts")) {
process.stdout.write(file);
process.stdout.write("\0");
}
}
`,
"utf8",
);
writeFileSync(
path.join(dir, "scripts", "pre-commit", "run-node-tool.sh"),
`#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> ${JSON.stringify(logPath)}
`,
{
encoding: "utf8",
mode: 0o755,
},
);
return logPath;
}
function installRunNodeToolFixture(dir: string): void {
mkdirSync(path.join(dir, "scripts", "pre-commit"), { recursive: true });
symlinkSync(
@@ -101,6 +129,13 @@ function splitNonEmptyLines(output: string): string[] {
return lines;
}
function readFormatterLog(logPath: string): string[] {
if (!existsSync(logPath)) {
return [];
}
return splitNonEmptyLines(readFileSync(logPath, "utf8"));
}
afterEach(() => {
cleanupTempDirs(tempDirs);
});
@@ -128,6 +163,100 @@ describe("git-hooks/pre-commit (integration)", () => {
expect(staged).toEqual(["--all"]);
});
it("skips formatting staged files while a merge commit is in progress", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-merge-");
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
installPreCommitFixture(dir);
const logPath = installFormattingRecorder(dir);
writeFileSync(path.join(dir, "changed.ts"), "export const value = 1;\n", "utf8");
run(dir, "git", ["add", "--", "changed.ts"]);
run(dir, "git", [
"-c",
"user.name=Test User",
"-c",
"user.email=test@example.invalid",
"commit",
"-q",
"-m",
"initial",
]);
run(dir, "git", ["checkout", "-q", "-b", "side"]);
writeFileSync(path.join(dir, "changed.ts"), "export const value = 2;\n", "utf8");
run(dir, "git", ["add", "--", "changed.ts"]);
run(dir, "git", [
"-c",
"user.name=Test User",
"-c",
"user.email=test@example.invalid",
"commit",
"-q",
"-m",
"side change",
]);
run(dir, "git", ["checkout", "-q", "main"]);
run(dir, "git", [
"-c",
"user.name=Test User",
"-c",
"user.email=test@example.invalid",
"merge",
"--no-commit",
"--no-ff",
"side",
]);
expect(existsSync(path.join(dir, ".git", "MERGE_HEAD"))).toBe(true);
expect(run(dir, "git", ["diff", "--cached", "--name-only"])).toBe("changed.ts");
run(dir, "bash", ["git-hooks/pre-commit"]);
expect(readFormatterLog(logPath)).toEqual([]);
});
it.each([
["cherry-pick", "CHERRY_PICK_HEAD", "file"],
["revert", "REVERT_HEAD", "file"],
["rebase head", "REBASE_HEAD", "file"],
["merge rebase state", "rebase-merge", "dir"],
["apply rebase state", "rebase-apply", "dir"],
])("skips formatting staged files while %s metadata is present", (_label, gitPath, kind) => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-sequencer-");
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
installPreCommitFixture(dir);
const logPath = installFormattingRecorder(dir);
writeFileSync(path.join(dir, "changed.ts"), "export const value = 1;\n", "utf8");
run(dir, "git", ["add", "--", "changed.ts"]);
const metadataPath = path.join(dir, ".git", gitPath);
if (kind === "dir") {
mkdirSync(metadataPath, { recursive: true });
} else {
writeFileSync(metadataPath, "sequencer state\n", "utf8");
}
run(dir, "bash", ["git-hooks/pre-commit"]);
expect(readFormatterLog(logPath)).toEqual([]);
});
it("still formats staged files during a normal commit", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-normal-");
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
installPreCommitFixture(dir);
const logPath = installFormattingRecorder(dir);
writeFileSync(path.join(dir, "changed.ts"), "export const value = 1;\n", "utf8");
run(dir, "git", ["add", "--", "changed.ts"]);
run(dir, "bash", ["git-hooks/pre-commit"]);
expect(readFormatterLog(logPath)).toEqual([
"oxfmt --write --no-error-on-unmatched-pattern changed.ts",
]);
});
it("does not run the changed-scope check for non-doc staged changes", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-no-check-changed-");
run(dir, "git", ["init", "-q", "--initial-branch=main"]);

View File

@@ -14,39 +14,57 @@ function runSurfaceReport(env: Record<string, string>) {
});
}
function readCurrentPublicFunctionExportCount() {
type PublicSurfaceCounts = {
callableExports: number;
exports: number;
wildcardReexports: number;
};
function readDefaultPublicSurfaceBudgets(): PublicSurfaceCounts {
const source = readFileSync("scripts/plugin-sdk-surface-report.mjs", "utf8");
const readFallback = (budgetKey: string) => {
const match = new RegExp(
`${budgetKey}:\\s*readBudgetEnv\\(\\s*"[^"]+",\\s*(\\d+)`,
"u",
).exec(source);
if (match === null || match[1] === undefined) {
throw new Error(`failed to read default ${budgetKey} budget`);
}
return Number(match[1]);
};
return {
exports: readFallback("publicExports"),
callableExports: readFallback("publicFunctionExports"),
wildcardReexports: readFallback("publicWildcardReexports"),
};
}
function readCurrentPublicSurfaceCounts(): PublicSurfaceCounts {
const result = runSurfaceReport({});
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
return parseCurrentPublicCounts(result.stdout).functionExports;
}
function parseCurrentPublicCounts(stdout: string) {
const match = /public package SDK entrypoints:[\s\S]*?\n exports: (\d+)\n callable exports: (\d+)/u
.exec(stdout);
if (match === null || match[1] === undefined || match[2] === undefined) {
throw new Error("failed to read current public export counts");
const totalsMatch =
/public package SDK entrypoints:[\s\S]*?\n exports: (\d+)\n callable exports: (\d+)/u.exec(
result.stdout,
);
const wildcardsMatch = /public wildcard reexports: (\d+)/u.exec(result.stdout);
if (
totalsMatch === null ||
totalsMatch[1] === undefined ||
totalsMatch[2] === undefined ||
wildcardsMatch === null ||
wildcardsMatch[1] === undefined
) {
throw new Error("failed to read current public surface counts");
}
return {
exports: Number(match[1]),
functionExports: Number(match[2]),
exports: Number(totalsMatch[1]),
callableExports: Number(totalsMatch[2]),
wildcardReexports: Number(wildcardsMatch[1]),
};
}
function readDefaultBudget(envName: string): number {
const source = readFileSync("scripts/plugin-sdk-surface-report.mjs", "utf8");
const match = new RegExp(
`readBudgetEnv\\("${envName}",\\s*(\\d+)\\)`,
"u",
);
const result = match.exec(source);
if (result === null || result[1] === undefined) {
throw new Error(`failed to read default budget for ${envName}`);
}
return Number(result[1]);
}
describe("plugin SDK surface report", () => {
it("rejects unknown CLI options before collecting SDK stats", () => {
const result = spawnSync(
@@ -115,20 +133,12 @@ describe("plugin SDK surface report", () => {
expect(result.stderr).toBe("");
});
it("keeps default public budgets tight to the current source surface", () => {
const result = runSurfaceReport({});
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
const counts = parseCurrentPublicCounts(result.stdout);
expect(readDefaultBudget("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS")).toBe(counts.exports);
expect(readDefaultBudget("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS")).toBe(
counts.functionExports,
);
it("keeps default public surface budgets pinned to current source counts", () => {
expect(readDefaultPublicSurfaceBudgets()).toEqual(readCurrentPublicSurfaceCounts());
});
it("keeps generated package declarations out of source surface counts", () => {
const budget = readCurrentPublicFunctionExportCount();
const budget = readCurrentPublicSurfaceCounts().callableExports;
const result = runSurfaceReport({
OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS: String(budget - 1),
});