Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
ad09f15fe3 chore(deps): bump the actions group across 1 directory with 5 updates
Bumps the actions group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/create-github-app-token](https://github.com/actions/create-github-app-token) | `3.1.1` | `3.2.0` |
| [actions/setup-python](https://github.com/actions/setup-python) | `6.2.0` | `6.3.0` |
| [actions/setup-java](https://github.com/actions/setup-java) | `5.2.0` | `5.3.0` |
| [openai/codex-action](https://github.com/openai/codex-action) | `1.8` | `1.9` |
| [actions/setup-go](https://github.com/actions/setup-go) | `6.4.0` | `6.5.0` |



Updates `actions/create-github-app-token` from 3.1.1 to 3.2.0
- [Release notes](https://github.com/actions/create-github-app-token/releases)
- [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/create-github-app-token/compare/v3.1.1...bcd2ba49218906704ab6c1aa796996da409d3eb1)

Updates `actions/setup-python` from 6.2.0 to 6.3.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](a309ff8b42...ece7cb06ca)

Updates `actions/setup-java` from 5.2.0 to 5.3.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5.2.0...ad2b38190b15e4d6bdf0c97fb4fca8412226d287)

Updates `openai/codex-action` from 1.8 to 1.9
- [Changelog](https://github.com/openai/codex-action/blob/main/CHANGELOG.md)
- [Commits](e0fdf01220...10cb888d2e)

Updates `actions/setup-go` from 6.4.0 to 6.5.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](4a3601121d...924ae3a1cd)

---
updated-dependencies:
- dependency-name: actions/create-github-app-token
  dependency-version: 3.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/setup-java
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: openai/codex-action
  dependency-version: '1.9'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/setup-go
  dependency-version: 6.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 08:43:11 +00:00
93 changed files with 325 additions and 2747 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # 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@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
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@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # 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@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
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@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
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@e0fdf01220eb9a88167c4898839d273e3f2609d1
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
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@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # 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@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # 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@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # 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@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # 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@e0fdf01220eb9a88167c4898839d273e3f2609d1
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
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@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # 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@e0fdf01220eb9a88167c4898839d273e3f2609d1
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
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@e0fdf01220eb9a88167c4898839d273e3f2609d1
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.12"

View File

@@ -737,10 +737,6 @@ 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, reservedLiterals, resolveTarget
inferTargetChatType, looksLikeId, 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).toBe("reply-1");
expect(messageOptions.replyTo).toBeUndefined();
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).toBe("reply-1");
expect(mediaOptions.replyTo).toBeUndefined();
expect(result).toEqual({
channel: "discord",
messageId: "msg-1",
@@ -361,31 +361,6 @@ 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,15 +84,13 @@ 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),
replyTo: voiceReplyTo,
}),
await sendContext.sendVoice(
sendContext.target,
mediaUrls[0],
resolveDiscordDeliveryOptions(ctx, sendContext),
),
);
if (payload.text?.trim()) {
lastResult = await sendContext.withRetry(
@@ -100,7 +98,6 @@ export async function sendDiscordOutboundPayload(params: {
await sendContext.send(sendContext.target, payload.text, {
verbose: false,
...resolveDiscordFormattedDeliveryOptions(ctx, sendContext),
replyTo: voiceReplyTo,
}),
);
}
@@ -110,7 +107,6 @@ export async function sendDiscordOutboundPayload(params: {
await sendContext.send(sendContext.target, "", {
verbose: false,
...resolveDiscordMediaDeliveryOptions(ctx, sendContext, mediaUrl),
replyTo: voiceReplyTo,
}),
);
}

View File

@@ -55,35 +55,20 @@ describe("PDF document extractor", () => {
});
});
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,
},
],
});
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,
},
],
});
const extractor = createPdfDocumentExtractor();
const result = await extractor.extract(request());
@@ -97,24 +82,18 @@ 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",
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 },
maxPages: 2,
image: {
maxDimension: 10_000,
maxPixels: 100,
forms: true,
},
});
expect(result).toEqual({
text: "",
images: [
{ type: "image", data: "cG5nMQ==", mimeType: "image/png" },
{ type: "image", data: "cG5nMg==", mimeType: "image/png" },
],
images: [{ type: "image", data: "cG5n", mimeType: "image/png" }],
});
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
});
@@ -152,9 +131,8 @@ describe("PDF document extractor", () => {
expect(pdfDocument.destroy).not.toHaveBeenCalled();
});
it("filters selected pages and renders them one page per image call", async () => {
it("filters selected pages before passing them to clawpdf", async () => {
pdfDocument.extract
.mockResolvedValueOnce({ text: "", images: [] })
.mockResolvedValueOnce({ text: "", images: [] })
.mockResolvedValueOnce({ text: "", images: [] });
const extractor = createPdfDocumentExtractor();
@@ -163,15 +141,11 @@ describe("PDF document extractor", () => {
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ mode: "text", pages: [2, 1] }),
expect.objectContaining({ pages: [2, 1] }),
);
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ mode: "images", pages: [2] }),
);
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ mode: "images", pages: [1] }),
expect.objectContaining({ pages: [2, 1] }),
);
});

View File

@@ -83,38 +83,17 @@ 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 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 };
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) };
} catch (err) {
request.onImageExtractionError?.(err);
return { text, images: [] };

View File

@@ -833,7 +833,6 @@ 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 on a UTF-16 boundary", async () => {
it("clamps a first oversized non-final preview", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, { maxChars: 10 });
stream.update("123456789😀tail");
stream.update("1234567890ABCDEFGHIJ");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expectNthPreviewSend(api, 1, "123456789");
expect(stream.lastDeliveredText?.()).toBe("123456789");
expectNthPreviewSend(api, 1, "1234567890");
expect(stream.lastDeliveredText?.()).toBe("1234567890");
});
it("finalizes overflow that was hidden by a clamped non-final preview", async () => {

View File

@@ -5,7 +5,6 @@ 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 {
@@ -170,7 +169,7 @@ function findTelegramDraftChunkLength(
high = mid - 1;
}
}
return sliceUtf16Safe(text, 0, best).length;
return best;
}
export function createTelegramDraftStream(params: {

View File

@@ -16,18 +16,6 @@ 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,19 +34,6 @@ 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,9 +150,6 @@ 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,27 +55,4 @@ 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,7 +51,6 @@ 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,
@@ -63,7 +62,7 @@ export function synthesizeMediaGenerationCatalogEntries<TCapabilities>(params: {
if (params.provider.label) {
entry.label = params.provider.label;
}
if (model === defaultModel) {
if (model === params.provider.defaultModel) {
entry.default = true;
}
if (params.modes) {

View File

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

View File

@@ -6,7 +6,6 @@ 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 {
@@ -421,8 +420,7 @@ 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"));
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
try {
const image = await loadImageFromRef(
@@ -439,7 +437,7 @@ describe("loadImageFromRef", () => {
expect(image?.mimeType).toBe("image/png");
expect(image?.data).toBe(TINY_PNG_BASE64);
} finally {
envSnapshot.restore();
vi.unstubAllEnvs();
await fs.rm(stateDir, { recursive: true, force: true });
}
});
@@ -672,8 +670,7 @@ describe("detectAndLoadPromptImages", () => {
const imagePath = path.join(inboundDir, "signal-replay.png");
const pngB64 = TINY_PNG_BASE64;
await fs.writeFile(imagePath, Buffer.from(pngB64, "base64"));
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
try {
const result = await detectAndLoadPromptImages({
@@ -688,7 +685,7 @@ describe("detectAndLoadPromptImages", () => {
expect(result.skippedCount).toBe(0);
expect(result.images).toHaveLength(1);
} finally {
envSnapshot.restore();
vi.unstubAllEnvs();
await fs.rm(stateDir, { recursive: true, force: true });
}
});

View File

@@ -10,7 +10,6 @@ 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: {} };
@@ -118,9 +117,9 @@ afterEach(() => {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir;
}
if (originalConfigPath === undefined) {
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
delete process.env.OPENCLAW_CONFIG_PATH;
} else {
setTestEnvValue("OPENCLAW_CONFIG_PATH", originalConfigPath);
process.env.OPENCLAW_CONFIG_PATH = originalConfigPath;
}
for (const dir of tempDirs) {
fs.rmSync(dir, { force: true, recursive: true });
@@ -260,10 +259,9 @@ 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);
const configPath = path.join(tempDir, "openclaw.json");
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
process.env.OPENCLAW_CONFIG_PATH = path.join(tempDir, "openclaw.json");
fs.writeFileSync(
configPath,
process.env.OPENCLAW_CONFIG_PATH,
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, setTestEnvValue } from "../test-utils/env.js";
import { captureEnv } 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.
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
process.env.OPENCLAW_STATE_DIR = stateDir;
if (options.home) {
setTestEnvValue("HOME", options.home);
process.env.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 });
setTestEnvValue(EXEC_SHELL_SNAPSHOT_ENV, "0");
process.env[EXEC_SHELL_SNAPSHOT_ENV] = "0";
const command = "echo unchanged";
const wrapped = await maybeWrapCommandWithShellSnapshot({
command,

View File

@@ -5,7 +5,6 @@ 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];
@@ -19,7 +18,7 @@ function useLoggingConfig(name: string, logging: Record<string, unknown>): void
}
const configPath = path.join(tempDir, name);
fs.writeFileSync(configPath, `${JSON.stringify({ logging })}\n`, "utf8");
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
process.env.OPENCLAW_CONFIG_PATH = configPath;
}
function createHistoryToolWithMessage(content: string) {
@@ -51,9 +50,9 @@ describe("sessions_history redaction", () => {
afterAll(() => {
if (previousConfigPath === undefined) {
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
delete process.env.OPENCLAW_CONFIG_PATH;
} else {
setTestEnvValue("OPENCLAW_CONFIG_PATH", previousConfigPath);
process.env.OPENCLAW_CONFIG_PATH = previousConfigPath;
}
if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });

View File

@@ -2,7 +2,6 @@ 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());
@@ -22,7 +21,7 @@ let tempAgentDir: string | undefined;
beforeEach(() => {
originalAgentDir = process.env.OPENCLAW_AGENT_DIR;
tempAgentDir = mkdtempSync(join(tmpdir(), "openclaw-tools-manager-"));
setTestEnvValue("OPENCLAW_AGENT_DIR", tempAgentDir);
process.env.OPENCLAW_AGENT_DIR = tempAgentDir;
fetchWithSsrFGuardMock.mockReset();
spawnSyncMock.mockReturnValue({
error: new Error("ENOENT"),
@@ -36,9 +35,9 @@ afterEach(() => {
vi.clearAllMocks();
vi.resetModules();
if (originalAgentDir === undefined) {
deleteTestEnvValue("OPENCLAW_AGENT_DIR");
delete process.env.OPENCLAW_AGENT_DIR;
} else {
setTestEnvValue("OPENCLAW_AGENT_DIR", originalAgentDir);
process.env.OPENCLAW_AGENT_DIR = originalAgentDir;
}
if (tempAgentDir) {
rmSync(tempAgentDir, { recursive: true, force: true });

View File

@@ -78,17 +78,12 @@ 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,
params.parseSelectedProvider,
);
const selected = normalizeModelRef(params.selectedModel, params.selectedProvider);
const runtimeModel = normalizeOptionalString(params.sessionEntry?.model);
const runtimeProvider = normalizeOptionalString(params.sessionEntry?.modelProvider);

View File

@@ -17,7 +17,6 @@ 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";
@@ -257,15 +256,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;
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
process.env.OPENCLAW_CONFIG_PATH = configPath;
await fs.writeFile(configPath, JSON.stringify(initialConfig, null, 2), "utf-8");
try {
return await run(configPath);
} finally {
if (previous === undefined) {
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
delete process.env.OPENCLAW_CONFIG_PATH;
} else {
setTestEnvValue("OPENCLAW_CONFIG_PATH", previous);
process.env.OPENCLAW_CONFIG_PATH = previous;
}
await fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}

View File

@@ -1249,155 +1249,6 @@ 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(),
@@ -1446,241 +1297,6 @@ 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,7 +4,6 @@ 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";
@@ -12,9 +11,9 @@ const originalStateDirEnv = process.env.OPENCLAW_STATE_DIR;
function restoreProcessState() {
if (originalStateDirEnv === undefined) {
deleteTestEnvValue("OPENCLAW_STATE_DIR");
delete process.env.OPENCLAW_STATE_DIR;
} else {
setTestEnvValue("OPENCLAW_STATE_DIR", originalStateDirEnv);
process.env.OPENCLAW_STATE_DIR = originalStateDirEnv;
}
}
@@ -34,7 +33,7 @@ describe("resolveCurrentTurnImages", () => {
await fs.mkdir(path.dirname(attachmentPath), { recursive: true });
await fs.mkdir(cwd, { recursive: true });
await fs.writeFile(attachmentPath, imageBytes);
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
process.env.OPENCLAW_STATE_DIR = stateDir;
vi.spyOn(process, "cwd").mockReturnValue(cwd);
const result = await resolveCurrentTurnImages({

View File

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

View File

@@ -92,14 +92,6 @@ 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,8 +608,6 @@ 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, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { captureEnv } 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",
]);
setTestEnvValue("OPENCLAW_STATE_DIR", tmpDir);
setTestEnvValue("OPENCLAW_CONFIG_PATH", path.join(tmpDir, "openclaw.json"));
deleteTestEnvValue("OPENCLAW_GATEWAY_PORT");
deleteTestEnvValue("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;
serviceReadCommand.mockResolvedValue(null);
resolveGatewayProbeAuthSafeWithSecretInputs.mockClear();
findExtraGatewayServices.mockClear();

View File

@@ -15,11 +15,8 @@ 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 {
@@ -69,23 +66,6 @@ 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 = "";
@@ -155,57 +135,6 @@ 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";
@@ -527,9 +456,8 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
});
const raw = await fs.readFile(storePath, "utf-8");
const snapshot = readMainSkillsSnapshot(raw);
expect(snapshot.prompt).not.toContain(stalePath);
expect(snapshot.prompt).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
expect(raw).not.toContain(stalePath);
expect(raw).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
expect(note).toHaveBeenCalledTimes(1);
const [message] = note.mock.calls[0] as [string, string];
expect(message).toContain("Repaired");
@@ -607,13 +535,9 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
const raw = await fs.readFile(storePath, "utf-8");
const expectedBaseDir = path.dirname(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
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(raw).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
expect(raw).toContain(expectedBaseDir);
expect(raw).not.toContain(path.join(root, "old-runtime"));
expect(note).toHaveBeenCalledTimes(1);
const [message] = note.mock.calls[0] as [string, string];
expect(message).toContain("Repaired");
@@ -652,12 +576,9 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
});
const raw = await fs.readFile(storePath, "utf-8");
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(raw).toContain(currentPath);
expect(raw).toContain(path.dirname(currentPath));
expect(raw).not.toContain(path.join(root, "old-runtime"));
expect(note).toHaveBeenCalledTimes(1);
const [message] = note.mock.calls[0] as [string, string];
expect(message).toContain("Repaired");
@@ -822,8 +743,7 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
expect(backupFiles.length).toBe(1);
const backupContent = await fs.readFile(path.join(dir, backupFiles[0]), "utf-8");
const backupSnapshot = readMainSkillsSnapshot(backupContent);
expect(backupSnapshot.prompt).toContain(stalePath);
expect(backupContent).toContain(stalePath);
});
it("is idempotent — second repair finds nothing", async () => {

View File

@@ -12,14 +12,11 @@ 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"
@@ -37,10 +34,6 @@ type StaleSessionSnapshotPathFinding = {
expectedPath: string;
};
export type SessionSnapshotHealthIssue = StaleSessionSnapshotPathFinding & {
storePath: string;
};
function decodeXmlText(value: string): string {
return value
.replace(/&lt;/g, "<")
@@ -293,72 +286,6 @@ 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,11 +12,8 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
}));
import {
detectSessionTranscriptHealthIssues,
noteSessionTranscriptHealth,
repairBrokenSessionTranscriptFile,
sessionTranscriptIssueToHealthFinding,
sessionTranscriptIssueToRepairEffect,
} from "./doctor-session-transcripts.js";
function countNonEmptyLines(value: string): number {
@@ -153,44 +150,6 @@ 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,11 +16,8 @@ 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;
@@ -39,10 +36,6 @@ type TranscriptRepairResult = {
reason?: string;
};
export type SessionTranscriptHealthIssue = TranscriptRepairResult & {
broken: true;
};
type ActiveTranscriptPath = {
entries: TranscriptEntry[];
entriesToPersist: TranscriptEntry[];
@@ -379,57 +372,6 @@ 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;
@@ -444,14 +386,14 @@ export async function noteSessionTranscriptHealth(params?: {
return;
}
const files = await listSessionTranscriptFiles(sessionDirs);
if (files.length === 0) {
return;
}
const results: TranscriptRepairResult[] = [];
if (shouldRepair) {
const files = await listSessionTranscriptFiles(sessionDirs);
for (const filePath of files) {
results.push(await repairBrokenSessionTranscriptFile({ filePath, shouldRepair }));
}
} else {
results.push(...(await detectSessionTranscriptHealthIssues({ sessionDirs })));
for (const filePath of files) {
results.push(await repairBrokenSessionTranscriptFile({ filePath, shouldRepair }));
}
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("sends one setup-timeout notification when concurrent manual runs both stall before runner start", async () => {
it("requests one setup-timeout restart 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,9 +1,6 @@
// Isolated agent delivery target tests cover target resolution for cron runs.
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
ChannelDirectoryEntry,
ChannelOutboundAdapter,
} from "../../channels/plugins/types.js";
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import {
@@ -744,57 +741,6 @@ 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,7 +11,6 @@ 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";
@@ -350,20 +349,17 @@ export async function resolveDeliveryTarget(
allowFrom: effectiveAllowFrom,
});
if (!docked.ok) {
if (!toCandidate || !isReservedTargetLiteralError(docked.error)) {
return {
ok: false,
channel,
to: undefined,
accountId,
threadId: explicitThreadId,
mode,
error: docked.error,
};
}
} else {
toCandidate = docked.to;
return {
ok: false,
channel,
to: undefined,
accountId,
threadId: explicitThreadId,
mode,
error: docked.error,
};
}
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>(),
manualSetupTimeoutNotified: false,
manualSetupTimeoutRestartNotified: 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.manualSetupTimeoutNotified = false;
state.manualSetupTimeoutRestartNotified = false;
}
}
@@ -98,11 +98,11 @@ function maybeNotifyManualIsolatedSetupTimeout(
isolatedAgentSetupTimeout?: IsolatedAgentSetupTimeoutSignal;
},
): boolean {
if (!result.isolatedAgentSetupTimeout || state.manualSetupTimeoutNotified) {
if (!result.isolatedAgentSetupTimeout || state.manualSetupTimeoutRestartNotified) {
return false;
}
const notified = maybeNotifyIsolatedAgentSetupTimeout(state, result);
state.manualSetupTimeoutNotified ||= notified;
state.manualSetupTimeoutRestartNotified ||= notified;
return notified;
}

View File

@@ -197,7 +197,7 @@ export type CronServiceState = {
stopped: boolean;
restartRecoveryPending: boolean;
activeManualRunJobIds: Set<string>;
manualSetupTimeoutNotified: boolean;
manualSetupTimeoutRestartNotified: 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>(),
manualSetupTimeoutNotified: false,
manualSetupTimeoutRestartNotified: 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 after startup catch-up finalization", async () => {
it("notifies setup-timeout restart 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("sends one setup-timeout notification when a concurrent cron batch stalls before runners start", async () => {
it("requests one setup-timeout restart 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("sends setup-timeout notification after a prior serial cron job completes", async () => {
it("requests setup-timeout restart 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("sends setup-timeout notification when manual and scheduled runs both stall", async () => {
it("requests setup-timeout restart 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("rearms scheduled jobs after manual setup timeout notification", async () => {
it("suppresses scheduled rearm after manual setup-timeout restart request", 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(false);
expect(state.timer).not.toBeNull();
expect(state.restartRecoveryPending).toBe(true);
expect(state.timer).toBeNull();
expect(scheduledStarted).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
@@ -2352,7 +2352,7 @@ describe("cron service timer regressions", () => {
).toBe(replacementReservationMs);
});
it("continues an active scheduled batch after manual setup-timeout notification", async () => {
it("stops an active scheduled batch from claiming more jobs after manual setup-timeout recovery", 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(false);
expect(state.restartRecoveryPending).toBe(true);
finishFirstScheduled.resolve();
await timerRun;
const second = requireJob(state, secondScheduledJob.id);
expect(onIsolatedAgentSetupTimeout).toHaveBeenCalledTimes(1);
expect(secondScheduledStarted).toHaveBeenCalledWith(secondScheduledJob.id);
expect(secondScheduledStarted).not.toHaveBeenCalled();
expect(second.state.runningAtMs).toBeUndefined();
} finally {
vi.useRealTimers();
@@ -2794,7 +2794,7 @@ describe("cron service timer regressions", () => {
}
});
it("does not notify setup timeout for cron-nested lane contention", async () => {
it("does not request setup-timeout restart 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 for custom-session cron waits", async () => {
it("does not notify setup-timeout restart for custom-session cron waits", async () => {
vi.useFakeTimers();
try {
const store = timerRegressionFixtures.makeStorePath();

View File

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

View File

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

View File

@@ -1014,8 +1014,6 @@ 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,71 +1298,11 @@ 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,34 +456,6 @@ 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,20 +145,12 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
restartsThisHour: [],
};
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) {
if (now - record.lastRestartAt <= cooldownMs) {
continue;
}
pruneOldRestarts(record, now);
if (!continuingPendingRestart && record.restartsThisHour.length >= maxRestartsPerHour) {
if (record.restartsThisHour.length >= maxRestartsPerHour) {
log.warn?.(
`[${channelId}:${accountId}] health-monitor: hit ${maxRestartsPerHour} restarts/hour limit, skipping`,
);
@@ -169,11 +161,9 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
log.info?.(`[${channelId}:${accountId}] health-monitor: restarting (reason: ${reason})`);
if (!continuingPendingRestart) {
record.lastRestartAt = now;
record.restartsThisHour.push({ at: now });
restartRecords.set(key, record);
}
record.lastRestartAt = now;
record.restartsThisHour.push({ at: now });
restartRecords.set(key, record);
try {
if (status.running) {

View File

@@ -511,82 +511,6 @@ 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,7 +449,6 @@ 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);
@@ -457,30 +456,10 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
if (manuallyStopped.has(rKey)) {
return;
}
// 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;
recoveryStartRequested.add(rKey);
setRuntime(channelId, id, { accountId: id, restartPending: true });
}
return;
}
const existingStart = store.starting.get(id);
if (existingStart) {
@@ -628,10 +607,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
abortSignal: abort.signal,
log,
getStatus: () => getRuntime(channelId, id),
setStatus: (next) =>
isCurrentTask()
? setRuntimeFromTaskStatus(channelId, id, next, abort.signal)
: getRuntime(channelId, id),
setStatus: (next) => setRuntimeFromTaskStatus(channelId, id, next, abort.signal),
...(channelRuntimeForTask ? { channelRuntime: channelRuntimeForTask } : {}),
});
const routeRegistry = getPluginHttpRouteRegistry?.();
@@ -644,11 +620,9 @@ 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) || !isCurrentTask()) {
if (abort.signal.aborted || manuallyStopped.has(rKey)) {
return;
}
const message = "channel exited without an error";
@@ -656,26 +630,17 @@ 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);
@@ -766,9 +731,6 @@ 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("backs off isolated cron setup timeout without gateway restart", async () => {
it("requests a safe gateway restart when isolated cron setup times out", async () => {
vi.useFakeTimers();
const cfg = createCronConfig("server-cron-isolated-setup-timeout");
loadConfigMock.mockReturnValue(cfg);
@@ -315,7 +315,12 @@ describe("buildGatewayCronService", () => {
const runResult = await runPromise;
expect(runResult).toEqual({ ok: true, ran: true });
expect(requestSafeGatewayRestartMock).not.toHaveBeenCalled();
expect(requestSafeGatewayRestartMock).toHaveBeenCalledTimes(1);
expect(requestSafeGatewayRestartMock).toHaveBeenCalledWith({
reason: "cron.isolated_agent_setup_timeout",
delayMs: 0,
preservePendingEmitHooks: true,
});
} finally {
state.cron.stop();
vi.useRealTimers();

View File

@@ -32,6 +32,7 @@ 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,
@@ -546,14 +547,23 @@ 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; backing off job without gateway restart",
"cron: isolated agent setup timed out before runner start; requested safe 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 = deliveryPlan.targetResolutionError;
let deliveryTargetResolutionError: Error | undefined;
if (wantsDelivery && resolvedChannel === INTERNAL_MESSAGE_CHANNEL) {
const cfgResolved = cfgForAgent ?? cfg;
@@ -2328,27 +2328,6 @@ 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,37 +923,6 @@ 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,6 +15,7 @@ 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 {
@@ -48,7 +49,6 @@ 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,16 +177,17 @@ async function resolveRequestedChannel(params: {
}
> {
const channelInput = readStringValue(params.requestChannel);
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.",
),
};
}
const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null;
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,34 +5,8 @@ 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 = getTranscriptFieldRegexes(field).stringRe.exec(prefix);
const match = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(prefix);
if (!match) {
return undefined;
}
@@ -48,14 +22,16 @@ export function extractJsonNullableStringFieldPrefix(
prefix: string,
field: string,
): string | null | undefined {
if (getTranscriptFieldRegexes(field).nullRe.test(prefix)) {
if (new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*null`).test(prefix)) {
return null;
}
return extractJsonStringFieldPrefix(prefix, field);
}
export function extractJsonNumberFieldPrefix(prefix: string, field: string): number | undefined {
const match = getTranscriptFieldRegexes(field).numberRe.exec(prefix);
const match = new RegExp(
`"${escapeRegExp(field)}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)`,
).exec(prefix);
if (!match) {
return undefined;
}

View File

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

View File

@@ -4,15 +4,6 @@ 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" }),
),
@@ -91,16 +82,11 @@ 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", "telegram"].includes(channel),
isDeliverableMessageChannel: (channel: string) => ["directchat", "workspace"].includes(channel),
isGatewayMessageChannel: (channel: string) =>
["directchat", "workspace", "telegram", "webchat"].includes(channel),
["directchat", "workspace", "webchat"].includes(channel),
normalizeMessageChannel: (value: string) => value.trim().toLowerCase(),
}));
@@ -120,18 +106,7 @@ beforeAll(async () => {
beforeEach(() => {
mocks.resolveOutboundChannelPlugin.mockReset();
mocks.resolveOutboundChannelPlugin.mockReturnValue(null);
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.resolveOutboundTarget.mockClear();
mocks.resolveOutboundSessionRoute.mockReset();
mocks.resolveOutboundSessionRoute.mockResolvedValue(null);
mocks.resolveSessionDeliveryTarget.mockClear();
@@ -338,181 +313,6 @@ 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,8 +15,6 @@ 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,
@@ -31,7 +29,6 @@ export type AgentDeliveryPlan = {
resolvedAccountId?: string;
resolvedThreadId?: string | number;
deliveryTargetMode?: ChannelOutboundTargetMode;
targetResolutionError?: Error;
};
export function resolveAgentDeliveryPlan(params: {
@@ -146,46 +143,27 @@ export async function resolveAgentDeliveryPlanWithSessionRoute(
},
): Promise<AgentDeliveryPlan> {
const plan = resolveAgentDeliveryPlan(params);
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) {
if (
!params.wantsDelivery ||
!plan.resolvedTo ||
!isDeliverableMessageChannel(plan.resolvedChannel) ||
!resolveOutboundChannelPlugin({
channel: plan.resolvedChannel,
cfg: params.cfg,
allowBootstrap: true,
})?.messaging?.resolveOutboundSessionRoute
) {
return plan;
}
const normalizedTarget = resolveOutboundTarget({
channel: resolvedChannel,
to: resolvedTo,
channel: plan.resolvedChannel,
to: plan.resolvedTo,
cfg: params.cfg,
accountId: plan.resolvedAccountId,
mode: plan.deliveryTargetMode ?? "explicit",
});
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;
if (!normalizedTarget.ok) {
return plan;
}
const explicitThreadId =
params.explicitThreadId != null && params.explicitThreadId !== ""
@@ -195,11 +173,10 @@ export async function resolveAgentDeliveryPlanWithSessionRoute(
try {
return await resolveOutboundSessionRoute({
cfg: params.cfg,
channel: resolvedChannel as ChannelId,
channel: plan.resolvedChannel as ChannelId,
agentId: params.agentId,
accountId: plan.resolvedAccountId,
target: sessionRouteTarget,
...(resolvedSessionRouteTarget ? { resolvedTarget: resolvedSessionRouteTarget } : {}),
target: normalizedTarget.to,
currentSessionKey: params.currentSessionKey,
threadId: plan.deliveryTargetMode === "explicit" ? explicitThreadId : plan.resolvedThreadId,
});
@@ -208,14 +185,6 @@ export async function resolveAgentDeliveryPlanWithSessionRoute(
}
})();
if (!route) {
if (resolvedSessionRouteTarget) {
return {
...plan,
resolvedTo: resolvedSessionRouteTarget.to,
resolvedThreadId:
plan.deliveryTargetMode === "explicit" ? explicitThreadId : plan.resolvedThreadId,
};
}
return plan;
}
return {
@@ -241,13 +210,6 @@ 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

@@ -1,109 +0,0 @@
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,51 +1045,6 @@ 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,29 +1390,11 @@ async function deliverOutboundPayloadsWithQueueCleanup(
if (isDeliveryAbortError(err)) {
await ackDelivery(queueId).catch(() => {});
} else if (!platformResultsReturned) {
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)}`,
);
});
}
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,19 +389,15 @@ async function drainQueuedEntry(opts: {
return "failed";
}
}
const reconciliationProvedPreSendFailure =
reconciliation?.status === "not_sent" && entry.recoveryState === "send_attempt_started";
if (reconciliationProvedPreSendFailure) {
if (reconciliation?.status === "not_sent") {
opts.log.info(
`Delivery entry ${entry.id} reconciled ${entry.recoveryState} as not sent; replaying`,
);
} else {
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}`;
}
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`;
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("moves unknown-after-send entries to failed when adapter reports not sent", async () => {
it("replays unknown-after-send entries only after adapter proves they were not sent", async () => {
const id = await enqueueDelivery(
{ channel: "demo-channel-a", to: "+1", payloads: [{ text: "not sent" }] },
tmpDir(),
@@ -346,19 +346,24 @@ describe("delivery-queue recovery", () => {
});
const deliver = vi.fn().mockResolvedValue([]);
const log = createRecoveryLog();
const { result } = await runRecovery({ deliver, log });
const { result } = await runRecovery({ deliver });
expect(deliver).not.toHaveBeenCalled();
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(result).toEqual({
recovered: 0,
failed: 1,
recovered: 1,
failed: 0,
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,10 +3,8 @@ import { describe, expect, it } from "vitest";
import {
ambiguousTargetError,
ambiguousTargetMessage,
isReservedTargetLiteralError,
missingTargetError,
missingTargetMessage,
reservedTargetLiteralError,
unknownTargetError,
unknownTargetMessage,
} from "./target-errors.js";
@@ -71,13 +69,4 @@ 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,18 +40,6 @@ 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,52 +32,6 @@ 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();
}
@@ -270,15 +224,10 @@ 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}|${reserved}|${source}`);
return hashSignature(`${registryScope}|${hint}|${source}`);
}
function hashSignature(value: string): string {

View File

@@ -130,206 +130,6 @@ 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,11 +11,7 @@ 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,
reservedTargetLiteralError,
unknownTargetError,
} from "./target-errors.js";
import { ambiguousTargetError, unknownTargetError } from "./target-errors.js";
import { maybeResolveIdLikeTarget, type ResolvedIdLikeTarget } from "./target-id-resolution.js";
import {
buildTargetResolverSignature,
@@ -24,7 +20,6 @@ import {
normalizeChannelTargetInput,
normalizeTargetForProvider,
resolveNormalizedTargetInput,
resolveReservedTargetLiteral,
} from "./target-normalization.js";
/** Directory-backed destination kind used by outbound target resolution. */
@@ -96,21 +91,9 @@ function normalizeQuery(value: string): string {
return normalizeLowercaseStringOrEmpty(value);
}
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, "")
function stripTargetPrefixes(value: string): string {
return value
.replace(/^(channel|user):/i, "")
.replace(/^[@#]/, "")
.trim();
}
@@ -227,7 +210,6 @@ function matchesDirectoryEntry(params: {
entry: ChannelDirectoryEntry;
query: string;
plugin?: ChannelPlugin;
exactOnly?: boolean;
}): boolean {
const query = normalizeQuery(params.query);
if (!query) {
@@ -235,19 +217,11 @@ 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, params.channel, params.plugin)
: "";
const handle = params.entry.handle
? stripTargetPrefixes(params.entry.handle, params.channel, params.plugin)
: "";
const name = params.entry.name ? stripTargetPrefixes(params.entry.name) : "";
const handle = params.entry.handle ? stripTargetPrefixes(params.entry.handle) : "";
const candidates = [id, name, handle].map((value) => normalizeQuery(value)).filter(Boolean);
return candidates.some((value) =>
params.exactOnly ? value === query : value === query || value.includes(query),
);
return candidates.some((value) => value === query || value.includes(query));
}
function resolveMatch(params: {
@@ -255,7 +229,6 @@ function resolveMatch(params: {
entries: ChannelDirectoryEntry[];
query: string;
plugin?: ChannelPlugin;
exactOnly?: boolean;
}) {
const matches = params.entries.filter((entry) =>
matchesDirectoryEntry({
@@ -263,7 +236,6 @@ function resolveMatch(params: {
entry,
query: params.query,
plugin: params.plugin,
exactOnly: params.exactOnly,
}),
);
if (matches.length === 0) {
@@ -426,10 +398,8 @@ 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,
@@ -456,7 +426,7 @@ export async function resolveMessagingTarget(params: {
kind,
});
}
const query = stripTargetPrefixes(raw, params.channel, plugin);
const query = stripTargetPrefixes(raw);
const entries = await getDirectoryEntries({
cfg: params.cfg,
channel: params.channel,
@@ -467,13 +437,7 @@ export async function resolveMessagingTarget(params: {
preferLiveOnMiss: true,
plugin,
});
const match = resolveMatch({
channel: params.channel,
entries,
query,
plugin,
exactOnly: Boolean(reservedLiteral),
});
const match = resolveMatch({ channel: params.channel, entries, query, plugin });
if (match.kind === "single") {
const entry = match.entry;
return {
@@ -481,8 +445,7 @@ export async function resolveMessagingTarget(params: {
target: {
to: normalizeDirectoryEntryId(params.channel, entry, plugin),
kind,
display:
entry.name ?? entry.handle ?? stripTargetPrefixes(entry.id, params.channel, plugin),
display: entry.name ?? entry.handle ?? stripTargetPrefixes(entry.id),
source: "directory",
resolutionSource: "directory",
},
@@ -498,8 +461,7 @@ export async function resolveMessagingTarget(params: {
target: {
to: normalizeDirectoryEntryId(params.channel, best, plugin),
kind,
display:
best.name ?? best.handle ?? stripTargetPrefixes(best.id, params.channel, plugin),
display: best.name ?? best.handle ?? stripTargetPrefixes(best.id),
source: "directory",
resolutionSource: "directory",
},
@@ -512,10 +474,6 @@ 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,8 +8,7 @@ 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, reservedTargetLiteralError } from "./target-errors.js";
import { resolveReservedTargetLiteral } from "./target-normalization.js";
import { missingTargetError } from "./target-errors.js";
/**
* Result of resolving a concrete outbound target for a channel send.
@@ -80,21 +79,6 @@ 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) {
@@ -110,6 +94,7 @@ 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,73 +100,6 @@ 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,117 +1194,6 @@ 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,7 +27,6 @@ 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,
@@ -362,13 +361,6 @@ 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,31 +573,6 @@ 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,7 +33,6 @@ import {
readImageMetadataFromHeader,
readImageProbeFromHeader,
} from "./media-services.js";
import { extractOriginalFilename, getMediaDir } from "./store.js";
export { getDefaultLocalRoots, LocalMediaAccessError };
export type { LocalMediaAccessErrorCode };
@@ -285,13 +284,6 @@ 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);
@@ -1082,7 +1074,7 @@ async function loadWebMediaInternal(
trustedGeneratedHtmlPath,
});
}
let fileName = resolveLocalMediaFileName(mediaUrl);
let fileName = basenameFromAnyPath(mediaUrl) || undefined;
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, InstalledPluginIndexRecord } from "./installed-plugin-index.js";
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
import {
loadPluginManifestRegistryForInstalledIndex,
resolveInstalledManifestRegistryIndexFingerprint,
@@ -151,24 +151,6 @@ 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();
@@ -198,97 +180,6 @@ 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,12 +32,8 @@ 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;
@@ -48,7 +44,6 @@ type InstalledPackageMetadata = {
export function clearInstalledManifestRegistryProcessCaches(): void {
installedPackageJsonPathCache.clear();
installedPackageMetadataCache.clear();
installedManifestRegistryRealpathCache.clear();
}
registerPluginMetadataProcessMemoLifecycleClear(clearInstalledManifestRegistryProcessCaches);
@@ -174,19 +169,6 @@ 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 {
@@ -214,6 +196,7 @@ function buildInstalledPackageMetadataCacheKey(params: {
}
function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) {
const realpathCache = new Map<string, string>();
return {
version: index.version,
hostContractVersion: index.hostContractVersion,
@@ -223,11 +206,7 @@ function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) {
installRecords: index.installRecords,
diagnostics: index.diagnostics,
plugins: index.plugins.map((record) => {
const packageJsonPath = resolvePackageJsonPath(
record,
installedManifestRegistryRealpathCache,
);
trimInstalledManifestRegistryRealpathCache();
const packageJsonPath = resolvePackageJsonPath(record, realpathCache);
const packageJsonFile = record.packageJson?.fileSignature
? packageJsonPath
? formatFileSignature(packageJsonPath, record.packageJson.fileSignature)

View File

@@ -593,17 +593,11 @@ 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",
@@ -724,8 +718,8 @@ export function buildStatusMessage(args: StatusArgs): string {
const runtimeDiffersFromSelected = activeModelLabel !== (modelRefs.selected.label || "unknown");
const selectedContextTokens = resolveContextTokensForModel({
cfg: contextConfig,
provider: selectedLookupProvider,
model: selectedLookupModel,
provider: selectedProvider,
model: selectedModel,
allowAsyncLoad: false,
});
const explicitRuntimeContextTokens =
@@ -746,8 +740,8 @@ export function buildStatusMessage(args: StatusArgs): string {
const channelModelNote = resolveChannelModelNote({
config: args.config,
entry,
selectedProvider: selectedLookupProvider,
selectedModel: selectedLookupModel,
selectedProvider,
selectedModel,
parentSessionKey: args.parentSessionKey,
});
const persistedContextTokens =
@@ -1013,7 +1007,7 @@ export function buildStatusMessage(args: StatusArgs): string {
{ config: args.config },
);
const selectedAuthMode =
normalizeAuthMode(args.modelAuth) ?? resolveModelAuthMode(selectedLookupProvider, args.config);
normalizeAuthMode(args.modelAuth) ?? resolveModelAuthMode(selectedProvider, args.config);
const rawSelectedAuthLabelValue =
selectedAuthMode && selectedAuthMode !== "unknown"
? (args.modelAuth ?? selectedAuthMode)

View File

@@ -49,7 +49,6 @@ 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";
@@ -353,35 +352,27 @@ 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,
selectedModel,
selectedProvider: provider,
selectedModel: model,
sessionEntry,
parseSelectedProvider,
});
const selectedLookupProvider = modelRefs.selected.provider || selectedProvider || provider;
const selectedLookupModel = modelRefs.selected.model || selectedModel || model;
const effectiveHarness =
params.resolvedHarness ??
(await resolveStatusHarnessId({
cfg,
provider: selectedLookupProvider,
model: selectedLookupModel,
provider,
model,
agentId: statusAgentId,
sessionKey,
sessionEntry,
}));
const selectedStatusProvider = resolveStatusRuntimeProvider({
provider: selectedLookupProvider,
provider,
effectiveHarness,
});
const selectedAuthProviders = listOpenAIAuthProfileProvidersForAgentRuntime({
provider: selectedLookupProvider,
provider,
harnessRuntime: effectiveHarness,
config: cfg,
});
@@ -424,12 +415,6 @@ 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,
@@ -441,20 +426,11 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
// labels differ; prefer the active auth label so status matches execution.
selectedModelAuth = activeModelAuth;
}
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 usageAuthLabel = modelRefs.activeDiffers ? activeModelAuth : selectedModelAuth;
const selectedUsageCredentialType = resolveUsageCredentialType(usageAuthLabel);
const useCodexSyntheticUsage =
shouldUseCodexSyntheticUsage({
provider: usageStatusProvider,
provider: activeStatusProvider,
effectiveHarness,
}) &&
(selectedUsageCredentialType === "oauth" || selectedUsageCredentialType === "token");
@@ -467,8 +443,8 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
: undefined;
const usageCredentialType = useCodexSyntheticUsage ? "token" : selectedUsageCredentialType;
const currentUsageProvider =
resolveUsageProviderId(usageStatusProvider, { credentialType: usageCredentialType }) ??
resolveUsageProviderId(usageProvider, { credentialType: usageCredentialType });
resolveUsageProviderId(activeStatusProvider, { credentialType: usageCredentialType }) ??
resolveUsageProviderId(activeProvider, { credentialType: usageCredentialType });
let usageLine: string | null = null;
if (
currentUsageProvider &&
@@ -614,21 +590,24 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
const selectedContextTokens = resolveStatusRuntimeContextTokens({
cfg,
provider: selectedStatusProvider,
model: modelRefs.selected.model || selectedLookupModel,
model,
});
const runtimeSnapshotHasFallbackProvenance =
!modelRefs.activeDiffers ||
hasSessionAutoModelFallbackProvenance(sessionEntry) ||
areRuntimeModelRefsEquivalent(modelRefs.active.label, modelRefs.selected.label, {
config: cfg,
});
const statusAgentContextTokens =
typeof contextTokens === "number" &&
contextTokens > 0 &&
(activeRuntimeIsAuthoritative ||
(runtimeSnapshotHasFallbackProvenance ||
contextTokens === configuredContextTokens ||
contextTokens === selectedContextTokens)
? contextTokens
: undefined;
const statusRuntimeContextTokens = activeRuntimeIsAuthoritative
? (runtimeContextTokens ??
(fallbackState.active && typeof contextTokens === "number" && contextTokens > 0
? contextTokens
: undefined))
const statusRuntimeContextTokens = runtimeSnapshotHasFallbackProvenance
? runtimeContextTokens
: undefined;
return buildStatusMessage({
config: cfg,

View File

@@ -1467,39 +1467,4 @@ 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,7 +185,9 @@ 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 { id?: unknown }).id === "string" &&
(typeof (entry as { timestamp?: unknown }).timestamp === "string" ||
typeof (entry as { timestamp?: unknown }).timestamp === "number"),
);
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, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, 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: failure.stderr ?? "",
stdout: failure.stdout ?? "",
stderr: String(failure.stderr ?? ""),
stdout: String(failure.stdout ?? ""),
};
}
throw error;
@@ -83,34 +83,6 @@ 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(
@@ -129,13 +101,6 @@ function splitNonEmptyLines(output: string): string[] {
return lines;
}
function readFormatterLog(logPath: string): string[] {
if (!existsSync(logPath)) {
return [];
}
return splitNonEmptyLines(readFileSync(logPath, "utf8"));
}
afterEach(() => {
cleanupTempDirs(tempDirs);
});
@@ -163,100 +128,6 @@ 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,57 +14,39 @@ function runSurfaceReport(env: Record<string, string>) {
});
}
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 {
function readCurrentPublicFunctionExportCount() {
const result = runSurfaceReport({});
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
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 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");
}
return {
exports: Number(totalsMatch[1]),
callableExports: Number(totalsMatch[2]),
wildcardReexports: Number(wildcardsMatch[1]),
exports: Number(match[1]),
functionExports: Number(match[2]),
};
}
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(
@@ -133,12 +115,20 @@ describe("plugin SDK surface report", () => {
expect(result.stderr).toBe("");
});
it("keeps default public surface budgets pinned to current source counts", () => {
expect(readDefaultPublicSurfaceBudgets()).toEqual(readCurrentPublicSurfaceCounts());
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 generated package declarations out of source surface counts", () => {
const budget = readCurrentPublicSurfaceCounts().callableExports;
const budget = readCurrentPublicFunctionExportCount();
const result = runSurfaceReport({
OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS: String(budget - 1),
});