Compare commits

..

2 Commits

Author SHA1 Message Date
Peter Steinberger
16e6789dd5 fix: preserve inline code copy fidelity in web ui (#32346) (thanks @hclsys) 2026-03-03 02:04:36 +00:00
HCL
ba99fda951 fix(webui): prevent inline code from breaking mid-token on copy/paste
The parent `.chat-text` applies `overflow-wrap: anywhere; word-break: break-word;`
which forces long tokens (UUIDs, hashes) inside inline `<code>` to break across
visual lines. When copied, the browser injects spaces at those break points,
corrupting the pasted value.

Override with `overflow-wrap: normal; word-break: keep-all;` on inline `<code>`
selectors so tokens stay intact.

Fixes #32230

Signed-off-by: HCL <chenglunhu@gmail.com>
2026-03-03 02:04:09 +00:00
69 changed files with 1682 additions and 2435 deletions

View File

@@ -223,8 +223,8 @@ jobs:
# Types, lint, and format check.
check:
name: "check"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout

View File

@@ -41,7 +41,6 @@ Docs: https://docs.openclaw.ai
- Slack/socket auth failure handling: fail fast on non-recoverable auth errors (`account_inactive`, `invalid_auth`, etc.) during startup and reconnect instead of retry-looping indefinitely, including `unable_to_socket_mode_start` error payload propagation. (#32377) Thanks @scoootscooob.
- CLI/installer Node preflight: enforce Node.js `v22.12+` consistently in both `openclaw.mjs` runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.
- Web UI/inline code copy fidelity: disable forced mid-token wraps on inline `<code>` spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.
- Agents/host edit reliability: treat host edit-tool throws as success only when on-disk post-check confirms replacement likely happened (`newText` present and `oldText` absent), preventing false failure reports while avoiding pre-write false positives. (#32383) Thanks @polooooo.
- Gateway/message tool reliability: avoid false `Unknown channel` failures when `message.*` actions receive platform-specific channel ids by falling back to `toolContext.currentChannelProvider`, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.
- Discord/lifecycle startup status: push an immediate `connected` status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.
- Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel `resolveDefaultTo` fallback) when `delivery.to` is omitted. (#32364) Thanks @hclsys.
@@ -52,7 +51,6 @@ Docs: https://docs.openclaw.ai
- OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty `function_call_output.call_id` payloads in the WS conversion path to avoid OpenAI 400 errors (`Invalid 'input[n].call_id': empty string`), with regression coverage for both inbound stream normalization and outbound payload guards.
- Gateway/Control UI basePath webhook passthrough: let non-read methods under configured `controlUiBasePath` fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.
- CLI/Config validation and routing hardening: dedupe `openclaw config validate` failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including `--json` fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed `config get/unset` with split root options). Thanks @gumadeiras.
- Context-window metadata warmup: add exponential config-load retry backoff (1s -> 2s -> 4s, capped at 60s) so transient startup failures recover automatically without hot-loop retries.
- Models/config env propagation: apply `config.env.vars` before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.
- Hooks/runtime stability: keep the internal hook handler registry on a `globalThis` singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.
- Hooks/plugin context parity: ensure `llm_input` hooks in embedded attempts receive the same `trigger` and `channelId`-aware `hookCtx` used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.
@@ -122,7 +120,6 @@ Docs: https://docs.openclaw.ai
- Discord/audio preflight mentions: detect audio attachments via Discord `content_type` and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.
- Memory/LanceDB embeddings: forward configured `embedding.dimensions` into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
- Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
- Failover/network resilience: classify connection and DNS failures (`fetch failed`, `ECONN*`, `ENOTFOUND`, `EAI_AGAIN`) as retryable timeout errors so provider fallback can advance instead of stalling on transient network outages. (#31697) Thanks @haotian2546.
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
- Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (`pnpm`, `bun`) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.
- Web UI/config form: support SecretInput string-or-secret-ref unions in map `additionalProperties`, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.

View File

@@ -72,7 +72,7 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
# Update OPENCLAW_DOCKER_GPG_FINGERPRINT when Docker rotates release keys.
curl -fsSL https://download.docker.com/linux/debian/gpg -o /tmp/docker.gpg.asc && \
expected_fingerprint="$(printf '%s' "$OPENCLAW_DOCKER_GPG_FINGERPRINT" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')" && \
actual_fingerprint="$(gpg --batch --show-keys --with-colons /tmp/docker.gpg.asc | awk -F: '$1 == "fpr" { print toupper($10); exit }')" && \
actual_fingerprint="$(gpg --batch --show-keys --with-colons /tmp/docker.gpg.asc | awk -F: '$1 == \"fpr\" { print toupper($10); exit }')" && \
if [ -z "$actual_fingerprint" ] || [ "$actual_fingerprint" != "$expected_fingerprint" ]; then \
echo "ERROR: Docker apt key fingerprint mismatch (expected $expected_fingerprint, got ${actual_fingerprint:-<empty>})" >&2; \
exit 1; \

View File

@@ -13,20 +13,20 @@ The CI runs on every push to `main` and every pull request. It uses smart scopin
## Job Overview
| Job | Purpose | When it runs |
| ----------------- | ----------------------------------------------- | ------------------------------------------------- |
| `docs-scope` | Detect docs-only changes | Always |
| `changed-scope` | Detect which areas changed (node/macos/android) | Non-docs PRs |
| `check` | TypeScript types, lint, format | Push to `main`, or PRs with Node-relevant changes |
| `check-docs` | Markdown lint + broken link check | Docs changed |
| `code-analysis` | LOC threshold check (1000 lines) | PRs only |
| `secrets` | Detect leaked secrets | Always |
| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes |
| `release-check` | Validate npm pack contents | After build |
| `checks` | Node/Bun tests + protocol check | Non-docs, node changes |
| `checks-windows` | Windows-specific tests | Non-docs, node changes |
| `macos` | Swift lint/build/test + TS tests | PRs with macos changes |
| `android` | Gradle build + tests | Non-docs, android changes |
| Job | Purpose | When it runs |
| ----------------- | ----------------------------------------------- | ------------------------- |
| `docs-scope` | Detect docs-only changes | Always |
| `changed-scope` | Detect which areas changed (node/macos/android) | Non-docs PRs |
| `check` | TypeScript types, lint, format | Non-docs changes |
| `check-docs` | Markdown lint + broken link check | Docs changed |
| `code-analysis` | LOC threshold check (1000 lines) | PRs only |
| `secrets` | Detect leaked secrets | Always |
| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes |
| `release-check` | Validate npm pack contents | After build |
| `checks` | Node/Bun tests + protocol check | Non-docs, node changes |
| `checks-windows` | Windows-specific tests | Non-docs, node changes |
| `macos` | Swift lint/build/test + TS tests | PRs with macos changes |
| `android` | Gradle build + tests | Non-docs, android changes |
## Fail-Fast Order

View File

@@ -313,7 +313,7 @@ See [Configuration Reference](/gateway/configuration-reference).
Install and enable plugin:
```bash
openclaw plugins install acpx
openclaw plugins install @openclaw/acpx
openclaw config set plugins.entries.acpx.enabled true
```

View File

@@ -1,8 +1,8 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { fetchBlueBubblesHistory } from "./history.js";
import {
@@ -94,15 +94,47 @@ const mockResolveChunkMode = vi.fn(() => "length");
const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
function createMockRuntime(): PluginRuntime {
return createPluginRuntimeMock({
return {
version: "1.0.0",
config: {
loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"],
writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
},
system: {
enqueueSystemEvent:
mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"],
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
formatNativeDependencyHint: vi.fn(
() => "",
) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"],
},
media: {
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"],
mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"],
isVoiceCompatibleAudio:
vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
},
tts: {
textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
},
stt: {
transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"],
},
tools: {
createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
createMemorySearchTool:
vi.fn() as unknown as PluginRuntime["tools"]["createMemorySearchTool"],
registerMemoryCli: vi.fn() as unknown as PluginRuntime["tools"]["registerMemoryCli"],
},
channel: {
text: {
chunkMarkdownText:
mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"],
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
chunkByNewline:
mockChunkByNewline as unknown as PluginRuntime["channel"]["text"]["chunkByNewline"],
chunkMarkdownTextWithMode:
@@ -111,12 +143,50 @@ function createMockRuntime(): PluginRuntime {
mockChunkTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkTextWithMode"],
resolveChunkMode:
mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
resolveTextChunkLimit: vi.fn(
() => 4000,
) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
hasControlCommand:
mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
resolveMarkdownTableMode: vi.fn(
() => "code",
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
convertMarkdownTables: vi.fn(
(text: string) => text,
) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"],
},
reply: {
dispatchReplyWithBufferedBlockDispatcher:
mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
createReplyDispatcherWithTyping:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"],
resolveEffectiveMessagesConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveEffectiveMessagesConfig"],
resolveHumanDelayConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
dispatchReplyFromConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
withReplyDispatcher: vi.fn(
async ({
dispatcher,
run,
onSettled,
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
try {
return await run();
} finally {
dispatcher.markComplete();
try {
await dispatcher.waitForIdle();
} finally {
await onSettled?.();
}
}
},
) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
finalizeInboundContext: vi.fn(
(ctx: Record<string, unknown>) => ctx,
) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
formatAgentEnvelope:
mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
formatInboundEnvelope:
@@ -137,6 +207,8 @@ function createMockRuntime(): PluginRuntime {
mockUpsertPairingRequest as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
},
media: {
fetchRemoteMedia:
vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
saveMediaBuffer:
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
@@ -145,6 +217,12 @@ function createMockRuntime(): PluginRuntime {
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
readSessionUpdatedAt:
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
recordInboundSession:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
recordSessionMetaFromInbound:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
updateLastRoute:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
},
mentions: {
buildMentionRegexes:
@@ -154,18 +232,72 @@ function createMockRuntime(): PluginRuntime {
matchesMentionWithExplicit:
mockMatchesMentionWithExplicit as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
},
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: {
resolveGroupPolicy:
mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention:
mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
},
debounce: {
// Create a pass-through debouncer that immediately calls onFlush
createInboundDebouncer: vi.fn(
(params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
enqueue: async (item: unknown) => {
await params.onFlush([item]);
},
flushKey: vi.fn(),
}),
) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
resolveInboundDebounceMs: vi.fn(
() => 0,
) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
},
commands: {
resolveCommandAuthorizedFromAuthorizers:
mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
isControlCommandMessage:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
shouldComputeCommandAuthorized:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
shouldHandleTextCommands:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
},
discord: {} as PluginRuntime["channel"]["discord"],
activity: {} as PluginRuntime["channel"]["activity"],
line: {} as PluginRuntime["channel"]["line"],
slack: {} as PluginRuntime["channel"]["slack"],
telegram: {} as PluginRuntime["channel"]["telegram"],
signal: {} as PluginRuntime["channel"]["signal"],
imessage: {} as PluginRuntime["channel"]["imessage"],
whatsapp: {} as PluginRuntime["channel"]["whatsapp"],
},
});
events: {
onAgentEvent: vi.fn(() => () => {}) as unknown as PluginRuntime["events"]["onAgentEvent"],
onSessionTranscriptUpdate: vi.fn(
() => () => {},
) as unknown as PluginRuntime["events"]["onSessionTranscriptUpdate"],
},
logging: {
shouldLogVerbose: vi.fn(
() => false,
) as unknown as PluginRuntime["logging"]["shouldLogVerbose"],
getChildLogger: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})) as unknown as PluginRuntime["logging"]["getChildLogger"],
},
state: {
resolveStateDir: vi.fn(
() => "/tmp/openclaw",
) as unknown as PluginRuntime["state"]["resolveStateDir"],
},
};
}
function createMockAccount(

View File

@@ -239,15 +239,14 @@ describe("loginGeminiCliOAuth", () => {
"GOOGLE_CLOUD_PROJECT_ID",
] as const;
function getExpectedPlatform(): "WINDOWS" | "MACOS" | "PLATFORM_UNSPECIFIED" {
function getExpectedPlatform(): "WINDOWS" | "MACOS" | "LINUX" {
if (process.platform === "win32") {
return "WINDOWS";
}
if (process.platform === "darwin") {
return "MACOS";
if (process.platform === "linux") {
return "LINUX";
}
// Matches updated resolvePlatform() which uses PLATFORM_UNSPECIFIED for Linux
return "PLATFORM_UNSPECIFIED";
return "MACOS";
}
function getRequestUrl(input: string | URL | Request): string {

View File

@@ -224,16 +224,14 @@ function generatePkce(): { verifier: string; challenge: string } {
return { verifier, challenge };
}
function resolvePlatform(): "WINDOWS" | "MACOS" | "PLATFORM_UNSPECIFIED" {
function resolvePlatform(): "WINDOWS" | "MACOS" | "LINUX" {
if (process.platform === "win32") {
return "WINDOWS";
}
if (process.platform === "darwin") {
return "MACOS";
if (process.platform === "linux") {
return "LINUX";
}
// Google's loadCodeAssist API rejects "LINUX" as an invalid Platform enum value.
// Use "PLATFORM_UNSPECIFIED" for Linux and other platforms to match the pi-ai runtime.
return "PLATFORM_UNSPECIFIED";
return "MACOS";
}
async function fetchWithTimeout(

View File

@@ -1,248 +0,0 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
import { vi } from "vitest";
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends (...args: never[]) => unknown
? T[K]
: T[K] extends ReadonlyArray<unknown>
? T[K]
: T[K] extends object
? DeepPartial<T[K]>
: T[K];
};
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function mergeDeep<T>(base: T, overrides: DeepPartial<T>): T {
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
for (const [key, overrideValue] of Object.entries(overrides as Record<string, unknown>)) {
if (overrideValue === undefined) {
continue;
}
const baseValue = result[key];
if (isObject(baseValue) && isObject(overrideValue)) {
result[key] = mergeDeep(baseValue, overrideValue);
continue;
}
result[key] = overrideValue;
}
return result as T;
}
export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
const base: PluginRuntime = {
version: "1.0.0-test",
config: {
loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"],
writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
},
system: {
enqueueSystemEvent: vi.fn() as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"],
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
formatNativeDependencyHint: vi.fn(
() => "",
) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"],
},
media: {
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"],
mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"],
isVoiceCompatibleAudio:
vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
},
tts: {
textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
},
stt: {
transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"],
},
tools: {
createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
createMemorySearchTool:
vi.fn() as unknown as PluginRuntime["tools"]["createMemorySearchTool"],
registerMemoryCli: vi.fn() as unknown as PluginRuntime["tools"]["registerMemoryCli"],
},
channel: {
text: {
chunkByNewline: vi.fn((text: string) => (text ? [text] : [])),
chunkMarkdownText: vi.fn((text: string) => [text]),
chunkMarkdownTextWithMode: vi.fn((text: string) => (text ? [text] : [])),
chunkText: vi.fn((text: string) => (text ? [text] : [])),
chunkTextWithMode: vi.fn((text: string) => (text ? [text] : [])),
resolveChunkMode: vi.fn(
() => "length",
) as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
resolveTextChunkLimit: vi.fn(() => 4000),
hasControlCommand: vi.fn(() => false),
resolveMarkdownTableMode: vi.fn(
() => "code",
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
convertMarkdownTables: vi.fn((text: string) => text),
},
reply: {
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
async () => undefined,
) as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
createReplyDispatcherWithTyping:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"],
resolveEffectiveMessagesConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveEffectiveMessagesConfig"],
resolveHumanDelayConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
dispatchReplyFromConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
withReplyDispatcher: vi.fn(async ({ dispatcher, run, onSettled }) => {
try {
return await run();
} finally {
dispatcher.markComplete();
try {
await dispatcher.waitForIdle();
} finally {
await onSettled?.();
}
}
}) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
finalizeInboundContext: vi.fn(
(ctx: Record<string, unknown>) => ctx,
) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
formatAgentEnvelope: vi.fn(
(opts: { body: string }) => opts.body,
) as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
formatInboundEnvelope: vi.fn(
(opts: { body: string }) => opts.body,
) as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
resolveEnvelopeFormatOptions: vi.fn(() => ({
template: "channel+name+time",
})) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
},
routing: {
resolveAgentRoute: vi.fn(() => ({
agentId: "main",
accountId: "default",
sessionKey: "agent:main:test:dm:peer",
})) as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
pairing: {
buildPairingReply: vi.fn(
() => "Pairing code: TESTCODE",
) as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"],
readAllowFromStore: vi
.fn()
.mockResolvedValue(
[],
) as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"],
upsertPairingRequest: vi.fn().mockResolvedValue({
code: "TESTCODE",
created: true,
}) as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
},
media: {
fetchRemoteMedia:
vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
saveMediaBuffer: vi.fn().mockResolvedValue({
path: "/tmp/test-media.jpg",
contentType: "image/jpeg",
}) as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
session: {
resolveStorePath: vi.fn(
() => "/tmp/sessions.json",
) as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
readSessionUpdatedAt: vi.fn(
() => undefined,
) as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
recordSessionMetaFromInbound:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
recordInboundSession:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
updateLastRoute:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
},
mentions: {
buildMentionRegexes: vi.fn(() => [
/\bbert\b/i,
]) as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
matchesMentionPatterns: vi.fn((text: string, regexes: RegExp[]) =>
regexes.some((regex) => regex.test(text)),
) as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
matchesMentionWithExplicit: vi.fn(
(params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) =>
params.explicitWasMentioned === true
? true
: params.mentionRegexes.some((regex) => regex.test(params.text)),
) as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
},
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: {
resolveGroupPolicy: vi.fn(
() => "open",
) as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention: vi.fn(
() => false,
) as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
},
debounce: {
createInboundDebouncer: vi.fn(
(params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
enqueue: async (item: unknown) => {
await params.onFlush([item]);
},
flushKey: vi.fn(),
}),
) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
resolveInboundDebounceMs: vi.fn(
() => 0,
) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
},
commands: {
resolveCommandAuthorizedFromAuthorizers: vi.fn(
() => false,
) as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
isControlCommandMessage:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
shouldComputeCommandAuthorized:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
shouldHandleTextCommands:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
},
discord: {} as PluginRuntime["channel"]["discord"],
activity: {} as PluginRuntime["channel"]["activity"],
line: {} as PluginRuntime["channel"]["line"],
slack: {} as PluginRuntime["channel"]["slack"],
telegram: {} as PluginRuntime["channel"]["telegram"],
signal: {} as PluginRuntime["channel"]["signal"],
imessage: {} as PluginRuntime["channel"]["imessage"],
whatsapp: {} as PluginRuntime["channel"]["whatsapp"],
},
events: {
onAgentEvent: vi.fn(() => () => {}) as unknown as PluginRuntime["events"]["onAgentEvent"],
onSessionTranscriptUpdate: vi.fn(
() => () => {},
) as unknown as PluginRuntime["events"]["onSessionTranscriptUpdate"],
},
logging: {
shouldLogVerbose: vi.fn(() => false),
getChildLogger: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})),
},
state: {
resolveStateDir: vi.fn(() => "/tmp/openclaw"),
},
};
return mergeDeep(base, overrides);
}

View File

@@ -4,27 +4,18 @@ import module from "node:module";
const MIN_NODE_MAJOR = 22;
const MIN_NODE_MINOR = 12;
const MIN_NODE_VERSION = `${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}`;
const parseNodeVersion = (rawVersion) => {
const [majorRaw = "0", minorRaw = "0"] = rawVersion.split(".");
return {
major: Number(majorRaw),
minor: Number(minorRaw),
};
};
const isSupportedNodeVersion = (version) =>
version.major > MIN_NODE_MAJOR ||
(version.major === MIN_NODE_MAJOR && version.minor >= MIN_NODE_MINOR);
const ensureSupportedNodeVersion = () => {
if (isSupportedNodeVersion(parseNodeVersion(process.versions.node))) {
const [majorRaw = "0", minorRaw = "0"] = process.versions.node.split(".");
const major = Number(majorRaw);
const minor = Number(minorRaw);
const supported = major > MIN_NODE_MAJOR || (major === MIN_NODE_MAJOR && minor >= MIN_NODE_MINOR);
if (supported) {
return;
}
process.stderr.write(
`openclaw: Node.js v${MIN_NODE_VERSION}+ is required (current: v${process.versions.node}).\n` +
`openclaw: Node.js v${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}+ is required (current: v${process.versions.node}).\n` +
"If you use nvm, run:\n" +
" nvm install 22\n" +
" nvm use 22\n" +

View File

@@ -16,9 +16,6 @@ MUTED='\033[38;2;90;100;128m' # text-muted #5a6480
NC='\033[0m' # No Color
DEFAULT_TAGLINE="All your chats, one OpenClaw."
NODE_MIN_MAJOR=22
NODE_MIN_MINOR=12
NODE_MIN_VERSION="${NODE_MIN_MAJOR}.${NODE_MIN_MINOR}"
ORIGINAL_PATH="${PATH:-}"
@@ -1250,10 +1247,26 @@ install_homebrew() {
}
# Check Node.js version
parse_node_version_components() {
node_major_version() {
if ! command -v node &> /dev/null; then
return 1
fi
local version major
version="$(node -v 2>/dev/null || true)"
major="${version#v}"
major="${major%%.*}"
if [[ "$major" =~ ^[0-9]+$ ]]; then
echo "$major"
return 0
fi
return 1
}
node_is_at_least_22_12() {
if ! command -v node &> /dev/null; then
return 1
fi
local version major minor
version="$(node -v 2>/dev/null || true)"
major="${version#v}"
@@ -1268,32 +1281,11 @@ parse_node_version_components() {
if [[ ! "$minor" =~ ^[0-9]+$ ]]; then
return 1
fi
echo "${major} ${minor}"
return 0
}
node_major_version() {
local version_components major minor
version_components="$(parse_node_version_components || true)"
read -r major minor <<< "$version_components"
if [[ "$major" =~ ^[0-9]+$ && "$minor" =~ ^[0-9]+$ ]]; then
echo "$major"
if [[ "$major" -gt 22 ]]; then
return 0
fi
return 1
}
node_is_at_least_required() {
local version_components major minor
version_components="$(parse_node_version_components || true)"
read -r major minor <<< "$version_components"
if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ ]]; then
return 1
fi
if [[ "$major" -gt "$NODE_MIN_MAJOR" ]]; then
return 0
fi
if [[ "$major" -eq "$NODE_MIN_MAJOR" && "$minor" -ge "$NODE_MIN_MINOR" ]]; then
if [[ "$major" -eq 22 && "$minor" -ge 12 ]]; then
return 0
fi
return 1
@@ -1351,7 +1343,7 @@ ensure_macos_node22_active() {
}
ensure_node22_active_shell() {
if node_is_at_least_required; then
if node_is_at_least_22_12; then
return 0
fi
@@ -1359,7 +1351,7 @@ ensure_node22_active_shell() {
active_path="$(command -v node 2>/dev/null || echo "not found")"
active_version="$(node -v 2>/dev/null || echo "missing")"
ui_error "Active Node.js must be v${NODE_MIN_VERSION}+ but this shell is using ${active_version} (${active_path})"
ui_error "Active Node.js must be v22.12+ but this shell is using ${active_version} (${active_path})"
print_active_node_paths || true
local nvm_detected=0
@@ -1388,15 +1380,15 @@ ensure_node22_active_shell() {
check_node() {
if command -v node &> /dev/null; then
NODE_VERSION="$(node_major_version || true)"
if node_is_at_least_required; then
if node_is_at_least_22_12; then
ui_success "Node.js v$(node -v | cut -d'v' -f2) found"
print_active_node_paths || true
return 0
else
if [[ -n "$NODE_VERSION" ]]; then
ui_info "Node.js $(node -v) found, upgrading to v${NODE_MIN_VERSION}+"
ui_info "Node.js $(node -v) found, upgrading to v22.12+"
else
ui_info "Node.js found but version could not be parsed; reinstalling v${NODE_MIN_VERSION}+"
ui_info "Node.js found but version could not be parsed; reinstalling v22.12+"
fi
return 1
fi

View File

@@ -29,9 +29,6 @@ Generate a handful of “random but structured” prompts and render them via th
## Run
Note: Image generation can take longer than common exec timeouts (for example 30 seconds).
When invoking this skill via OpenClaws exec tool, set a higher timeout to avoid premature termination/retries (e.g., exec timeout=300).
```bash
python3 {baseDir}/scripts/gen.py
open ~/Projects/tmp/openai-image-gen-*/index.html # if ~/Projects/tmp exists; else ./tmp/...

View File

@@ -535,8 +535,8 @@ export async function runExecProcess(opts: {
: "Command not executable (permission denied)"
: exit.reason === "overall-timeout"
? typeof opts.timeoutSec === "number" && opts.timeoutSec > 0
? `Command timed out after ${opts.timeoutSec} seconds. If this command is expected to take longer, re-run with a higher timeout (e.g., exec timeout=300).`
: "Command timed out. If this command is expected to take longer, re-run with a higher timeout (e.g., exec timeout=300)."
? `Command timed out after ${opts.timeoutSec} seconds`
: "Command timed out"
: exit.reason === "no-output-timeout"
? "Command timed out waiting for output"
: exit.exitSignal != null

View File

@@ -458,9 +458,6 @@ describe("exec tool backgrounding", () => {
allowBackground: false,
});
await expect(executeExecCommand(customBash, longDelayCmd)).rejects.toThrow(/timed out/i);
await expect(executeExecCommand(customBash, longDelayCmd)).rejects.toThrow(
/re-run with a higher timeout/i,
);
});
it.each<DisallowedElevationCase>(DISALLOWED_ELEVATION_CASES)(

View File

@@ -61,54 +61,4 @@ describe("lookupContextTokens", () => {
process.argv = argvSnapshot;
}
});
it("retries config loading after backoff when an initial load fails", async () => {
vi.useFakeTimers();
const loadConfigMock = vi
.fn()
.mockImplementationOnce(() => {
throw new Error("transient");
})
.mockImplementation(() => ({
models: {
providers: {
openrouter: {
models: [{ id: "openrouter/claude-sonnet", contextWindow: 654_321 }],
},
},
},
}));
vi.doMock("../config/config.js", () => ({
loadConfig: loadConfigMock,
}));
vi.doMock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn(async () => {}),
}));
vi.doMock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
}));
vi.doMock("./pi-model-discovery.js", () => ({
discoverAuthStorage: vi.fn(() => ({})),
discoverModels: vi.fn(() => ({
getAll: () => [],
})),
}));
const argvSnapshot = process.argv;
process.argv = ["node", "openclaw", "config", "validate"];
try {
const { lookupContextTokens } = await import("./context.js");
expect(lookupContextTokens("openrouter/claude-sonnet")).toBeUndefined();
expect(loadConfigMock).toHaveBeenCalledTimes(1);
expect(lookupContextTokens("openrouter/claude-sonnet")).toBeUndefined();
expect(loadConfigMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_000);
expect(lookupContextTokens("openrouter/claude-sonnet")).toBe(654_321);
expect(loadConfigMock).toHaveBeenCalledTimes(2);
} finally {
process.argv = argvSnapshot;
vi.useRealTimers();
}
});
});

View File

@@ -3,7 +3,6 @@
import { loadConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/config.js";
import { computeBackoff, type BackoffPolicy } from "../infra/backoff.js";
import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
@@ -20,12 +19,6 @@ type AgentModelEntry = { params?: Record<string, unknown> };
const ANTHROPIC_1M_MODEL_PREFIXES = ["claude-opus-4", "claude-sonnet-4"] as const;
export const ANTHROPIC_CONTEXT_1M_TOKENS = 1_048_576;
const CONFIG_LOAD_RETRY_POLICY: BackoffPolicy = {
initialMs: 1_000,
maxMs: 60_000,
factor: 2,
jitter: 0,
};
export function applyDiscoveredContextWindows(params: {
cache: Map<string, number>;
@@ -75,9 +68,7 @@ export function applyConfiguredContextWindows(params: {
const MODEL_CACHE = new Map<string, number>();
let loadPromise: Promise<void> | null = null;
let configuredConfig: OpenClawConfig | undefined;
let configLoadFailures = 0;
let nextConfigLoadAttemptAtMs = 0;
let configuredWindowsPrimed = false;
function getCommandPathFromArgv(argv: string[]): string[] {
const args = argv.slice(2);
@@ -109,42 +100,33 @@ function shouldSkipEagerContextWindowWarmup(argv: string[] = process.argv): bool
}
function primeConfiguredContextWindows(): OpenClawConfig | undefined {
if (configuredConfig) {
return configuredConfig;
}
if (Date.now() < nextConfigLoadAttemptAtMs) {
if (configuredWindowsPrimed) {
return undefined;
}
configuredWindowsPrimed = true;
try {
const cfg = loadConfig();
applyConfiguredContextWindows({
cache: MODEL_CACHE,
modelsConfig: cfg.models as ModelsConfig | undefined,
});
configuredConfig = cfg;
configLoadFailures = 0;
nextConfigLoadAttemptAtMs = 0;
return cfg;
} catch {
configLoadFailures += 1;
const backoffMs = computeBackoff(CONFIG_LOAD_RETRY_POLICY, configLoadFailures);
nextConfigLoadAttemptAtMs = Date.now() + backoffMs;
// If config can't be loaded, leave cache empty and retry after backoff.
// If config can't be loaded, leave cache empty.
return undefined;
}
}
function ensureContextWindowCacheLoaded(): Promise<void> {
const cfg = primeConfiguredContextWindows();
if (loadPromise) {
return loadPromise;
}
const cfg = primeConfiguredContextWindows();
if (!cfg) {
return Promise.resolve();
}
loadPromise = (async () => {
if (!cfg) {
return;
}
try {
await ensureOpenClawModelsJson(cfg);
} catch {

View File

@@ -48,22 +48,6 @@ describe("failover-error", () => {
expect(resolveFailoverReasonFromError({ message: "reason: error" })).toBe("timeout");
});
it("infers timeout from connection/network error messages", () => {
expect(resolveFailoverReasonFromError({ message: "Connection error." })).toBe("timeout");
expect(resolveFailoverReasonFromError({ message: "fetch failed" })).toBe("timeout");
expect(resolveFailoverReasonFromError({ message: "Network error: ECONNREFUSED" })).toBe(
"timeout",
);
expect(
resolveFailoverReasonFromError({
message: "dial tcp: lookup api.example.com: no such host (ENOTFOUND)",
}),
).toBe("timeout");
expect(resolveFailoverReasonFromError({ message: "temporary dns failure EAI_AGAIN" })).toBe(
"timeout",
);
});
it("treats AbortError reason=abort as timeout", () => {
const err = Object.assign(new Error("aborted"), {
name: "AbortError",

View File

@@ -6,7 +6,7 @@ import {
} from "./pi-embedded-helpers.js";
const TIMEOUT_HINT_RE =
/timeout|timed out|deadline exceeded|context deadline exceeded|connection error|network error|network request failed|fetch failed|socket hang up|econnrefused|econnreset|econnaborted|enotfound|eai_again|stop reason:\s*(?:abort|error)|reason:\s*(?:abort|error)|unhandled stop reason:\s*(?:abort|error)/i;
/timeout|timed out|deadline exceeded|context deadline exceeded|stop reason:\s*(?:abort|error)|reason:\s*(?:abort|error)|unhandled stop reason:\s*(?:abort|error)/i;
const ABORT_TIMEOUT_RE = /request was aborted|request aborted/i;
export class FailoverError extends Error {

View File

@@ -415,7 +415,6 @@ describe("isFailoverErrorMessage", () => {
"429 rate limit exceeded",
"Your credit balance is too low",
"request timed out",
"Connection error.",
"invalid request format",
];
for (const sample of samples) {
@@ -495,13 +494,6 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("credit balance too low")).toBe("billing");
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
expect(classifyFailoverReason("request ended without sending any chunks")).toBe("timeout");
expect(classifyFailoverReason("Connection error.")).toBe("timeout");
expect(classifyFailoverReason("fetch failed")).toBe("timeout");
expect(classifyFailoverReason("network error: ECONNREFUSED")).toBe("timeout");
expect(
classifyFailoverReason("dial tcp: lookup api.example.com: no such host (ENOTFOUND)"),
).toBe("timeout");
expect(classifyFailoverReason("temporary dns failure EAI_AGAIN")).toBe("timeout");
expect(
classifyFailoverReason(
"521 <!DOCTYPE html><html><head><title>Web server is down</title></head><body>Cloudflare</body></html>",

View File

@@ -5,7 +5,6 @@ import {
sanitizeGoogleTurnOrdering,
sanitizeSessionMessagesImages,
} from "./pi-embedded-helpers.js";
import { castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
let testTimestamp = 1;
const nextTimestamp = () => testTimestamp++;
@@ -94,7 +93,7 @@ describe("sanitizeSessionMessagesImages", () => {
});
it("does not synthesize tool call input when missing", async () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read" }],
@@ -112,7 +111,7 @@ describe("sanitizeSessionMessagesImages", () => {
stopReason: "toolUse",
timestamp: nextTimestamp(),
},
]);
] as unknown as AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
const assistant = out[0] as { content?: Array<Record<string, unknown>> };
@@ -123,7 +122,7 @@ describe("sanitizeSessionMessagesImages", () => {
});
it("removes empty assistant text blocks but preserves tool calls", async () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [
@@ -144,7 +143,7 @@ describe("sanitizeSessionMessagesImages", () => {
stopReason: "toolUse",
timestamp: nextTimestamp(),
},
]);
] as AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
@@ -154,7 +153,7 @@ describe("sanitizeSessionMessagesImages", () => {
});
it("sanitizes tool ids in strict mode (alphanumeric only)", async () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [
@@ -172,7 +171,7 @@ describe("sanitizeSessionMessagesImages", () => {
toolUseId: "call_abc|item:123",
content: [{ type: "text", text: "ok" }],
},
]);
] as unknown as AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test", {
sanitizeToolCallIds: true,
@@ -189,7 +188,7 @@ describe("sanitizeSessionMessagesImages", () => {
});
it("sanitizes tool IDs in images-only mode when explicitly enabled", async () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_123|fc_456", name: "read", arguments: {} }],
@@ -215,7 +214,7 @@ describe("sanitizeSessionMessagesImages", () => {
isError: false,
timestamp: nextTimestamp(),
},
]);
] as AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test", {
sanitizeMode: "images-only",
@@ -237,7 +236,7 @@ describe("sanitizeSessionMessagesImages", () => {
}
});
it("filters whitespace-only assistant text blocks", async () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [
@@ -258,7 +257,7 @@ describe("sanitizeSessionMessagesImages", () => {
stopReason: "stop",
timestamp: nextTimestamp(),
},
]);
] as AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
@@ -267,7 +266,7 @@ describe("sanitizeSessionMessagesImages", () => {
});
});
it("drops assistant messages that only contain empty text", async () => {
const input = castAgentMessages([
const input = [
{ role: "user", content: "hello", timestamp: nextTimestamp() } satisfies UserMessage,
{
role: "assistant",
@@ -286,7 +285,7 @@ describe("sanitizeSessionMessagesImages", () => {
stopReason: "stop",
timestamp: nextTimestamp(),
} satisfies AssistantMessage,
]);
];
const out = await sanitizeSessionMessagesImages(input, "test");
@@ -294,7 +293,7 @@ describe("sanitizeSessionMessagesImages", () => {
expect(out[0]?.role).toBe("user");
});
it("keeps empty assistant error messages", async () => {
const input = castAgentMessages([
const input = [
{ role: "user", content: "hello", timestamp: nextTimestamp() } satisfies UserMessage,
{
role: "assistant",
@@ -330,7 +329,7 @@ describe("sanitizeSessionMessagesImages", () => {
},
timestamp: nextTimestamp(),
} satisfies AssistantMessage,
]);
] as unknown as AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
@@ -361,7 +360,7 @@ describe("sanitizeSessionMessagesImages", () => {
describe("thought_signature stripping", () => {
it("strips msg_-prefixed thought_signature from assistant message content blocks", async () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [
@@ -373,7 +372,7 @@ describe("sanitizeSessionMessagesImages", () => {
},
],
},
]);
] as unknown as AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
@@ -388,19 +387,19 @@ describe("sanitizeSessionMessagesImages", () => {
describe("sanitizeGoogleTurnOrdering", () => {
it("prepends a synthetic user turn when history starts with assistant", () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "exec", arguments: {} }],
},
]);
] as unknown as AgentMessage[];
const out = sanitizeGoogleTurnOrdering(input);
expect(out[0]?.role).toBe("user");
expect(out[1]?.role).toBe("assistant");
});
it("is a no-op when history starts with user", () => {
const input = castAgentMessages([{ role: "user", content: "hi" }]);
const input = [{ role: "user", content: "hi" }] as unknown as AgentMessage[];
const out = sanitizeGoogleTurnOrdering(input);
expect(out).toBe(input);
});

View File

@@ -640,14 +640,6 @@ const ERROR_PATTERNS = {
"timed out",
"deadline exceeded",
"context deadline exceeded",
"connection error",
"network error",
"network request failed",
"fetch failed",
"socket hang up",
/\beconn(?:refused|reset|aborted)\b/i,
/\benotfound\b/i,
/\beai_again\b/i,
/without sending (?:any )?chunks?/i,
/\bstop reason:\s*(?:abort|error)\b/i,
/\breason:\s*(?:abort|error)\b/i,

View File

@@ -2,14 +2,13 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { applyGoogleTurnOrderingFix } from "./pi-embedded-runner.js";
import { castAgentMessage } from "./test-helpers/agent-message-fixtures.js";
describe("applyGoogleTurnOrderingFix", () => {
const makeAssistantFirst = (): AgentMessage[] => [
castAgentMessage({
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "exec", arguments: {} }],
}),
} as unknown as AgentMessage,
];
it("prepends a bootstrap once and records a marker for Google models", () => {

View File

@@ -5,7 +5,6 @@ import {
makeModelSnapshotEntry,
} from "./pi-embedded-runner.sanitize-session-history.test-harness.js";
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
import { castAgentMessage } from "./test-helpers/agent-message-fixtures.js";
describe("sanitizeSessionHistory openai tool id preservation", () => {
const makeSessionManager = () =>
@@ -18,7 +17,7 @@ describe("sanitizeSessionHistory openai tool id preservation", () => {
]);
const makeMessages = (withReasoning: boolean): AgentMessage[] => [
castAgentMessage({
{
role: "assistant",
content: [
...(withReasoning
@@ -32,14 +31,14 @@ describe("sanitizeSessionHistory openai tool id preservation", () => {
: []),
{ type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} },
],
}),
castAgentMessage({
} as unknown as AgentMessage,
{
role: "toolResult",
toolCallId: "call_123|fc_123",
toolName: "noop",
content: [{ type: "text", text: "ok" }],
isError: false,
}),
} as unknown as AgentMessage,
];
it.each([

View File

@@ -15,7 +15,6 @@ import {
sanitizeWithOpenAIResponses,
TEST_SESSION_ID,
} from "./pi-embedded-runner.sanitize-session-history.test-harness.js";
import { castAgentMessage, castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
import { makeZeroUsageSnapshot } from "./usage.js";
vi.mock("./pi-embedded-helpers.js", async () => ({
@@ -137,12 +136,12 @@ describe("sanitizeSessionHistory", () => {
});
const makeCompactionSummaryMessage = (tokensBefore: number, timestamp: string) =>
castAgentMessage({
({
role: "compactionSummary",
summary: "compressed",
tokensBefore,
timestamp,
});
}) as unknown as AgentMessage;
const sanitizeOpenAIHistory = async (
messages: AgentMessage[],
@@ -259,7 +258,7 @@ describe("sanitizeSessionHistory", () => {
setNonGoogleModelApi();
const messages: AgentMessage[] = [
castAgentMessage({
{
role: "user",
content: "forwarded instruction",
provenance: {
@@ -267,7 +266,7 @@ describe("sanitizeSessionHistory", () => {
sourceSessionKey: "agent:main:req",
sourceTool: "sessions_send",
},
}),
} as unknown as AgentMessage,
];
const result = await sanitizeSessionHistory({
@@ -288,14 +287,14 @@ describe("sanitizeSessionHistory", () => {
it("drops stale assistant usage snapshots kept before latest compaction summary", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
const messages = castAgentMessages([
const messages = [
{ role: "user", content: "old context" },
makeAssistantUsageMessage({
text: "old answer",
usage: makeUsage(191_919, 2_000, 193_919),
}),
makeCompactionSummaryMessage(191_919, new Date().toISOString()),
]);
] as unknown as AgentMessage[];
const result = await sanitizeOpenAIHistory(messages);
@@ -309,7 +308,7 @@ describe("sanitizeSessionHistory", () => {
it("preserves fresh assistant usage snapshots created after latest compaction summary", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
const messages = castAgentMessages([
const messages = [
makeAssistantUsageMessage({
text: "pre-compaction answer",
usage: makeUsage(120_000, 3_000, 123_000),
@@ -320,7 +319,7 @@ describe("sanitizeSessionHistory", () => {
text: "fresh answer",
usage: makeUsage(1_000, 250, 1_250),
}),
]);
] as unknown as AgentMessage[];
const result = await sanitizeOpenAIHistory(messages);
@@ -334,14 +333,14 @@ describe("sanitizeSessionHistory", () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
const messages = castAgentMessages([
const messages = [
makeCompactionSummaryMessage(191_919, new Date(compactionTs).toISOString()),
makeAssistantUsageMessage({
text: "kept pre-compaction answer",
timestamp: compactionTs - 1_000,
usage: makeUsage(191_919, 2_000, 193_919),
}),
]);
] as unknown as AgentMessage[];
const result = await sanitizeOpenAIHistory(messages);
@@ -355,7 +354,7 @@ describe("sanitizeSessionHistory", () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
const messages = castAgentMessages([
const messages = [
makeCompactionSummaryMessage(123_000, new Date(compactionTs).toISOString()),
makeAssistantUsageMessage({
text: "kept pre-compaction answer",
@@ -368,7 +367,7 @@ describe("sanitizeSessionHistory", () => {
timestamp: compactionTs + 2_000,
usage: makeUsage(1_000, 250, 1_250),
}),
]);
] as unknown as AgentMessage[];
const result = await sanitizeOpenAIHistory(messages);
@@ -432,13 +431,13 @@ describe("sanitizeSessionHistory", () => {
{
name: "missing input or arguments",
makeMessages: () =>
castAgentMessages([
castAgentMessage({
[
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read" }],
}),
} as unknown as AgentMessage,
makeUserMessage("hello"),
]),
] as AgentMessage[],
overrides: { sessionId: "test-session" } as Partial<
Parameters<typeof sanitizeOpenAIHistory>[1]
>,
@@ -446,7 +445,7 @@ describe("sanitizeSessionHistory", () => {
{
name: "invalid or overlong names",
makeMessages: () =>
castAgentMessages([
[
makeAssistantMessage(
[
{
@@ -465,7 +464,7 @@ describe("sanitizeSessionHistory", () => {
{ stopReason: "toolUse" },
),
makeUserMessage("hello"),
]),
] as AgentMessage[],
overrides: {} as Partial<Parameters<typeof sanitizeOpenAIHistory>[1]>,
},
])("drops malformed tool calls: $name", async ({ makeMessages, overrides }) => {

View File

@@ -1,5 +1,5 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { castAgentMessage } from "../../test-helpers/agent-message-fixtures.js";
import {
selectCompactionTimeoutSnapshot,
shouldFlagCompactionTimeout,
@@ -32,8 +32,8 @@ describe("compaction-timeout helpers", () => {
});
it("uses pre-compaction snapshot when compaction timeout occurs", () => {
const pre = [castAgentMessage({ role: "assistant", content: "pre" })] as const;
const current = [castAgentMessage({ role: "assistant", content: "current" })] as const;
const pre = [{ role: "assistant", content: "pre" } as unknown as AgentMessage] as const;
const current = [{ role: "assistant", content: "current" } as unknown as AgentMessage] as const;
const selected = selectCompactionTimeoutSnapshot({
timedOutDuringCompaction: true,
preCompactionSnapshot: [...pre],
@@ -47,7 +47,7 @@ describe("compaction-timeout helpers", () => {
});
it("falls back to current snapshot when pre-compaction snapshot is unavailable", () => {
const current = [castAgentMessage({ role: "assistant", content: "current" })] as const;
const current = [{ role: "assistant", content: "current" } as unknown as AgentMessage] as const;
const selected = selectCompactionTimeoutSnapshot({
timedOutDuringCompaction: true,
preCompactionSnapshot: null,

View File

@@ -1,7 +1,6 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { castAgentMessage } from "../../test-helpers/agent-message-fixtures.js";
import { PRUNED_HISTORY_IMAGE_MARKER, pruneProcessedHistoryImages } from "./history-image-prune.js";
describe("pruneProcessedHistoryImages", () => {
@@ -9,14 +8,14 @@ describe("pruneProcessedHistoryImages", () => {
it("prunes image blocks from user messages that already have assistant replies", () => {
const messages: AgentMessage[] = [
castAgentMessage({
{
role: "user",
content: [{ type: "text", text: "See /tmp/photo.png" }, { ...image }],
}),
castAgentMessage({
} as AgentMessage,
{
role: "assistant",
content: "got it",
}),
} as unknown as AgentMessage,
];
const didMutate = pruneProcessedHistoryImages(messages);
@@ -32,10 +31,10 @@ describe("pruneProcessedHistoryImages", () => {
it("does not prune latest user message when no assistant response exists yet", () => {
const messages: AgentMessage[] = [
castAgentMessage({
{
role: "user",
content: [{ type: "text", text: "See /tmp/photo.png" }, { ...image }],
}),
} as AgentMessage,
];
const didMutate = pruneProcessedHistoryImages(messages);
@@ -51,10 +50,10 @@ describe("pruneProcessedHistoryImages", () => {
it("does not change messages when no assistant turn exists", () => {
const messages: AgentMessage[] = [
castAgentMessage({
{
role: "user",
content: "noop",
}),
} as AgentMessage,
];
const didMutate = pruneProcessedHistoryImages(messages);

View File

@@ -1,16 +1,15 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js";
import { dropThinkingBlocks, isAssistantMessageWithContent } from "./thinking.js";
describe("isAssistantMessageWithContent", () => {
it("accepts assistant messages with array content and rejects others", () => {
const assistant = castAgentMessage({
const assistant = {
role: "assistant",
content: [{ type: "text", text: "ok" }],
});
const user = castAgentMessage({ role: "user", content: "hi" });
const malformed = castAgentMessage({ role: "assistant", content: "not-array" });
} as AgentMessage;
const user = { role: "user", content: "hi" } as AgentMessage;
const malformed = { role: "assistant", content: "not-array" } as unknown as AgentMessage;
expect(isAssistantMessageWithContent(assistant)).toBe(true);
expect(isAssistantMessageWithContent(user)).toBe(false);
@@ -21,8 +20,8 @@ describe("isAssistantMessageWithContent", () => {
describe("dropThinkingBlocks", () => {
it("returns the original reference when no thinking blocks are present", () => {
const messages: AgentMessage[] = [
castAgentMessage({ role: "user", content: "hello" }),
castAgentMessage({ role: "assistant", content: [{ type: "text", text: "world" }] }),
{ role: "user", content: "hello" } as AgentMessage,
{ role: "assistant", content: [{ type: "text", text: "world" }] } as AgentMessage,
];
const result = dropThinkingBlocks(messages);
@@ -31,13 +30,13 @@ describe("dropThinkingBlocks", () => {
it("drops thinking blocks while preserving non-thinking assistant content", () => {
const messages: AgentMessage[] = [
castAgentMessage({
{
role: "assistant",
content: [
{ type: "thinking", thinking: "internal" },
{ type: "text", text: "final" },
],
}),
} as unknown as AgentMessage,
];
const result = dropThinkingBlocks(messages);
@@ -48,10 +47,10 @@ describe("dropThinkingBlocks", () => {
it("keeps assistant turn structure when all content blocks were thinking", () => {
const messages: AgentMessage[] = [
castAgentMessage({
{
role: "assistant",
content: [{ type: "thinking", thinking: "internal-only" }],
}),
} as unknown as AgentMessage,
];
const result = dropThinkingBlocks(messages);

View File

@@ -1,6 +1,5 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js";
import {
CONTEXT_LIMIT_TRUNCATION_NOTICE,
PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER,
@@ -8,35 +7,35 @@ import {
} from "./tool-result-context-guard.js";
function makeUser(text: string): AgentMessage {
return castAgentMessage({
return {
role: "user",
content: text,
timestamp: Date.now(),
});
} as unknown as AgentMessage;
}
function makeToolResult(id: string, text: string): AgentMessage {
return castAgentMessage({
return {
role: "toolResult",
toolCallId: id,
toolName: "read",
content: [{ type: "text", text }],
isError: false,
timestamp: Date.now(),
});
} as unknown as AgentMessage;
}
function makeLegacyToolResult(id: string, text: string): AgentMessage {
return castAgentMessage({
return {
role: "tool",
tool_call_id: id,
tool_name: "read",
content: text,
});
} as unknown as AgentMessage;
}
function makeToolResultWithDetails(id: string, text: string, detailText: string): AgentMessage {
return castAgentMessage({
return {
role: "toolResult",
toolCallId: id,
toolName: "read",
@@ -50,7 +49,7 @@ function makeToolResultWithDetails(id: string, text: string, detailText: string)
},
isError: false,
timestamp: Date.now(),
});
} as unknown as AgentMessage;
}
function getToolResultText(msg: AgentMessage): string {
@@ -200,10 +199,11 @@ describe("installToolResultContextGuard", () => {
it("wraps an existing transformContext and guards the transformed output", async () => {
const agent = makeGuardableAgent((messages) => {
return messages.map((msg) =>
castAgentMessage({
...(msg as unknown as Record<string, unknown>),
}),
return messages.map(
(msg) =>
({
...(msg as unknown as Record<string, unknown>),
}) as unknown as AgentMessage,
);
});
const contextForNextCall = makeTwoToolResultOverflowContext();
@@ -254,10 +254,10 @@ describe("installToolResultContextGuard", () => {
await agent.transformContext?.(contextForNextCall, new AbortController().signal);
const oldResult = contextForNextCall[1] as {
const oldResult = contextForNextCall[1] as unknown as {
details?: unknown;
};
const newResult = contextForNextCall[2] as {
const newResult = contextForNextCall[2] as unknown as {
details?: unknown;
};
const oldResultText = getToolResultText(contextForNextCall[1]);

View File

@@ -23,7 +23,6 @@ type GuardableAgent = object;
type GuardableAgentRecord = {
transformContext?: GuardableTransformContext;
};
type MessageCharEstimateCache = WeakMap<AgentMessage, number>;
function isTextBlock(block: unknown): block is { type: "text"; text: string } {
return !!block && typeof block === "object" && (block as { type?: unknown }).type === "text";
@@ -156,18 +155,8 @@ function estimateMessageChars(msg: AgentMessage): number {
return 256;
}
function estimateMessageCharsCached(msg: AgentMessage, cache: MessageCharEstimateCache): number {
const hit = cache.get(msg);
if (hit !== undefined) {
return hit;
}
const estimated = estimateMessageChars(msg);
cache.set(msg, estimated);
return estimated;
}
function estimateContextChars(messages: AgentMessage[], cache: MessageCharEstimateCache): number {
return messages.reduce((sum, msg) => sum + estimateMessageCharsCached(msg, cache), 0);
function estimateContextChars(messages: AgentMessage[]): number {
return messages.reduce((sum, msg) => sum + estimateMessageChars(msg), 0);
}
function truncateTextToBudget(text: string, maxChars: number): string {
@@ -206,16 +195,12 @@ function replaceToolResultText(msg: AgentMessage, text: string): AgentMessage {
} as AgentMessage;
}
function truncateToolResultToChars(
msg: AgentMessage,
maxChars: number,
cache: MessageCharEstimateCache,
): AgentMessage {
function truncateToolResultToChars(msg: AgentMessage, maxChars: number): AgentMessage {
if (!isToolResultMessage(msg)) {
return msg;
}
const estimatedChars = estimateMessageCharsCached(msg, cache);
const estimatedChars = estimateMessageChars(msg);
if (estimatedChars <= maxChars) {
return msg;
}
@@ -232,9 +217,8 @@ function truncateToolResultToChars(
function compactExistingToolResultsInPlace(params: {
messages: AgentMessage[];
charsNeeded: number;
cache: MessageCharEstimateCache;
}): number {
const { messages, charsNeeded, cache } = params;
const { messages, charsNeeded } = params;
if (charsNeeded <= 0) {
return 0;
}
@@ -246,14 +230,14 @@ function compactExistingToolResultsInPlace(params: {
continue;
}
const before = estimateMessageCharsCached(msg, cache);
const before = estimateMessageChars(msg);
if (before <= PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER.length) {
continue;
}
const compacted = replaceToolResultText(msg, PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
applyMessageMutationInPlace(msg, compacted, cache);
const after = estimateMessageCharsCached(msg, cache);
applyMessageMutationInPlace(msg, compacted);
const after = estimateMessageChars(msg);
if (after >= before) {
continue;
}
@@ -267,11 +251,7 @@ function compactExistingToolResultsInPlace(params: {
return reduced;
}
function applyMessageMutationInPlace(
target: AgentMessage,
source: AgentMessage,
cache?: MessageCharEstimateCache,
): void {
function applyMessageMutationInPlace(target: AgentMessage, source: AgentMessage): void {
if (target === source) {
return;
}
@@ -284,7 +264,6 @@ function applyMessageMutationInPlace(
}
}
Object.assign(targetRecord, sourceRecord);
cache?.delete(target);
}
function enforceToolResultContextBudgetInPlace(params: {
@@ -293,18 +272,17 @@ function enforceToolResultContextBudgetInPlace(params: {
maxSingleToolResultChars: number;
}): void {
const { messages, contextBudgetChars, maxSingleToolResultChars } = params;
const estimateCache: MessageCharEstimateCache = new WeakMap();
// Ensure each tool result has an upper bound before considering total context usage.
for (const message of messages) {
if (!isToolResultMessage(message)) {
continue;
}
const truncated = truncateToolResultToChars(message, maxSingleToolResultChars, estimateCache);
applyMessageMutationInPlace(message, truncated, estimateCache);
const truncated = truncateToolResultToChars(message, maxSingleToolResultChars);
applyMessageMutationInPlace(message, truncated);
}
let currentChars = estimateContextChars(messages, estimateCache);
let currentChars = estimateContextChars(messages);
if (currentChars <= contextBudgetChars) {
return;
}
@@ -313,7 +291,6 @@ function enforceToolResultContextBudgetInPlace(params: {
compactExistingToolResultsInPlace({
messages,
charsNeeded: currentChars - contextBudgetChars,
cache: estimateCache,
});
}

View File

@@ -5,7 +5,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js";
import {
getCompactionSafeguardRuntime,
setCompactionSafeguardRuntime,
@@ -219,11 +218,11 @@ describe("computeAdaptiveChunkRatio", () => {
// Small messages: 1000 tokens each, well under 10% of context
const messages: AgentMessage[] = [
{ role: "user", content: "x".repeat(1000), timestamp: Date.now() },
castAgentMessage({
{
role: "assistant",
content: [{ type: "text", text: "y".repeat(1000) }],
timestamp: Date.now(),
}),
} as unknown as AgentMessage,
];
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
@@ -234,11 +233,11 @@ describe("computeAdaptiveChunkRatio", () => {
// Large messages: ~50K tokens each (25% of context)
const messages: AgentMessage[] = [
{ role: "user", content: "x".repeat(50_000 * 4), timestamp: Date.now() },
castAgentMessage({
{
role: "assistant",
content: [{ type: "text", text: "y".repeat(50_000 * 4) }],
timestamp: Date.now(),
}),
} as unknown as AgentMessage,
];
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);

View File

@@ -1,82 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { AnyAgentTool } from "./pi-tools.types.js";
/** Resolve path for host edit: expand ~ and resolve relative paths against root. */
function resolveHostEditPath(root: string, pathParam: string): string {
const expanded =
pathParam.startsWith("~/") || pathParam === "~"
? pathParam.replace(/^~/, os.homedir())
: pathParam;
return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(root, expanded);
}
/**
* When the upstream edit tool throws after having already written (e.g. generateDiffString fails),
* the file may be correctly updated but the tool reports failure. This wrapper catches errors and
* if the target file on disk contains the intended newText, returns success so we don't surface
* a false "edit failed" to the user (fixes #32333, same pattern as #30773 for write).
*/
export function wrapHostEditToolWithPostWriteRecovery(
base: AnyAgentTool,
root: string,
): AnyAgentTool {
return {
...base,
execute: async (
toolCallId: string,
params: unknown,
signal: AbortSignal | undefined,
onUpdate?: (update: unknown) => void,
) => {
try {
return await base.execute(toolCallId, params, signal, onUpdate);
} catch (err) {
const record =
params && typeof params === "object" ? (params as Record<string, unknown>) : undefined;
const pathParam = record && typeof record.path === "string" ? record.path : undefined;
const newText =
record && typeof record.newText === "string"
? record.newText
: record && typeof record.new_string === "string"
? record.new_string
: undefined;
const oldText =
record && typeof record.oldText === "string"
? record.oldText
: record && typeof record.old_string === "string"
? record.old_string
: undefined;
if (!pathParam || !newText) {
throw err;
}
try {
const absolutePath = resolveHostEditPath(root, pathParam);
const content = await fs.readFile(absolutePath, "utf-8");
// Only recover when the replacement likely occurred: newText is present and oldText
// is no longer present. This avoids false success when upstream threw before writing
// (e.g. oldText not found) but the file already contained newText (review feedback).
const hasNew = content.includes(newText);
const stillHasOld =
oldText !== undefined && oldText.length > 0 && content.includes(oldText);
if (hasNew && !stillHasOld) {
return {
content: [
{
type: "text",
text: `Successfully replaced text in ${pathParam}.`,
},
],
details: { diff: "", firstChangedLine: undefined },
} as AgentToolResult<unknown>;
}
} catch {
// File read failed or path invalid; rethrow original error.
}
throw err;
}
},
};
}

View File

@@ -1,225 +0,0 @@
import type { AnyAgentTool } from "./pi-tools.types.js";
export type RequiredParamGroup = {
keys: readonly string[];
allowEmpty?: boolean;
label?: string;
};
const RETRY_GUIDANCE_SUFFIX = " Supply correct parameters before retrying.";
function parameterValidationError(message: string): Error {
return new Error(`${message}.${RETRY_GUIDANCE_SUFFIX}`);
}
export const CLAUDE_PARAM_GROUPS = {
read: [{ keys: ["path", "file_path"], label: "path (path or file_path)" }],
write: [
{ keys: ["path", "file_path"], label: "path (path or file_path)" },
{ keys: ["content"], label: "content" },
],
edit: [
{ keys: ["path", "file_path"], label: "path (path or file_path)" },
{
keys: ["oldText", "old_string"],
label: "oldText (oldText or old_string)",
},
{
keys: ["newText", "new_string"],
label: "newText (newText or new_string)",
allowEmpty: true,
},
],
} as const;
function extractStructuredText(value: unknown, depth = 0): string | undefined {
if (depth > 6) {
return undefined;
}
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
const parts = value
.map((entry) => extractStructuredText(entry, depth + 1))
.filter((entry): entry is string => typeof entry === "string");
return parts.length > 0 ? parts.join("") : undefined;
}
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
if (typeof record.text === "string") {
return record.text;
}
if (typeof record.content === "string") {
return record.content;
}
if (Array.isArray(record.content)) {
return extractStructuredText(record.content, depth + 1);
}
if (Array.isArray(record.parts)) {
return extractStructuredText(record.parts, depth + 1);
}
if (typeof record.value === "string" && record.value.length > 0) {
const type = typeof record.type === "string" ? record.type.toLowerCase() : "";
const kind = typeof record.kind === "string" ? record.kind.toLowerCase() : "";
if (type.includes("text") || kind === "text") {
return record.value;
}
}
return undefined;
}
function normalizeTextLikeParam(record: Record<string, unknown>, key: string) {
const value = record[key];
if (typeof value === "string") {
return;
}
const extracted = extractStructuredText(value);
if (typeof extracted === "string") {
record[key] = extracted;
}
}
// Normalize tool parameters from Claude Code conventions to pi-coding-agent conventions.
// Claude Code uses file_path/old_string/new_string while pi-coding-agent uses path/oldText/newText.
// This prevents models trained on Claude Code from getting stuck in tool-call loops.
export function normalizeToolParams(params: unknown): Record<string, unknown> | undefined {
if (!params || typeof params !== "object") {
return undefined;
}
const record = params as Record<string, unknown>;
const normalized = { ...record };
// file_path → path (read, write, edit)
if ("file_path" in normalized && !("path" in normalized)) {
normalized.path = normalized.file_path;
delete normalized.file_path;
}
// old_string → oldText (edit)
if ("old_string" in normalized && !("oldText" in normalized)) {
normalized.oldText = normalized.old_string;
delete normalized.old_string;
}
// new_string → newText (edit)
if ("new_string" in normalized && !("newText" in normalized)) {
normalized.newText = normalized.new_string;
delete normalized.new_string;
}
// Some providers/models emit text payloads as structured blocks instead of raw strings.
// Normalize these for write/edit so content matching and writes stay deterministic.
normalizeTextLikeParam(normalized, "content");
normalizeTextLikeParam(normalized, "oldText");
normalizeTextLikeParam(normalized, "newText");
return normalized;
}
export function patchToolSchemaForClaudeCompatibility(tool: AnyAgentTool): AnyAgentTool {
const schema =
tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>)
: undefined;
if (!schema || !schema.properties || typeof schema.properties !== "object") {
return tool;
}
const properties = { ...(schema.properties as Record<string, unknown>) };
const required = Array.isArray(schema.required)
? schema.required.filter((key): key is string => typeof key === "string")
: [];
let changed = false;
const aliasPairs: Array<{ original: string; alias: string }> = [
{ original: "path", alias: "file_path" },
{ original: "oldText", alias: "old_string" },
{ original: "newText", alias: "new_string" },
];
for (const { original, alias } of aliasPairs) {
if (!(original in properties)) {
continue;
}
if (!(alias in properties)) {
properties[alias] = properties[original];
changed = true;
}
const idx = required.indexOf(original);
if (idx !== -1) {
required.splice(idx, 1);
changed = true;
}
}
if (!changed) {
return tool;
}
return {
...tool,
parameters: {
...schema,
properties,
required,
},
};
}
export function assertRequiredParams(
record: Record<string, unknown> | undefined,
groups: readonly RequiredParamGroup[],
toolName: string,
): void {
if (!record || typeof record !== "object") {
throw parameterValidationError(`Missing parameters for ${toolName}`);
}
const missingLabels: string[] = [];
for (const group of groups) {
const satisfied = group.keys.some((key) => {
if (!(key in record)) {
return false;
}
const value = record[key];
if (typeof value !== "string") {
return false;
}
if (group.allowEmpty) {
return true;
}
return value.trim().length > 0;
});
if (!satisfied) {
const label = group.label ?? group.keys.join(" or ");
missingLabels.push(label);
}
}
if (missingLabels.length > 0) {
const joined = missingLabels.join(", ");
const noun = missingLabels.length === 1 ? "parameter" : "parameters";
throw parameterValidationError(`Missing required ${noun}: ${joined}`);
}
}
// Generic wrapper to normalize parameters for any tool.
export function wrapToolParamNormalization(
tool: AnyAgentTool,
requiredParamGroups?: readonly RequiredParamGroup[],
): AnyAgentTool {
const patched = patchToolSchemaForClaudeCompatibility(tool);
return {
...patched,
execute: async (toolCallId, params, signal, onUpdate) => {
const normalized = normalizeToolParams(params);
const record =
normalized ??
(params && typeof params === "object" ? (params as Record<string, unknown>) : undefined);
if (requiredParamGroups?.length) {
assertRequiredParams(record, requiredParamGroups, tool.name);
}
return tool.execute(toolCallId, normalized ?? params, signal, onUpdate);
},
};
}

View File

@@ -1,89 +0,0 @@
/**
* Tests for edit tool post-write recovery: when the upstream library throws after
* having already written the file (e.g. generateDiffString fails), we catch and
* if the file on disk contains the intended newText we return success (#32333).
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { EditToolOptions } from "@mariozechner/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
executeThrows: true,
}));
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
const actual = await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
return {
...actual,
createEditTool: (cwd: string, options?: EditToolOptions) => {
const base = actual.createEditTool(cwd, options);
return {
...base,
execute: async (...args: Parameters<typeof base.execute>) => {
if (mocks.executeThrows) {
throw new Error("Simulated post-write failure (e.g. generateDiffString)");
}
return base.execute(...args);
},
};
},
};
});
const { createHostWorkspaceEditTool } = await import("./pi-tools.read.js");
describe("createHostWorkspaceEditTool post-write recovery", () => {
let tmpDir = "";
afterEach(async () => {
mocks.executeThrows = true;
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = "";
}
});
it("returns success when upstream throws but file has newText and no longer has oldText", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-recovery-"));
const filePath = path.join(tmpDir, "MEMORY.md");
const oldText = "# Memory";
const newText = "Blog Writing";
await fs.writeFile(filePath, `\n\n${newText}\n`, "utf-8");
const tool = createHostWorkspaceEditTool(tmpDir);
const result = await tool.execute("call-1", { path: filePath, oldText, newText }, undefined);
expect(result).toBeDefined();
const content = Array.isArray((result as { content?: unknown }).content)
? (result as { content: Array<{ type?: string; text?: string }> }).content
: [];
const textBlock = content.find((b) => b?.type === "text" && typeof b.text === "string");
expect(textBlock?.text).toContain("Successfully replaced text");
});
it("rethrows when file on disk does not contain newText", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-recovery-"));
const filePath = path.join(tmpDir, "other.md");
await fs.writeFile(filePath, "unchanged content", "utf-8");
const tool = createHostWorkspaceEditTool(tmpDir);
await expect(
tool.execute("call-1", { path: filePath, oldText: "x", newText: "never-written" }, undefined),
).rejects.toThrow("Simulated post-write failure");
});
it("rethrows when file still contains oldText (pre-write failure; avoid false success)", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-recovery-"));
const filePath = path.join(tmpDir, "pre-write-fail.md");
const oldText = "replace me";
const newText = "new content";
await fs.writeFile(filePath, `before ${oldText} after ${newText}`, "utf-8");
const tool = createHostWorkspaceEditTool(tmpDir);
await expect(
tool.execute("call-1", { path: filePath, oldText, newText }, undefined),
).rejects.toThrow("Simulated post-write failure");
});
});

View File

@@ -13,26 +13,11 @@ import { detectMime } from "../media/mime.js";
import { sniffMimeFromBase64 } from "../media/sniff-mime-from-base64.js";
import type { ImageSanitizationLimits } from "./image-sanitization.js";
import { toRelativeWorkspacePath } from "./path-policy.js";
import { wrapHostEditToolWithPostWriteRecovery } from "./pi-tools.host-edit.js";
import {
CLAUDE_PARAM_GROUPS,
assertRequiredParams,
normalizeToolParams,
patchToolSchemaForClaudeCompatibility,
wrapToolParamNormalization,
} from "./pi-tools.params.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import { sanitizeToolResultImages } from "./tool-images.js";
export {
CLAUDE_PARAM_GROUPS,
normalizeToolParams,
patchToolSchemaForClaudeCompatibility,
wrapToolParamNormalization,
} from "./pi-tools.params.js";
// NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper
// to normalize payloads and sanitize oversized images before they hit providers.
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
@@ -349,6 +334,230 @@ async function normalizeReadImageResult(
return { ...result, content: nextContent };
}
type RequiredParamGroup = {
keys: readonly string[];
allowEmpty?: boolean;
label?: string;
};
const RETRY_GUIDANCE_SUFFIX = " Supply correct parameters before retrying.";
function parameterValidationError(message: string): Error {
return new Error(`${message}.${RETRY_GUIDANCE_SUFFIX}`);
}
export const CLAUDE_PARAM_GROUPS = {
read: [{ keys: ["path", "file_path"], label: "path (path or file_path)" }],
write: [
{ keys: ["path", "file_path"], label: "path (path or file_path)" },
{ keys: ["content"], label: "content" },
],
edit: [
{ keys: ["path", "file_path"], label: "path (path or file_path)" },
{
keys: ["oldText", "old_string"],
label: "oldText (oldText or old_string)",
},
{
keys: ["newText", "new_string"],
label: "newText (newText or new_string)",
allowEmpty: true,
},
],
} as const;
function extractStructuredText(value: unknown, depth = 0): string | undefined {
if (depth > 6) {
return undefined;
}
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
const parts = value
.map((entry) => extractStructuredText(entry, depth + 1))
.filter((entry): entry is string => typeof entry === "string");
return parts.length > 0 ? parts.join("") : undefined;
}
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
if (typeof record.text === "string") {
return record.text;
}
if (typeof record.content === "string") {
return record.content;
}
if (Array.isArray(record.content)) {
return extractStructuredText(record.content, depth + 1);
}
if (Array.isArray(record.parts)) {
return extractStructuredText(record.parts, depth + 1);
}
if (typeof record.value === "string" && record.value.length > 0) {
const type = typeof record.type === "string" ? record.type.toLowerCase() : "";
const kind = typeof record.kind === "string" ? record.kind.toLowerCase() : "";
if (type.includes("text") || kind === "text") {
return record.value;
}
}
return undefined;
}
function normalizeTextLikeParam(record: Record<string, unknown>, key: string) {
const value = record[key];
if (typeof value === "string") {
return;
}
const extracted = extractStructuredText(value);
if (typeof extracted === "string") {
record[key] = extracted;
}
}
// Normalize tool parameters from Claude Code conventions to pi-coding-agent conventions.
// Claude Code uses file_path/old_string/new_string while pi-coding-agent uses path/oldText/newText.
// This prevents models trained on Claude Code from getting stuck in tool-call loops.
export function normalizeToolParams(params: unknown): Record<string, unknown> | undefined {
if (!params || typeof params !== "object") {
return undefined;
}
const record = params as Record<string, unknown>;
const normalized = { ...record };
// file_path → path (read, write, edit)
if ("file_path" in normalized && !("path" in normalized)) {
normalized.path = normalized.file_path;
delete normalized.file_path;
}
// old_string → oldText (edit)
if ("old_string" in normalized && !("oldText" in normalized)) {
normalized.oldText = normalized.old_string;
delete normalized.old_string;
}
// new_string → newText (edit)
if ("new_string" in normalized && !("newText" in normalized)) {
normalized.newText = normalized.new_string;
delete normalized.new_string;
}
// Some providers/models emit text payloads as structured blocks instead of raw strings.
// Normalize these for write/edit so content matching and writes stay deterministic.
normalizeTextLikeParam(normalized, "content");
normalizeTextLikeParam(normalized, "oldText");
normalizeTextLikeParam(normalized, "newText");
return normalized;
}
export function patchToolSchemaForClaudeCompatibility(tool: AnyAgentTool): AnyAgentTool {
const schema =
tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>)
: undefined;
if (!schema || !schema.properties || typeof schema.properties !== "object") {
return tool;
}
const properties = { ...(schema.properties as Record<string, unknown>) };
const required = Array.isArray(schema.required)
? schema.required.filter((key): key is string => typeof key === "string")
: [];
let changed = false;
const aliasPairs: Array<{ original: string; alias: string }> = [
{ original: "path", alias: "file_path" },
{ original: "oldText", alias: "old_string" },
{ original: "newText", alias: "new_string" },
];
for (const { original, alias } of aliasPairs) {
if (!(original in properties)) {
continue;
}
if (!(alias in properties)) {
properties[alias] = properties[original];
changed = true;
}
const idx = required.indexOf(original);
if (idx !== -1) {
required.splice(idx, 1);
changed = true;
}
}
if (!changed) {
return tool;
}
return {
...tool,
parameters: {
...schema,
properties,
required,
},
};
}
export function assertRequiredParams(
record: Record<string, unknown> | undefined,
groups: readonly RequiredParamGroup[],
toolName: string,
): void {
if (!record || typeof record !== "object") {
throw parameterValidationError(`Missing parameters for ${toolName}`);
}
const missingLabels: string[] = [];
for (const group of groups) {
const satisfied = group.keys.some((key) => {
if (!(key in record)) {
return false;
}
const value = record[key];
if (typeof value !== "string") {
return false;
}
if (group.allowEmpty) {
return true;
}
return value.trim().length > 0;
});
if (!satisfied) {
const label = group.label ?? group.keys.join(" or ");
missingLabels.push(label);
}
}
if (missingLabels.length > 0) {
const joined = missingLabels.join(", ");
const noun = missingLabels.length === 1 ? "parameter" : "parameters";
throw parameterValidationError(`Missing required ${noun}: ${joined}`);
}
}
// Generic wrapper to normalize parameters for any tool
export function wrapToolParamNormalization(
tool: AnyAgentTool,
requiredParamGroups?: readonly RequiredParamGroup[],
): AnyAgentTool {
const patched = patchToolSchemaForClaudeCompatibility(tool);
return {
...patched,
execute: async (toolCallId, params, signal, onUpdate) => {
const normalized = normalizeToolParams(params);
const record =
normalized ??
(params && typeof params === "object" ? (params as Record<string, unknown>) : undefined);
if (requiredParamGroups?.length) {
assertRequiredParams(record, requiredParamGroups, tool.name);
}
return tool.execute(toolCallId, normalized ?? params, signal, onUpdate);
},
};
}
export function wrapToolWorkspaceRootGuard(tool: AnyAgentTool, root: string): AnyAgentTool {
return wrapToolWorkspaceRootGuardWithOptions(tool, root);
}
@@ -475,8 +684,7 @@ export function createHostWorkspaceEditTool(root: string, options?: { workspaceO
const base = createEditTool(root, {
operations: createHostEditOperations(root, options),
}) as unknown as AnyAgentTool;
const withRecovery = wrapHostEditToolWithPostWriteRecovery(base, root);
return wrapToolParamNormalization(withRecovery, CLAUDE_PARAM_GROUPS.edit);
return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.edit);
}
export function createOpenClawReadTool(

View File

@@ -2,7 +2,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { installSessionToolResultGuard } from "./session-tool-result-guard.js";
import { castAgentMessage } from "./test-helpers/agent-message-fixtures.js";
type AppendMessage = Parameters<SessionManager["appendMessage"]>[0];
@@ -389,10 +388,10 @@ describe("installSessionToolResultGuard", () => {
return undefined;
}
return {
message: castAgentMessage({
message: {
...(message as unknown as Record<string, unknown>),
content: [{ type: "text", text: "rewritten by hook" }],
}),
} as unknown as AgentMessage,
};
},
});
@@ -426,10 +425,10 @@ describe("installSessionToolResultGuard", () => {
installSessionToolResultGuard(sm, {
transformMessageForPersistence: (message) =>
(message as { role?: string }).role === "user"
? castAgentMessage({
? ({
...(message as unknown as Record<string, unknown>),
provenance: { kind: "inter_session", sourceTool: "sessions_send" },
})
} as unknown as AgentMessage)
: message,
});

View File

@@ -1,10 +1,9 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, it, expect } from "vitest";
import { sanitizeToolCallInputs } from "./session-transcript-repair.js";
import { castAgentMessage, castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
function mkSessionsSpawnToolCall(content: string): AgentMessage {
return castAgentMessage({
return {
role: "assistant",
content: [
{
@@ -24,7 +23,7 @@ function mkSessionsSpawnToolCall(content: string): AgentMessage {
},
],
timestamp: Date.now(),
});
} as unknown as AgentMessage;
}
describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
@@ -45,7 +44,7 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
it("redacts attachments content from tool input payloads too", () => {
const secret = "INPUT_SECRET_SHOULD_NOT_PERSIST";
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [
@@ -60,7 +59,7 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
},
],
},
]);
] as unknown as AgentMessage[];
const out = sanitizeToolCallInputs(input);
const msg = out[0] as { content?: unknown[] };

View File

@@ -6,7 +6,6 @@ import {
repairToolUseResultPairing,
stripToolResultDetails,
} from "./session-transcript-repair.js";
import { castAgentMessage, castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
const TOOL_CALL_BLOCK_TYPES = new Set(["toolCall", "toolUse", "functionCall"]);
@@ -26,7 +25,7 @@ describe("sanitizeToolUseResultPairing", () => {
middleMessage?: unknown;
secondText?: string;
}): AgentMessage[] =>
castAgentMessages([
[
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
@@ -38,7 +37,7 @@ describe("sanitizeToolUseResultPairing", () => {
content: [{ type: "text", text: "first" }],
isError: false,
},
...(opts?.middleMessage ? [castAgentMessage(opts.middleMessage)] : []),
...(opts?.middleMessage ? [opts.middleMessage as AgentMessage] : []),
{
role: "toolResult",
toolCallId: "call_1",
@@ -46,10 +45,10 @@ describe("sanitizeToolUseResultPairing", () => {
content: [{ type: "text", text: opts?.secondText ?? "second" }],
isError: false,
},
]);
] as unknown as AgentMessage[];
it("moves tool results directly after tool calls and inserts missing results", () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [
@@ -65,7 +64,7 @@ describe("sanitizeToolUseResultPairing", () => {
content: [{ type: "text", text: "ok" }],
isError: false,
},
]);
] as unknown as AgentMessage[];
const out = sanitizeToolUseResultPairing(input);
expect(out[0]?.role).toBe("assistant");
@@ -77,7 +76,7 @@ describe("sanitizeToolUseResultPairing", () => {
});
it("repairs blank tool result names from matching tool calls", () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
@@ -89,7 +88,7 @@ describe("sanitizeToolUseResultPairing", () => {
content: [{ type: "text", text: "ok" }],
isError: false,
},
]);
] as unknown as AgentMessage[];
const out = sanitizeToolUseResultPairing(input);
const toolResult = out.find((message) => message.role === "toolResult") as {
@@ -100,10 +99,10 @@ describe("sanitizeToolUseResultPairing", () => {
});
it("drops duplicate tool results for the same id within a span", () => {
const input = castAgentMessages([
const input = [
...buildDuplicateToolResultInput(),
{ role: "user", content: "ok" },
]);
] as AgentMessage[];
const out = sanitizeToolUseResultPairing(input);
expect(out.filter((m) => m.role === "toolResult")).toHaveLength(1);
@@ -124,7 +123,7 @@ describe("sanitizeToolUseResultPairing", () => {
});
it("drops orphan tool results that do not match any tool call", () => {
const input = castAgentMessages([
const input = [
{ role: "user", content: "hello" },
{
role: "toolResult",
@@ -137,7 +136,7 @@ describe("sanitizeToolUseResultPairing", () => {
role: "assistant",
content: [{ type: "text", text: "ok" }],
},
]);
] as unknown as AgentMessage[];
const out = sanitizeToolUseResultPairing(input);
expect(out.some((m) => m.role === "toolResult")).toBe(false);
@@ -148,14 +147,14 @@ describe("sanitizeToolUseResultPairing", () => {
// When an assistant message has stopReason: "error", its tool_use blocks may be
// incomplete/malformed. We should NOT create synthetic tool_results for them,
// as this causes API 400 errors: "unexpected tool_use_id found in tool_result blocks"
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_error", name: "exec", arguments: {} }],
stopReason: "error",
},
{ role: "user", content: "something went wrong" },
]);
] as unknown as AgentMessage[];
const result = repairToolUseResultPairing(input);
@@ -170,14 +169,14 @@ describe("sanitizeToolUseResultPairing", () => {
it("skips tool call extraction for assistant messages with stopReason 'aborted'", () => {
// When a request is aborted mid-stream, the assistant message may have incomplete
// tool_use blocks (with partialJson). We should NOT create synthetic tool_results.
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_aborted", name: "Bash", arguments: {} }],
stopReason: "aborted",
},
{ role: "user", content: "retrying after abort" },
]);
] as unknown as AgentMessage[];
const result = repairToolUseResultPairing(input);
@@ -191,14 +190,14 @@ describe("sanitizeToolUseResultPairing", () => {
it("still repairs tool results for normal assistant messages with stopReason 'toolUse'", () => {
// Normal tool calls (stopReason: "toolUse" or "stop") should still be repaired
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_normal", name: "read", arguments: {} }],
stopReason: "toolUse",
},
{ role: "user", content: "user message" },
]);
] as unknown as AgentMessage[];
const result = repairToolUseResultPairing(input);
@@ -211,7 +210,7 @@ describe("sanitizeToolUseResultPairing", () => {
// When an assistant message is aborted, any tool results that follow should be
// dropped as orphans (since we skip extracting tool calls from aborted messages).
// This addresses the edge case where a partial tool result was persisted before abort.
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_aborted", name: "exec", arguments: {} }],
@@ -225,7 +224,7 @@ describe("sanitizeToolUseResultPairing", () => {
isError: false,
},
{ role: "user", content: "retrying" },
]);
] as unknown as AgentMessage[];
const result = repairToolUseResultPairing(input);
@@ -245,12 +244,12 @@ describe("sanitizeToolCallInputs", () => {
options?: Parameters<typeof sanitizeToolCallInputs>[1],
) {
return sanitizeToolCallInputs(
castAgentMessages([
[
{
role: "assistant",
content,
},
]),
] as unknown as AgentMessage[],
options,
);
}
@@ -263,13 +262,13 @@ describe("sanitizeToolCallInputs", () => {
}
it("drops tool calls missing input or arguments", () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read" }],
},
{ role: "user", content: "hello" },
]);
] as unknown as AgentMessage[];
const out = sanitizeToolCallInputs(input);
expect(out.map((m) => m.role)).toEqual(["user"]);
@@ -326,7 +325,7 @@ describe("sanitizeToolCallInputs", () => {
});
it("keeps valid tool calls and preserves text blocks", () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [
@@ -335,7 +334,7 @@ describe("sanitizeToolCallInputs", () => {
{ type: "toolCall", id: "call_drop", name: "read" },
],
},
]);
] as unknown as AgentMessage[];
const out = sanitizeToolCallInputs(input);
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
@@ -385,7 +384,7 @@ describe("sanitizeToolCallInputs", () => {
});
it("preserves toolUse input shape for sessions_spawn when no attachments are present", () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [
@@ -397,7 +396,7 @@ describe("sanitizeToolCallInputs", () => {
},
],
},
]);
] as unknown as AgentMessage[];
const out = sanitizeToolCallInputs(input);
const toolCalls = getAssistantToolCallBlocks(out) as Array<Record<string, unknown>>;
@@ -409,7 +408,7 @@ describe("sanitizeToolCallInputs", () => {
});
it("redacts sessions_spawn attachments for mixed-case and padded tool names", () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [
@@ -424,7 +423,7 @@ describe("sanitizeToolCallInputs", () => {
},
],
},
]);
] as unknown as AgentMessage[];
const out = sanitizeToolCallInputs(input);
const toolCalls = getAssistantToolCallBlocks(out) as Array<Record<string, unknown>>;
@@ -449,7 +448,7 @@ describe("sanitizeToolCallInputs", () => {
describe("stripToolResultDetails", () => {
it("removes details only from toolResult messages", () => {
const input = castAgentMessages([
const input = [
{
role: "toolResult",
toolCallId: "call_1",
@@ -459,7 +458,7 @@ describe("stripToolResultDetails", () => {
},
{ role: "assistant", content: [{ type: "text", text: "keep me" }], details: { no: "touch" } },
{ role: "user", content: "hello" },
]);
] as unknown as AgentMessage[];
const out = stripToolResultDetails(input) as unknown as Array<Record<string, unknown>>;
@@ -473,7 +472,7 @@ describe("stripToolResultDetails", () => {
});
it("returns the same array reference when there are no toolResult details", () => {
const input = castAgentMessages([
const input = [
{ role: "assistant", content: [{ type: "text", text: "a" }] },
{
role: "toolResult",
@@ -482,7 +481,7 @@ describe("stripToolResultDetails", () => {
content: [{ type: "text", text: "ok" }],
},
{ role: "user", content: "b" },
]);
] as unknown as AgentMessage[];
const out = stripToolResultDetails(input);
expect(out).toBe(input);

View File

@@ -1,66 +0,0 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@mariozechner/pi-ai";
const ZERO_USAGE: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
export function castAgentMessage(message: unknown): AgentMessage {
return message as AgentMessage;
}
export function castAgentMessages(messages: unknown[]): AgentMessage[] {
return messages as AgentMessage[];
}
export function makeAgentUserMessage(
overrides: Partial<UserMessage> & Pick<UserMessage, "content">,
): UserMessage {
return {
role: "user",
timestamp: 0,
...overrides,
};
}
export function makeAgentAssistantMessage(
overrides: Partial<AssistantMessage> & Pick<AssistantMessage, "content">,
): AssistantMessage {
return {
role: "assistant",
api: "openai-responses",
provider: "openai",
model: "test-model",
usage: ZERO_USAGE,
stopReason: "stop",
timestamp: 0,
...overrides,
};
}
export function makeAgentToolResultMessage(
overrides: Partial<ToolResultMessage> &
Pick<ToolResultMessage, "toolCallId" | "toolName" | "content">,
): ToolResultMessage {
const { toolCallId, toolName, content, ...rest } = overrides;
return {
role: "toolResult",
toolCallId,
toolName,
content,
isError: false,
timestamp: 0,
...rest,
};
}

View File

@@ -1,13 +1,12 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
import {
isValidCloudCodeAssistToolId,
sanitizeToolCallIdsForCloudCodeAssist,
} from "./tool-call-id.js";
const buildDuplicateIdCollisionInput = () =>
castAgentMessages([
[
{
role: "assistant",
content: [
@@ -27,7 +26,7 @@ const buildDuplicateIdCollisionInput = () =>
toolName: "read",
content: [{ type: "text", text: "two" }],
},
]);
] as unknown as AgentMessage[];
function expectCollisionIdsRemainDistinct(
out: AgentMessage[],
@@ -66,7 +65,7 @@ function expectSingleToolCallRewrite(
describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
describe("strict mode (default)", () => {
it("is a no-op for already-valid non-colliding IDs", () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call1", name: "read", arguments: {} }],
@@ -77,14 +76,14 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
toolName: "read",
content: [{ type: "text", text: "ok" }],
},
]);
] as unknown as AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
expect(out).toBe(input);
});
it("strips non-alphanumeric characters from tool call IDs", () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call|item:123", name: "read", arguments: {} }],
@@ -95,7 +94,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
toolName: "read",
content: [{ type: "text", text: "ok" }],
},
]);
] as unknown as AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
expect(out).not.toBe(input);
@@ -114,7 +113,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
const longA = `call_${"a".repeat(60)}`;
const longB = `call_${"a".repeat(59)}b`;
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [
@@ -134,7 +133,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
toolName: "read",
content: [{ type: "text", text: "two" }],
},
]);
] as unknown as AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
const { aId, bId } = expectCollisionIdsRemainDistinct(out, "strict");
@@ -145,7 +144,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
describe("strict mode (alphanumeric only)", () => {
it("strips underscores and hyphens from tool call IDs", () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [
@@ -163,7 +162,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
toolName: "login",
content: [{ type: "text", text: "ok" }],
},
]);
] as unknown as AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
expect(out).not.toBe(input);
@@ -185,7 +184,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
describe("strict9 mode (Mistral tool call IDs)", () => {
it("is a no-op for already-valid 9-char alphanumeric IDs", () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "abc123XYZ", name: "read", arguments: {} }],
@@ -196,14 +195,14 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
toolName: "read",
content: [{ type: "text", text: "ok" }],
},
]);
] as unknown as AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict9");
expect(out).toBe(input);
});
it("enforces alphanumeric IDs with length 9", () => {
const input = castAgentMessages([
const input = [
{
role: "assistant",
content: [
@@ -223,7 +222,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
toolName: "read",
content: [{ type: "text", text: "two" }],
},
]);
] as unknown as AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict9");
expect(out).not.toBe(input);

View File

@@ -419,7 +419,7 @@ export function resolveAcpInstallCommandHint(cfg: OpenClawConfig): string {
if (existsSync(localPath)) {
return `openclaw plugins install ${localPath}`;
}
return "openclaw plugins install acpx";
return "openclaw plugins install @openclaw/acpx";
}
return `Install and enable the plugin that provides ACP backend "${backendId}".`;
}

View File

@@ -67,8 +67,7 @@ describe("session hook context wiring", () => {
await vi.waitFor(() => expect(hookRunnerMocks.runSessionStart).toHaveBeenCalledTimes(1));
const [event, context] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? [];
expect(event).toMatchObject({ sessionKey });
expect(context).toMatchObject({ sessionKey, agentId: "main" });
expect(context).toMatchObject({ sessionId: event?.sessionId });
expect(context).toMatchObject({ sessionKey });
});
it("passes sessionKey to session_end hook context on reset", async () => {
@@ -89,13 +88,8 @@ describe("session hook context wiring", () => {
});
await vi.waitFor(() => expect(hookRunnerMocks.runSessionEnd).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(hookRunnerMocks.runSessionStart).toHaveBeenCalledTimes(1));
const [event, context] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
expect(event).toMatchObject({ sessionKey });
expect(context).toMatchObject({ sessionKey, agentId: "main" });
expect(context).toMatchObject({ sessionId: event?.sessionId });
const [startEvent] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? [];
expect(startEvent).toMatchObject({ resumedFrom: "old-session" });
expect(context).toMatchObject({ sessionKey });
});
});

View File

@@ -146,70 +146,6 @@ type LegacyMainDeliveryRetirement = {
entry: SessionEntry;
};
type SessionHookContext = {
sessionId: string;
sessionKey: string;
agentId: string;
};
function buildSessionHookContext(params: {
sessionId: string;
sessionKey: string;
cfg: OpenClawConfig;
}): SessionHookContext {
return {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg }),
};
}
function buildSessionStartHookPayload(params: {
sessionId: string;
sessionKey: string;
cfg: OpenClawConfig;
resumedFrom?: string;
}): {
event: { sessionId: string; sessionKey: string; resumedFrom?: string };
context: SessionHookContext;
} {
return {
event: {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
resumedFrom: params.resumedFrom,
},
context: buildSessionHookContext({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
cfg: params.cfg,
}),
};
}
function buildSessionEndHookPayload(params: {
sessionId: string;
sessionKey: string;
cfg: OpenClawConfig;
messageCount?: number;
}): {
event: { sessionId: string; sessionKey: string; messageCount: number };
context: SessionHookContext;
} {
return {
event: {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
messageCount: params.messageCount ?? 0,
},
context: buildSessionHookContext({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
cfg: params.cfg,
}),
};
}
function resolveParentForkMaxTokens(cfg: OpenClawConfig): number {
const configured = cfg.session?.parentForkMaxTokens;
if (typeof configured === "number" && Number.isFinite(configured) && configured >= 0) {
@@ -707,24 +643,39 @@ export async function initSessionState(params: {
// If replacing an existing session, fire session_end for the old one
if (previousSessionEntry?.sessionId && previousSessionEntry.sessionId !== effectiveSessionId) {
if (hookRunner.hasHooks("session_end")) {
const payload = buildSessionEndHookPayload({
sessionId: previousSessionEntry.sessionId,
sessionKey,
cfg,
});
void hookRunner.runSessionEnd(payload.event, payload.context).catch(() => {});
void hookRunner
.runSessionEnd(
{
sessionId: previousSessionEntry.sessionId,
sessionKey,
messageCount: 0,
},
{
sessionId: previousSessionEntry.sessionId,
sessionKey,
agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
},
)
.catch(() => {});
}
}
// Fire session_start for the new session
if (hookRunner.hasHooks("session_start")) {
const payload = buildSessionStartHookPayload({
sessionId: effectiveSessionId,
sessionKey,
cfg,
resumedFrom: previousSessionEntry?.sessionId,
});
void hookRunner.runSessionStart(payload.event, payload.context).catch(() => {});
void hookRunner
.runSessionStart(
{
sessionId: effectiveSessionId,
sessionKey,
resumedFrom: previousSessionEntry?.sessionId,
},
{
sessionId: effectiveSessionId,
sessionKey,
agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
},
)
.catch(() => {});
}
}

View File

@@ -3,7 +3,6 @@ import {
buildParseArgv,
getFlagValue,
getCommandPath,
getCommandPositionalsWithRootOptions,
getCommandPathWithRootOptions,
getPrimaryCommand,
getPositiveIntFlagValue,
@@ -171,41 +170,6 @@ describe("argv helpers", () => {
).toEqual(["config", "validate"]);
});
it("extracts routed config get positionals with interleaved root options", () => {
expect(
getCommandPositionalsWithRootOptions(
["node", "openclaw", "config", "get", "--log-level", "debug", "update.channel", "--json"],
{
commandPath: ["config", "get"],
booleanFlags: ["--json"],
},
),
).toEqual(["update.channel"]);
});
it("extracts routed config unset positionals with interleaved root options", () => {
expect(
getCommandPositionalsWithRootOptions(
["node", "openclaw", "config", "unset", "--profile", "work", "update.channel"],
{
commandPath: ["config", "unset"],
},
),
).toEqual(["update.channel"]);
});
it("returns null when routed command sees unknown options", () => {
expect(
getCommandPositionalsWithRootOptions(
["node", "openclaw", "config", "get", "--mystery", "value", "update.channel"],
{
commandPath: ["config", "get"],
booleanFlags: ["--json"],
},
),
).toBeNull();
});
it.each([
{
name: "returns first command token",

View File

@@ -188,91 +188,6 @@ export function getPrimaryCommand(argv: string[]): string | null {
return primary ?? null;
}
type CommandPositionalsParseOptions = {
commandPath: ReadonlyArray<string>;
booleanFlags?: ReadonlyArray<string>;
valueFlags?: ReadonlyArray<string>;
};
function consumeKnownOptionToken(
args: ReadonlyArray<string>,
index: number,
booleanFlags: ReadonlySet<string>,
valueFlags: ReadonlySet<string>,
): number {
const arg = args[index];
if (!arg || arg === FLAG_TERMINATOR || !arg.startsWith("-")) {
return 0;
}
const equalsIndex = arg.indexOf("=");
const flag = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
if (booleanFlags.has(flag)) {
return equalsIndex === -1 ? 1 : 0;
}
if (!valueFlags.has(flag)) {
return 0;
}
if (equalsIndex !== -1) {
const value = arg.slice(equalsIndex + 1).trim();
return value ? 1 : 0;
}
return isValueToken(args[index + 1]) ? 2 : 0;
}
export function getCommandPositionalsWithRootOptions(
argv: string[],
options: CommandPositionalsParseOptions,
): string[] | null {
const args = argv.slice(2);
const commandPath = options.commandPath;
const booleanFlags = new Set(options.booleanFlags ?? []);
const valueFlags = new Set(options.valueFlags ?? []);
const positionals: string[] = [];
let commandIndex = 0;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (!arg || arg === FLAG_TERMINATOR) {
break;
}
const rootConsumed = consumeRootOptionToken(args, i);
if (rootConsumed > 0) {
i += rootConsumed - 1;
continue;
}
if (arg.startsWith("-")) {
const optionConsumed = consumeKnownOptionToken(args, i, booleanFlags, valueFlags);
if (optionConsumed === 0) {
return null;
}
i += optionConsumed - 1;
continue;
}
if (commandIndex < commandPath.length) {
if (arg !== commandPath[commandIndex]) {
return null;
}
commandIndex += 1;
continue;
}
positionals.push(arg);
}
if (commandIndex < commandPath.length) {
return null;
}
return positionals;
}
export function buildParseArgv(params: {
programName?: string;
rawArgs?: string[];

View File

@@ -102,38 +102,6 @@ describe("program routes", () => {
expect(runConfigUnsetMock).toHaveBeenCalledWith({ path: "update.channel" });
});
it("passes config get path when root value options appear after subcommand", async () => {
const route = expectRoute(["config", "get"]);
await expect(
route?.run([
"node",
"openclaw",
"config",
"get",
"--log-level",
"debug",
"update.channel",
"--json",
]),
).resolves.toBe(true);
expect(runConfigGetMock).toHaveBeenCalledWith({ path: "update.channel", json: true });
});
it("passes config unset path when root value options appear after subcommand", async () => {
const route = expectRoute(["config", "unset"]);
await expect(
route?.run(["node", "openclaw", "config", "unset", "--profile", "work", "update.channel"]),
).resolves.toBe(true);
expect(runConfigUnsetMock).toHaveBeenCalledWith({ path: "update.channel" });
});
it("returns false for config get route when unknown option appears", async () => {
await expectRunFalse(
["config", "get"],
["node", "openclaw", "config", "get", "--mystery", "value", "update.channel"],
);
});
it("returns false for memory status route when --agent value is missing", async () => {
await expectRunFalse(["memory", "status"], ["node", "openclaw", "memory", "status", "--agent"]);
});

View File

@@ -1,12 +1,6 @@
import { isValueToken } from "../../infra/cli-root-options.js";
import { consumeRootOptionToken, isValueToken } from "../../infra/cli-root-options.js";
import { defaultRuntime } from "../../runtime.js";
import {
getCommandPositionalsWithRootOptions,
getFlagValue,
getPositiveIntFlagValue,
getVerboseFlag,
hasFlag,
} from "../argv.js";
import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js";
export type RouteSpec = {
match: (path: string[]) => boolean;
@@ -106,6 +100,31 @@ const routeMemoryStatus: RouteSpec = {
},
};
function getCommandPositionals(argv: string[]): string[] {
const out: string[] = [];
const args = argv.slice(2);
let commandStarted = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (!arg || arg === "--") {
break;
}
if (!commandStarted) {
const consumed = consumeRootOptionToken(args, i);
if (consumed > 0) {
i += consumed - 1;
continue;
}
}
if (arg.startsWith("-")) {
continue;
}
commandStarted = true;
out.push(arg);
}
return out;
}
function getFlagValues(argv: string[], name: string): string[] | null {
const values: string[] = [];
const args = argv.slice(2);
@@ -137,14 +156,8 @@ function getFlagValues(argv: string[], name: string): string[] | null {
const routeConfigGet: RouteSpec = {
match: (path) => path[0] === "config" && path[1] === "get",
run: async (argv) => {
const positionals = getCommandPositionalsWithRootOptions(argv, {
commandPath: ["config", "get"],
booleanFlags: ["--json"],
});
if (!positionals || positionals.length !== 1) {
return false;
}
const pathArg = positionals[0];
const positionals = getCommandPositionals(argv);
const pathArg = positionals[2];
if (!pathArg) {
return false;
}
@@ -158,13 +171,8 @@ const routeConfigGet: RouteSpec = {
const routeConfigUnset: RouteSpec = {
match: (path) => path[0] === "config" && path[1] === "unset",
run: async (argv) => {
const positionals = getCommandPositionalsWithRootOptions(argv, {
commandPath: ["config", "unset"],
});
if (!positionals || positionals.length !== 1) {
return false;
}
const pathArg = positionals[0];
const positionals = getCommandPositionals(argv);
const pathArg = positionals[2];
if (!pathArg) {
return false;
}

View File

@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { acquireSessionWriteLock } from "../../agents/session-write-lock.js";
@@ -38,7 +39,6 @@ import {
import { applySessionStoreMigrations } from "./store-migrations.js";
import {
mergeSessionEntry,
mergeSessionEntryPreserveActivity,
normalizeSessionRuntimeModelFields,
type SessionEntry,
} from "./types.js";
@@ -738,9 +738,14 @@ export async function recordSessionMetaFromInbound(params: {
return null;
}
const next = existing
? // Inbound metadata updates must not refresh activity timestamps;
// idle reset evaluation relies on updatedAt from actual session turns.
mergeSessionEntryPreserveActivity(existing, patch)
? normalizeSessionRuntimeModelFields({
...existing,
...patch,
// Inbound metadata updates must not refresh activity timestamps;
// idle reset evaluation relies on updatedAt from actual session turns.
sessionId: existing.sessionId ?? crypto.randomUUID(),
updatedAt: existing.updatedAt ?? Date.now(),
})
: mergeSessionEntry(existing, patch);
store[resolved.normalizedKey] = next;
for (const legacyKey of resolved.legacyKeys) {

View File

@@ -225,31 +225,12 @@ export function setSessionRuntimeModel(
return true;
}
export type SessionEntryMergePolicy = "touch-activity" | "preserve-activity";
type MergeSessionEntryOptions = {
policy?: SessionEntryMergePolicy;
now?: number;
};
function resolveMergedUpdatedAt(
export function mergeSessionEntry(
existing: SessionEntry | undefined,
patch: Partial<SessionEntry>,
options?: MergeSessionEntryOptions,
): number {
if (options?.policy === "preserve-activity" && existing) {
return existing.updatedAt ?? patch.updatedAt ?? options.now ?? Date.now();
}
return Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, options?.now ?? Date.now());
}
export function mergeSessionEntryWithPolicy(
existing: SessionEntry | undefined,
patch: Partial<SessionEntry>,
options?: MergeSessionEntryOptions,
): SessionEntry {
const sessionId = patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID();
const updatedAt = resolveMergedUpdatedAt(existing, patch, options);
const updatedAt = Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, Date.now());
if (!existing) {
return normalizeSessionRuntimeModelFields({ ...patch, sessionId, updatedAt });
}
@@ -267,22 +248,6 @@ export function mergeSessionEntryWithPolicy(
return normalizeSessionRuntimeModelFields(next);
}
export function mergeSessionEntry(
existing: SessionEntry | undefined,
patch: Partial<SessionEntry>,
): SessionEntry {
return mergeSessionEntryWithPolicy(existing, patch);
}
export function mergeSessionEntryPreserveActivity(
existing: SessionEntry | undefined,
patch: Partial<SessionEntry>,
): SessionEntry {
return mergeSessionEntryWithPolicy(existing, patch, {
policy: "preserve-activity",
});
}
export function resolveFreshSessionTotalTokens(
entry?: Pick<SessionEntry, "totalTokens" | "totalTokensFresh"> | null,
): number | undefined {

View File

@@ -27,10 +27,4 @@ describe("Dockerfile", () => {
expect(dockerfile).toContain('find "$dir" -type d -exec chmod 755 {} +');
expect(dockerfile).toContain('find "$dir" -type f -exec chmod 644 {} +');
});
it("Docker GPG fingerprint awk uses correct quoting for OPENCLAW_SANDBOX=1 build", async () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
expect(dockerfile).toContain('== "fpr" {');
expect(dockerfile).not.toContain('\\"fpr\\"');
});
});

View File

@@ -1,14 +1,148 @@
import { createRequire } from "node:module";
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js";
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
import {
chunkByNewline,
chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText,
chunkTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
} from "../../auto-reply/chunk.js";
import {
hasControlCommand,
isControlCommandMessage,
shouldComputeCommandAuthorized,
} from "../../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
import { withReplyDispatcher } from "../../auto-reply/dispatch.js";
import {
formatAgentEnvelope,
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
matchesMentionWithExplicit,
} from "../../auto-reply/reply/mentions.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { discordMessageActions } from "../../channels/plugins/actions/discord.js";
import { signalMessageActions } from "../../channels/plugins/actions/signal.js";
import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js";
import { recordInboundSession } from "../../channels/session.js";
import { registerMemoryCli } from "../../cli/memory-cli.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "../../config/group-policy.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { resolveStateDir } from "../../config/paths.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
} from "../../config/sessions.js";
import { auditDiscordChannelPermissions } from "../../discord/audit.js";
import {
listDiscordDirectoryGroupsLive,
listDiscordDirectoryPeersLive,
} from "../../discord/directory-live.js";
import { monitorDiscordProvider } from "../../discord/monitor.js";
import { probeDiscord } from "../../discord/probe.js";
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
import { shouldLogVerbose } from "../../globals.js";
import { monitorIMessageProvider } from "../../imessage/monitor.js";
import { probeIMessage } from "../../imessage/probe.js";
import { sendMessageIMessage } from "../../imessage/send.js";
import { onAgentEvent } from "../../infra/agent-events.js";
import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js";
import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import {
listLineAccountIds,
normalizeAccountId as normalizeLineAccountId,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "../../line/accounts.js";
import { monitorLineProvider } from "../../line/monitor.js";
import { probeLineBot } from "../../line/probe.js";
import {
createQuickReplyItems,
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
sendMessageLine,
} from "../../line/send.js";
import { buildTemplateMessageFromPayload } from "../../line/template-messages.js";
import { getChildLogger } from "../../logging.js";
import { normalizeLogLevel } from "../../logging/levels.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
import { transcribeAudioFile } from "../../media-understanding/transcribe-audio.js";
import { isVoiceCompatibleAudio } from "../../media/audio.js";
import { mediaKindFromMime } from "../../media/constants.js";
import { fetchRemoteMedia } from "../../media/fetch.js";
import { getImageMetadata, resizeToJpeg } from "../../media/image-ops.js";
import { detectMime } from "../../media/mime.js";
import { saveMediaBuffer } from "../../media/store.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { runCommandWithTimeout } from "../../process/exec.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { monitorSignalProvider } from "../../signal/index.js";
import { probeSignal } from "../../signal/probe.js";
import { sendMessageSignal } from "../../signal/send.js";
import {
listSlackDirectoryGroupsLive,
listSlackDirectoryPeersLive,
} from "../../slack/directory-live.js";
import { monitorSlackProvider } from "../../slack/index.js";
import { probeSlack } from "../../slack/probe.js";
import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js";
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
import { sendMessageSlack } from "../../slack/send.js";
import {
auditTelegramGroupMembership,
collectTelegramUnmentionedGroupIds,
} from "../../telegram/audit.js";
import { monitorTelegramProvider } from "../../telegram/monitor.js";
import { probeTelegram } from "../../telegram/probe.js";
import { sendMessageTelegram, sendPollTelegram } from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import { textToSpeechTelephony } from "../../tts/tts.js";
import { createRuntimeChannel } from "./runtime-channel.js";
import { createRuntimeConfig } from "./runtime-config.js";
import { createRuntimeEvents } from "./runtime-events.js";
import { createRuntimeLogging } from "./runtime-logging.js";
import { createRuntimeMedia } from "./runtime-media.js";
import { createRuntimeSystem } from "./runtime-system.js";
import { createRuntimeTools } from "./runtime-tools.js";
import { getActiveWebListener } from "../../web/active-listener.js";
import {
getWebAuthAgeMs,
logoutWeb,
logWebSelfId,
readWebSelfId,
webAuthExists,
} from "../../web/auth-store.js";
import { loadWebMedia } from "../../web/media.js";
import { formatNativeDependencyHint } from "./native-deps.js";
import type { PluginRuntime } from "./types.js";
let cachedVersion: string | null = null;
@@ -28,8 +162,87 @@ function resolveVersion(): string {
}
}
const sendMessageWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendMessageWhatsApp"] = async (
...args
) => {
const { sendMessageWhatsApp } = await loadWebOutbound();
return sendMessageWhatsApp(...args);
};
const sendPollWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendPollWhatsApp"] = async (
...args
) => {
const { sendPollWhatsApp } = await loadWebOutbound();
return sendPollWhatsApp(...args);
};
const loginWebLazy: PluginRuntime["channel"]["whatsapp"]["loginWeb"] = async (...args) => {
const { loginWeb } = await loadWebLogin();
return loginWeb(...args);
};
const startWebLoginWithQrLazy: PluginRuntime["channel"]["whatsapp"]["startWebLoginWithQr"] = async (
...args
) => {
const { startWebLoginWithQr } = await loadWebLoginQr();
return startWebLoginWithQr(...args);
};
const waitForWebLoginLazy: PluginRuntime["channel"]["whatsapp"]["waitForWebLogin"] = async (
...args
) => {
const { waitForWebLogin } = await loadWebLoginQr();
return waitForWebLogin(...args);
};
const monitorWebChannelLazy: PluginRuntime["channel"]["whatsapp"]["monitorWebChannel"] = async (
...args
) => {
const { monitorWebChannel } = await loadWebChannel();
return monitorWebChannel(...args);
};
const handleWhatsAppActionLazy: PluginRuntime["channel"]["whatsapp"]["handleWhatsAppAction"] =
async (...args) => {
const { handleWhatsAppAction } = await loadWhatsAppActions();
return handleWhatsAppAction(...args);
};
let webOutboundPromise: Promise<typeof import("../../web/outbound.js")> | null = null;
let webLoginPromise: Promise<typeof import("../../web/login.js")> | null = null;
let webLoginQrPromise: Promise<typeof import("../../web/login-qr.js")> | null = null;
let webChannelPromise: Promise<typeof import("../../channels/web/index.js")> | null = null;
let whatsappActionsPromise: Promise<
typeof import("../../agents/tools/whatsapp-actions.js")
> | null = null;
function loadWebOutbound() {
webOutboundPromise ??= import("../../web/outbound.js");
return webOutboundPromise;
}
function loadWebLogin() {
webLoginPromise ??= import("../../web/login.js");
return webLoginPromise;
}
function loadWebLoginQr() {
webLoginQrPromise ??= import("../../web/login-qr.js");
return webLoginQrPromise;
}
function loadWebChannel() {
webChannelPromise ??= import("../../channels/web/index.js");
return webChannelPromise;
}
function loadWhatsAppActions() {
whatsappActionsPromise ??= import("../../agents/tools/whatsapp-actions.js");
return whatsappActionsPromise;
}
export function createPluginRuntime(): PluginRuntime {
const runtime = {
return {
version: resolveVersion(),
config: createRuntimeConfig(),
system: createRuntimeSystem(),
@@ -38,12 +251,226 @@ export function createPluginRuntime(): PluginRuntime {
stt: { transcribeAudioFile },
tools: createRuntimeTools(),
channel: createRuntimeChannel(),
events: createRuntimeEvents(),
events: {
onAgentEvent,
onSessionTranscriptUpdate,
},
logging: createRuntimeLogging(),
state: { resolveStateDir },
} satisfies PluginRuntime;
};
}
return runtime;
function createRuntimeConfig(): PluginRuntime["config"] {
return {
loadConfig,
writeConfigFile,
};
}
function createRuntimeSystem(): PluginRuntime["system"] {
return {
enqueueSystemEvent,
requestHeartbeatNow,
runCommandWithTimeout,
formatNativeDependencyHint,
};
}
function createRuntimeMedia(): PluginRuntime["media"] {
return {
loadWebMedia,
detectMime,
mediaKindFromMime,
isVoiceCompatibleAudio,
getImageMetadata,
resizeToJpeg,
};
}
function createRuntimeTools(): PluginRuntime["tools"] {
return {
createMemoryGetTool,
createMemorySearchTool,
registerMemoryCli,
};
}
function createRuntimeChannel(): PluginRuntime["channel"] {
return {
text: {
chunkByNewline,
chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText,
chunkTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
hasControlCommand,
resolveMarkdownTableMode,
convertMarkdownTables,
},
reply: {
dispatchReplyWithBufferedBlockDispatcher,
createReplyDispatcherWithTyping,
resolveEffectiveMessagesConfig,
resolveHumanDelayConfig,
dispatchReplyFromConfig,
withReplyDispatcher,
finalizeInboundContext,
formatAgentEnvelope,
/** @deprecated Prefer `BodyForAgent` + structured user-context blocks (do not build plaintext envelopes for prompts). */
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
},
routing: {
resolveAgentRoute,
},
pairing: {
buildPairingReply,
readAllowFromStore: ({ channel, accountId, env }) =>
readChannelAllowFromStore(channel, env, accountId),
upsertPairingRequest: ({ channel, id, accountId, meta, env, pairingAdapter }) =>
upsertChannelPairingRequest({
channel,
id,
accountId,
meta,
env,
pairingAdapter,
}),
},
media: {
fetchRemoteMedia,
saveMediaBuffer,
},
activity: {
record: recordChannelActivity,
get: getChannelActivity,
},
session: {
resolveStorePath,
readSessionUpdatedAt,
recordSessionMetaFromInbound,
recordInboundSession,
updateLastRoute,
},
mentions: {
buildMentionRegexes,
matchesMentionPatterns,
matchesMentionWithExplicit,
},
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: {
resolveGroupPolicy: resolveChannelGroupPolicy,
resolveRequireMention: resolveChannelGroupRequireMention,
},
debounce: {
createInboundDebouncer,
resolveInboundDebounceMs,
},
commands: {
resolveCommandAuthorizedFromAuthorizers,
isControlCommandMessage,
shouldComputeCommandAuthorized,
shouldHandleTextCommands,
},
discord: {
messageActions: discordMessageActions,
auditChannelPermissions: auditDiscordChannelPermissions,
listDirectoryGroupsLive: listDiscordDirectoryGroupsLive,
listDirectoryPeersLive: listDiscordDirectoryPeersLive,
probeDiscord,
resolveChannelAllowlist: resolveDiscordChannelAllowlist,
resolveUserAllowlist: resolveDiscordUserAllowlist,
sendMessageDiscord,
sendPollDiscord,
monitorDiscordProvider,
},
slack: {
listDirectoryGroupsLive: listSlackDirectoryGroupsLive,
listDirectoryPeersLive: listSlackDirectoryPeersLive,
probeSlack,
resolveChannelAllowlist: resolveSlackChannelAllowlist,
resolveUserAllowlist: resolveSlackUserAllowlist,
sendMessageSlack,
monitorSlackProvider,
handleSlackAction,
},
telegram: {
auditGroupMembership: auditTelegramGroupMembership,
collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds,
probeTelegram,
resolveTelegramToken,
sendMessageTelegram,
sendPollTelegram,
monitorTelegramProvider,
messageActions: telegramMessageActions,
},
signal: {
probeSignal,
sendMessageSignal,
monitorSignalProvider,
messageActions: signalMessageActions,
},
imessage: {
monitorIMessageProvider,
probeIMessage,
sendMessageIMessage,
},
whatsapp: {
getActiveWebListener,
getWebAuthAgeMs,
logoutWeb,
logWebSelfId,
readWebSelfId,
webAuthExists,
sendMessageWhatsApp: sendMessageWhatsAppLazy,
sendPollWhatsApp: sendPollWhatsAppLazy,
loginWeb: loginWebLazy,
startWebLoginWithQr: startWebLoginWithQrLazy,
waitForWebLogin: waitForWebLoginLazy,
monitorWebChannel: monitorWebChannelLazy,
handleWhatsAppAction: handleWhatsAppActionLazy,
createLoginTool: createWhatsAppLoginTool,
},
line: {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
normalizeAccountId: normalizeLineAccountId,
probeLineBot,
sendMessageLine,
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
monitorLineProvider,
},
};
}
function createRuntimeLogging(): PluginRuntime["logging"] {
return {
shouldLogVerbose,
getChildLogger: (bindings, opts) => {
const logger = getChildLogger(bindings, {
level: opts?.level ? normalizeLogLevel(opts.level) : undefined,
});
return {
debug: (message) => logger.debug?.(message),
info: (message) => logger.info(message),
warn: (message) => logger.warn(message),
error: (message) => logger.error(message),
};
},
};
}
export type { PluginRuntime } from "./types.js";

View File

@@ -1,263 +0,0 @@
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
import {
chunkByNewline,
chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText,
chunkTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
} from "../../auto-reply/chunk.js";
import {
hasControlCommand,
isControlCommandMessage,
shouldComputeCommandAuthorized,
} from "../../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
import { withReplyDispatcher } from "../../auto-reply/dispatch.js";
import {
formatAgentEnvelope,
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
matchesMentionWithExplicit,
} from "../../auto-reply/reply/mentions.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { discordMessageActions } from "../../channels/plugins/actions/discord.js";
import { signalMessageActions } from "../../channels/plugins/actions/signal.js";
import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
import { recordInboundSession } from "../../channels/session.js";
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "../../config/group-policy.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
} from "../../config/sessions.js";
import { auditDiscordChannelPermissions } from "../../discord/audit.js";
import {
listDiscordDirectoryGroupsLive,
listDiscordDirectoryPeersLive,
} from "../../discord/directory-live.js";
import { monitorDiscordProvider } from "../../discord/monitor.js";
import { probeDiscord } from "../../discord/probe.js";
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
import { monitorIMessageProvider } from "../../imessage/monitor.js";
import { probeIMessage } from "../../imessage/probe.js";
import { sendMessageIMessage } from "../../imessage/send.js";
import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js";
import {
listLineAccountIds,
normalizeAccountId as normalizeLineAccountId,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "../../line/accounts.js";
import { monitorLineProvider } from "../../line/monitor.js";
import { probeLineBot } from "../../line/probe.js";
import {
createQuickReplyItems,
pushFlexMessage,
pushLocationMessage,
pushMessageLine,
pushMessagesLine,
pushTemplateMessage,
pushTextMessageWithQuickReplies,
sendMessageLine,
} from "../../line/send.js";
import { buildTemplateMessageFromPayload } from "../../line/template-messages.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
import { fetchRemoteMedia } from "../../media/fetch.js";
import { saveMediaBuffer } from "../../media/store.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { monitorSignalProvider } from "../../signal/index.js";
import { probeSignal } from "../../signal/probe.js";
import { sendMessageSignal } from "../../signal/send.js";
import {
listSlackDirectoryGroupsLive,
listSlackDirectoryPeersLive,
} from "../../slack/directory-live.js";
import { monitorSlackProvider } from "../../slack/index.js";
import { probeSlack } from "../../slack/probe.js";
import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js";
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
import { sendMessageSlack } from "../../slack/send.js";
import {
auditTelegramGroupMembership,
collectTelegramUnmentionedGroupIds,
} from "../../telegram/audit.js";
import { monitorTelegramProvider } from "../../telegram/monitor.js";
import { probeTelegram } from "../../telegram/probe.js";
import { sendMessageTelegram, sendPollTelegram } from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import { createRuntimeWhatsApp } from "./runtime-whatsapp.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeChannel(): PluginRuntime["channel"] {
return {
text: {
chunkByNewline,
chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText,
chunkTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
hasControlCommand,
resolveMarkdownTableMode,
convertMarkdownTables,
},
reply: {
dispatchReplyWithBufferedBlockDispatcher,
createReplyDispatcherWithTyping,
resolveEffectiveMessagesConfig,
resolveHumanDelayConfig,
dispatchReplyFromConfig,
withReplyDispatcher,
finalizeInboundContext,
formatAgentEnvelope,
/** @deprecated Prefer `BodyForAgent` + structured user-context blocks (do not build plaintext envelopes for prompts). */
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
},
routing: {
resolveAgentRoute,
},
pairing: {
buildPairingReply,
readAllowFromStore: ({ channel, accountId, env }) =>
readChannelAllowFromStore(channel, env, accountId),
upsertPairingRequest: ({ channel, id, accountId, meta, env, pairingAdapter }) =>
upsertChannelPairingRequest({
channel,
id,
accountId,
meta,
env,
pairingAdapter,
}),
},
media: {
fetchRemoteMedia,
saveMediaBuffer,
},
activity: {
record: recordChannelActivity,
get: getChannelActivity,
},
session: {
resolveStorePath,
readSessionUpdatedAt,
recordSessionMetaFromInbound,
recordInboundSession,
updateLastRoute,
},
mentions: {
buildMentionRegexes,
matchesMentionPatterns,
matchesMentionWithExplicit,
},
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: {
resolveGroupPolicy: resolveChannelGroupPolicy,
resolveRequireMention: resolveChannelGroupRequireMention,
},
debounce: {
createInboundDebouncer,
resolveInboundDebounceMs,
},
commands: {
resolveCommandAuthorizedFromAuthorizers,
isControlCommandMessage,
shouldComputeCommandAuthorized,
shouldHandleTextCommands,
},
discord: {
messageActions: discordMessageActions,
auditChannelPermissions: auditDiscordChannelPermissions,
listDirectoryGroupsLive: listDiscordDirectoryGroupsLive,
listDirectoryPeersLive: listDiscordDirectoryPeersLive,
probeDiscord,
resolveChannelAllowlist: resolveDiscordChannelAllowlist,
resolveUserAllowlist: resolveDiscordUserAllowlist,
sendMessageDiscord,
sendPollDiscord,
monitorDiscordProvider,
},
slack: {
listDirectoryGroupsLive: listSlackDirectoryGroupsLive,
listDirectoryPeersLive: listSlackDirectoryPeersLive,
probeSlack,
resolveChannelAllowlist: resolveSlackChannelAllowlist,
resolveUserAllowlist: resolveSlackUserAllowlist,
sendMessageSlack,
monitorSlackProvider,
handleSlackAction,
},
telegram: {
auditGroupMembership: auditTelegramGroupMembership,
collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds,
probeTelegram,
resolveTelegramToken,
sendMessageTelegram,
sendPollTelegram,
monitorTelegramProvider,
messageActions: telegramMessageActions,
},
signal: {
probeSignal,
sendMessageSignal,
monitorSignalProvider,
messageActions: signalMessageActions,
},
imessage: {
monitorIMessageProvider,
probeIMessage,
sendMessageIMessage,
},
whatsapp: createRuntimeWhatsApp(),
line: {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
normalizeAccountId: normalizeLineAccountId,
probeLineBot,
sendMessageLine,
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
monitorLineProvider,
},
};
}

View File

@@ -1,9 +0,0 @@
import { loadConfig, writeConfigFile } from "../../config/config.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeConfig(): PluginRuntime["config"] {
return {
loadConfig,
writeConfigFile,
};
}

View File

@@ -1,10 +0,0 @@
import { onAgentEvent } from "../../infra/agent-events.js";
import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeEvents(): PluginRuntime["events"] {
return {
onAgentEvent,
onSessionTranscriptUpdate,
};
}

View File

@@ -1,21 +0,0 @@
import { shouldLogVerbose } from "../../globals.js";
import { getChildLogger } from "../../logging.js";
import { normalizeLogLevel } from "../../logging/levels.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeLogging(): PluginRuntime["logging"] {
return {
shouldLogVerbose,
getChildLogger: (bindings, opts) => {
const logger = getChildLogger(bindings, {
level: opts?.level ? normalizeLogLevel(opts.level) : undefined,
});
return {
debug: (message) => logger.debug?.(message),
info: (message) => logger.info(message),
warn: (message) => logger.warn(message),
error: (message) => logger.error(message),
};
},
};
}

View File

@@ -1,17 +0,0 @@
import { isVoiceCompatibleAudio } from "../../media/audio.js";
import { mediaKindFromMime } from "../../media/constants.js";
import { getImageMetadata, resizeToJpeg } from "../../media/image-ops.js";
import { detectMime } from "../../media/mime.js";
import { loadWebMedia } from "../../web/media.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeMedia(): PluginRuntime["media"] {
return {
loadWebMedia,
detectMime,
mediaKindFromMime,
isVoiceCompatibleAudio,
getImageMetadata,
resizeToJpeg,
};
}

View File

@@ -1,14 +0,0 @@
import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { runCommandWithTimeout } from "../../process/exec.js";
import { formatNativeDependencyHint } from "./native-deps.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeSystem(): PluginRuntime["system"] {
return {
enqueueSystemEvent,
requestHeartbeatNow,
runCommandWithTimeout,
formatNativeDependencyHint,
};
}

View File

@@ -1,11 +0,0 @@
import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js";
import { registerMemoryCli } from "../../cli/memory-cli.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeTools(): PluginRuntime["tools"] {
return {
createMemoryGetTool,
createMemorySearchTool,
registerMemoryCli,
};
}

View File

@@ -1,108 +0,0 @@
import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js";
import { getActiveWebListener } from "../../web/active-listener.js";
import {
getWebAuthAgeMs,
logoutWeb,
logWebSelfId,
readWebSelfId,
webAuthExists,
} from "../../web/auth-store.js";
import type { PluginRuntime } from "./types.js";
const sendMessageWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendMessageWhatsApp"] = async (
...args
) => {
const { sendMessageWhatsApp } = await loadWebOutbound();
return sendMessageWhatsApp(...args);
};
const sendPollWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendPollWhatsApp"] = async (
...args
) => {
const { sendPollWhatsApp } = await loadWebOutbound();
return sendPollWhatsApp(...args);
};
const loginWebLazy: PluginRuntime["channel"]["whatsapp"]["loginWeb"] = async (...args) => {
const { loginWeb } = await loadWebLogin();
return loginWeb(...args);
};
const startWebLoginWithQrLazy: PluginRuntime["channel"]["whatsapp"]["startWebLoginWithQr"] = async (
...args
) => {
const { startWebLoginWithQr } = await loadWebLoginQr();
return startWebLoginWithQr(...args);
};
const waitForWebLoginLazy: PluginRuntime["channel"]["whatsapp"]["waitForWebLogin"] = async (
...args
) => {
const { waitForWebLogin } = await loadWebLoginQr();
return waitForWebLogin(...args);
};
const monitorWebChannelLazy: PluginRuntime["channel"]["whatsapp"]["monitorWebChannel"] = async (
...args
) => {
const { monitorWebChannel } = await loadWebChannel();
return monitorWebChannel(...args);
};
const handleWhatsAppActionLazy: PluginRuntime["channel"]["whatsapp"]["handleWhatsAppAction"] =
async (...args) => {
const { handleWhatsAppAction } = await loadWhatsAppActions();
return handleWhatsAppAction(...args);
};
let webOutboundPromise: Promise<typeof import("../../web/outbound.js")> | null = null;
let webLoginPromise: Promise<typeof import("../../web/login.js")> | null = null;
let webLoginQrPromise: Promise<typeof import("../../web/login-qr.js")> | null = null;
let webChannelPromise: Promise<typeof import("../../channels/web/index.js")> | null = null;
let whatsappActionsPromise: Promise<
typeof import("../../agents/tools/whatsapp-actions.js")
> | null = null;
function loadWebOutbound() {
webOutboundPromise ??= import("../../web/outbound.js");
return webOutboundPromise;
}
function loadWebLogin() {
webLoginPromise ??= import("../../web/login.js");
return webLoginPromise;
}
function loadWebLoginQr() {
webLoginQrPromise ??= import("../../web/login-qr.js");
return webLoginQrPromise;
}
function loadWebChannel() {
webChannelPromise ??= import("../../channels/web/index.js");
return webChannelPromise;
}
function loadWhatsAppActions() {
whatsappActionsPromise ??= import("../../agents/tools/whatsapp-actions.js");
return whatsappActionsPromise;
}
export function createRuntimeWhatsApp(): PluginRuntime["channel"]["whatsapp"] {
return {
getActiveWebListener,
getWebAuthAgeMs,
logoutWeb,
logWebSelfId,
readWebSelfId,
webAuthExists,
sendMessageWhatsApp: sendMessageWhatsAppLazy,
sendPollWhatsApp: sendPollWhatsAppLazy,
loginWeb: loginWebLazy,
startWebLoginWithQr: startWebLoginWithQrLazy,
waitForWebLogin: waitForWebLoginLazy,
monitorWebChannel: monitorWebChannelLazy,
handleWhatsAppAction: handleWhatsAppActionLazy,
createLoginTool: createWhatsAppLoginTool,
};
}

View File

@@ -150,7 +150,6 @@ describe("security audit", () => {
let fixtureRoot = "";
let caseId = 0;
let channelSecurityRoot = "";
let sharedChannelSecurityStateDir = "";
let sharedCodeSafetyStateDir = "";
let sharedCodeSafetyWorkspaceDir = "";
let sharedExtensionsStateDir = "";
@@ -162,24 +161,12 @@ describe("security audit", () => {
return dir;
};
const createFilesystemAuditFixture = async (label: string) => {
const tmp = await makeTmpDir(label);
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
const configPath = path.join(stateDir, "openclaw.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
if (!isWindows) {
await fs.chmod(configPath, 0o600);
}
return { tmp, stateDir, configPath };
};
const withChannelSecurityStateDir = async (fn: (tmp: string) => Promise<void>) => {
const credentialsDir = path.join(sharedChannelSecurityStateDir, "credentials");
await fs.rm(credentialsDir, { recursive: true, force: true }).catch(() => undefined);
const channelSecurityStateDir = path.join(channelSecurityRoot, `state-${caseId++}`);
const credentialsDir = path.join(channelSecurityStateDir, "credentials");
await fs.mkdir(credentialsDir, { recursive: true, mode: 0o700 });
await withEnvAsync({ OPENCLAW_STATE_DIR: sharedChannelSecurityStateDir }, () =>
fn(sharedChannelSecurityStateDir),
await withEnvAsync({ OPENCLAW_STATE_DIR: channelSecurityStateDir }, () =>
fn(channelSecurityStateDir),
);
};
@@ -227,11 +214,6 @@ description: test skill
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-"));
channelSecurityRoot = path.join(fixtureRoot, "channel-security");
await fs.mkdir(channelSecurityRoot, { recursive: true, mode: 0o700 });
sharedChannelSecurityStateDir = path.join(channelSecurityRoot, "state-shared");
await fs.mkdir(path.join(sharedChannelSecurityStateDir, "credentials"), {
recursive: true,
mode: 0o700,
});
const codeSafetyFixture = await createSharedCodeSafetyFixture();
sharedCodeSafetyStateDir = codeSafetyFixture.stateDir;
sharedCodeSafetyWorkspaceDir = codeSafetyFixture.workspaceDir;
@@ -700,7 +682,12 @@ description: test skill
});
it("warns when sandbox browser containers have missing or stale hash labels", async () => {
const { stateDir, configPath } = await createFilesystemAuditFixture("browser-hash-labels");
const tmp = await makeTmpDir("browser-hash-labels");
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
const configPath = path.join(stateDir, "openclaw.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
await fs.chmod(configPath, 0o600);
const execDockerRawFn = (async (args: string[]) => {
if (args[0] === "ps") {
@@ -749,7 +736,12 @@ description: test skill
});
it("skips sandbox browser hash label checks when docker inspect is unavailable", async () => {
const { stateDir, configPath } = await createFilesystemAuditFixture("browser-hash-labels-skip");
const tmp = await makeTmpDir("browser-hash-labels-skip");
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
const configPath = path.join(stateDir, "openclaw.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
await fs.chmod(configPath, 0o600);
const execDockerRawFn = (async () => {
throw new Error("spawn docker ENOENT");
@@ -769,9 +761,12 @@ description: test skill
});
it("flags sandbox browser containers with non-loopback published ports", async () => {
const { stateDir, configPath } = await createFilesystemAuditFixture(
"browser-non-loopback-publish",
);
const tmp = await makeTmpDir("browser-non-loopback-publish");
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
const configPath = path.join(stateDir, "openclaw.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
await fs.chmod(configPath, 0o600);
const execDockerRawFn = (async (args: string[]) => {
if (args[0] === "ps") {

View File

@@ -6,7 +6,7 @@ import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
import { resolveBrowserControlAuth } from "../browser/control-auth.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
@@ -104,10 +104,6 @@ export type SecurityAuditOptions = {
execIcacls?: ExecFn;
/** Dependency injection for tests (Docker label checks). */
execDockerRawFn?: typeof execDockerRaw;
/** Optional preloaded config snapshot to skip audit-time config file reads. */
configSnapshot?: ConfigFileSnapshot | null;
/** Optional cache for code-safety summaries across repeated deep audits. */
codeSafetySummaryCache?: Map<string, Promise<unknown>>;
};
function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary {
@@ -1037,14 +1033,11 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
const configSnapshot =
opts.includeFilesystem !== false
? opts.configSnapshot !== undefined
? opts.configSnapshot
: await readConfigSnapshotForAudit({ env, configPath }).catch(() => null)
? await readConfigSnapshotForAudit({ env, configPath }).catch(() => null)
: null;
if (opts.includeFilesystem !== false) {
const codeSafetySummaryCache =
opts.codeSafetySummaryCache ?? new Map<string, Promise<unknown>>();
const codeSafetySummaryCache = new Map<string, Promise<unknown>>();
findings.push(
...(await collectFilesystemFindings({
stateDir,

View File

@@ -33,13 +33,6 @@ import { resolveSlackSlashCommandConfig } from "./commands.js";
import { createSlackMonitorContext } from "./context.js";
import { registerSlackMonitorEvents } from "./events.js";
import { createSlackMessageHandler } from "./message-handler.js";
import {
formatUnknownError,
getSocketEmitter,
isNonRecoverableSlackAuthError,
SLACK_SOCKET_RECONNECT_POLICY,
waitForSlackSocketDisconnect,
} from "./reconnect-policy.js";
import { registerSlackMonitorSlashCommands } from "./slash.js";
import type { MonitorSlackOpts } from "./types.js";
@@ -54,6 +47,113 @@ const { App, HTTPReceiver } = slackBolt;
const SLACK_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
const SLACK_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
const SLACK_SOCKET_RECONNECT_POLICY = {
initialMs: 2_000,
maxMs: 30_000,
factor: 1.8,
jitter: 0.25,
maxAttempts: 12,
} as const;
type SlackSocketDisconnectEvent = "disconnect" | "unable_to_socket_mode_start" | "error";
type EmitterLike = {
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
off: (event: string, listener: (...args: unknown[]) => void) => unknown;
};
function getSocketEmitter(app: unknown): EmitterLike | null {
const receiver = (app as { receiver?: unknown }).receiver;
const client =
receiver && typeof receiver === "object"
? (receiver as { client?: unknown }).client
: undefined;
if (!client || typeof client !== "object") {
return null;
}
const on = (client as { on?: unknown }).on;
const off = (client as { off?: unknown }).off;
if (typeof on !== "function" || typeof off !== "function") {
return null;
}
return {
on: (event, listener) =>
(
on as (this: unknown, event: string, listener: (...args: unknown[]) => void) => unknown
).call(client, event, listener),
off: (event, listener) =>
(
off as (this: unknown, event: string, listener: (...args: unknown[]) => void) => unknown
).call(client, event, listener),
};
}
function waitForSlackSocketDisconnect(
app: unknown,
abortSignal?: AbortSignal,
): Promise<{
event: SlackSocketDisconnectEvent;
error?: unknown;
}> {
return new Promise((resolve) => {
const emitter = getSocketEmitter(app);
if (!emitter) {
abortSignal?.addEventListener("abort", () => resolve({ event: "disconnect" }), {
once: true,
});
return;
}
const disconnectListener = () => resolveOnce({ event: "disconnect" });
const startFailListener = (error?: unknown) =>
resolveOnce({ event: "unable_to_socket_mode_start", error });
const errorListener = (error: unknown) => resolveOnce({ event: "error", error });
const abortListener = () => resolveOnce({ event: "disconnect" });
const cleanup = () => {
emitter.off("disconnected", disconnectListener);
emitter.off("unable_to_socket_mode_start", startFailListener);
emitter.off("error", errorListener);
abortSignal?.removeEventListener("abort", abortListener);
};
const resolveOnce = (value: { event: SlackSocketDisconnectEvent; error?: unknown }) => {
cleanup();
resolve(value);
};
emitter.on("disconnected", disconnectListener);
emitter.on("unable_to_socket_mode_start", startFailListener);
emitter.on("error", errorListener);
abortSignal?.addEventListener("abort", abortListener, { once: true });
});
}
/**
* Detect non-recoverable Slack API / auth errors that should NOT be retried.
* These indicate permanent credential problems (revoked bot, deactivated account, etc.)
* and retrying will never succeed — continuing to retry blocks the entire gateway.
*/
export function isNonRecoverableSlackAuthError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "";
return /account_inactive|invalid_auth|token_revoked|token_expired|not_authed|org_login_required|team_access_not_granted|missing_scope|cannot_find_service|invalid_token/i.test(
msg,
);
}
function formatUnknownError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return "unknown error";
}
}
function parseApiAppIdFromAppToken(raw?: string) {
const token = raw?.trim();
@@ -472,8 +572,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
}
}
export { isNonRecoverableSlackAuthError } from "./reconnect-policy.js";
export const __testing = {
resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,

View File

@@ -1,108 +0,0 @@
const SLACK_AUTH_ERROR_RE =
/account_inactive|invalid_auth|token_revoked|token_expired|not_authed|org_login_required|team_access_not_granted|missing_scope|cannot_find_service|invalid_token/i;
export const SLACK_SOCKET_RECONNECT_POLICY = {
initialMs: 2_000,
maxMs: 30_000,
factor: 1.8,
jitter: 0.25,
maxAttempts: 12,
} as const;
export type SlackSocketDisconnectEvent = "disconnect" | "unable_to_socket_mode_start" | "error";
type EmitterLike = {
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
off: (event: string, listener: (...args: unknown[]) => void) => unknown;
};
export function getSocketEmitter(app: unknown): EmitterLike | null {
const receiver = (app as { receiver?: unknown }).receiver;
const client =
receiver && typeof receiver === "object"
? (receiver as { client?: unknown }).client
: undefined;
if (!client || typeof client !== "object") {
return null;
}
const on = (client as { on?: unknown }).on;
const off = (client as { off?: unknown }).off;
if (typeof on !== "function" || typeof off !== "function") {
return null;
}
return {
on: (event, listener) =>
(
on as (this: unknown, event: string, listener: (...args: unknown[]) => void) => unknown
).call(client, event, listener),
off: (event, listener) =>
(
off as (this: unknown, event: string, listener: (...args: unknown[]) => void) => unknown
).call(client, event, listener),
};
}
export function waitForSlackSocketDisconnect(
app: unknown,
abortSignal?: AbortSignal,
): Promise<{
event: SlackSocketDisconnectEvent;
error?: unknown;
}> {
return new Promise((resolve) => {
const emitter = getSocketEmitter(app);
if (!emitter) {
abortSignal?.addEventListener("abort", () => resolve({ event: "disconnect" }), {
once: true,
});
return;
}
const disconnectListener = () => resolveOnce({ event: "disconnect" });
const startFailListener = (error?: unknown) =>
resolveOnce({ event: "unable_to_socket_mode_start", error });
const errorListener = (error: unknown) => resolveOnce({ event: "error", error });
const abortListener = () => resolveOnce({ event: "disconnect" });
const cleanup = () => {
emitter.off("disconnected", disconnectListener);
emitter.off("unable_to_socket_mode_start", startFailListener);
emitter.off("error", errorListener);
abortSignal?.removeEventListener("abort", abortListener);
};
const resolveOnce = (value: { event: SlackSocketDisconnectEvent; error?: unknown }) => {
cleanup();
resolve(value);
};
emitter.on("disconnected", disconnectListener);
emitter.on("unable_to_socket_mode_start", startFailListener);
emitter.on("error", errorListener);
abortSignal?.addEventListener("abort", abortListener, { once: true });
});
}
/**
* Detect non-recoverable Slack API / auth errors that should NOT be retried.
* These indicate permanent credential problems (revoked bot, deactivated account, etc.)
* and retrying will never succeed — continuing to retry blocks the entire gateway.
*/
export function isNonRecoverableSlackAuthError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "";
return SLACK_AUTH_ERROR_RE.test(msg);
}
export function formatUnknownError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return "unknown error";
}
}

View File

@@ -342,6 +342,166 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(loadSessionStore).toHaveBeenCalledWith("/tmp/sessions.json", { skipCache: true });
});
it("finalizes text-only replies by editing the preview message in place", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "Hel" });
await dispatcherOptions.deliver({ text: "Hello final" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" });
await dispatchWithContext({ context: createContext() });
expect(editMessageTelegram).toHaveBeenCalledWith(123, 999, "Hello final", expect.any(Object));
expect(deliverReplies).not.toHaveBeenCalled();
expect(draftStream.clear).not.toHaveBeenCalled();
expect(draftStream.stop).toHaveBeenCalled();
});
it("edits the preview message created during stop() final flush", async () => {
let messageId: number | undefined;
const draftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => messageId),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockImplementation(async () => {
messageId = 777;
}),
forceNewMessage: vi.fn(),
};
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Short final" }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "777" });
await dispatchWithContext({ context: createContext() });
expect(editMessageTelegram).toHaveBeenCalledWith(123, 777, "Short final", expect.any(Object));
expect(deliverReplies).not.toHaveBeenCalled();
expect(draftStream.stop).toHaveBeenCalled();
});
it("primes stop() with final text when pending partial is below initial threshold", async () => {
let answerMessageId: number | undefined;
const answerDraftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => answerMessageId),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockImplementation(async () => {
answerMessageId = 777;
}),
forceNewMessage: vi.fn(),
};
const reasoningDraftStream = createDraftStream();
createTelegramDraftStream
.mockImplementationOnce(() => answerDraftStream)
.mockImplementationOnce(() => reasoningDraftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "no" });
await dispatcherOptions.deliver({ text: "no problem" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "777" });
await dispatchWithContext({ context: createContext() });
expect(answerDraftStream.update).toHaveBeenCalledWith("no");
expect(answerDraftStream.update).toHaveBeenLastCalledWith("no problem");
expect(editMessageTelegram).toHaveBeenCalledWith(123, 777, "no problem", expect.any(Object));
expect(deliverReplies).not.toHaveBeenCalled();
expect(answerDraftStream.stop).toHaveBeenCalled();
});
it("does not duplicate final delivery when stop-created preview edit fails", async () => {
let messageId: number | undefined;
const draftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => messageId),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockImplementation(async () => {
messageId = 777;
}),
forceNewMessage: vi.fn(),
};
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Short final" }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockRejectedValue(new Error("500: edit failed after stop flush"));
await dispatchWithContext({ context: createContext() });
expect(editMessageTelegram).toHaveBeenCalledWith(123, 777, "Short final", expect.any(Object));
expect(deliverReplies).not.toHaveBeenCalled();
expect(draftStream.stop).toHaveBeenCalled();
});
it("falls back to normal delivery when existing preview edit fails", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "Hel" });
await dispatcherOptions.deliver({ text: "Hello final" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockRejectedValue(new Error("500: preview edit failed"));
await dispatchWithContext({ context: createContext() });
expect(editMessageTelegram).toHaveBeenCalledWith(123, 999, "Hello final", expect.any(Object));
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "Hello final" })],
}),
);
});
it("falls back to normal delivery when stop-created preview has no message id", async () => {
const draftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockReturnValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
forceNewMessage: vi.fn(),
};
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Short final" }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({ context: createContext() });
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "Short final" })],
}),
);
expect(draftStream.stop).toHaveBeenCalled();
});
it("does not overwrite finalized preview when additional final payloads are sent", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
@@ -405,10 +565,30 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.stop).toHaveBeenCalled();
});
it.each([
{ label: "default account config", telegramCfg: {} },
{ label: "account blockStreaming override", telegramCfg: { blockStreaming: true } },
])("disables block streaming when streamMode is off ($label)", async ({ telegramCfg }) => {
it("falls back to normal delivery when preview final is too long to edit", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
const longText = "x".repeat(5000);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: longText }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" });
await dispatchWithContext({ context: createContext() });
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: longText })],
}),
);
expect(draftStream.clear).toHaveBeenCalledTimes(1);
expect(draftStream.stop).toHaveBeenCalled();
});
it("disables block streaming when streamMode is off", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" });
return { queuedFinal: true };
@@ -418,7 +598,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({
context: createContext(),
streamMode: "off",
telegramCfg,
});
expect(createTelegramDraftStream).not.toHaveBeenCalled();
@@ -431,27 +610,69 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
});
it.each(["block", "partial"] as const)(
"forces new message when assistant message restarts (%s mode)",
async (streamMode) => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "First response" });
await replyOptions?.onAssistantMessageStart?.();
await replyOptions?.onPartialReply?.({ text: "After tool call" });
await dispatcherOptions.deliver({ text: "After tool call" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
it("disables block streaming when streamMode is off even if blockStreaming config is true", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({ context: createContext(), streamMode });
await dispatchWithContext({
context: createContext(),
streamMode: "off",
telegramCfg: { blockStreaming: true },
});
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
},
);
expect(createTelegramDraftStream).not.toHaveBeenCalled();
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith(
expect.objectContaining({
replyOptions: expect.objectContaining({
disableBlockStreaming: true,
}),
}),
);
});
it("forces new message for next assistant block in legacy block stream mode", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
// First assistant message: partial text
await replyOptions?.onPartialReply?.({ text: "First response" });
// New assistant message starts (e.g., after tool call)
await replyOptions?.onAssistantMessageStart?.();
// Second assistant message: new text
await replyOptions?.onPartialReply?.({ text: "After tool call" });
await dispatcherOptions.deliver({ text: "After tool call" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({ context: createContext(), streamMode: "block" });
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
});
it("forces new message in partial mode when assistant message restarts", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "First response" });
await replyOptions?.onAssistantMessageStart?.();
await replyOptions?.onPartialReply?.({ text: "After tool call" });
await dispatcherOptions.deliver({ text: "After tool call" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({ context: createContext(), streamMode: "partial" });
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
});
it("does not force new message on first assistant message start", async () => {
const draftStream = createDraftStream(999);
@@ -855,7 +1076,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
it.each([undefined, null] as const)(
"skips outbound send when final payload text is %s and has no media",
async (emptyText) => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 999 });
setupDraftStreams({ answerMessageId: 999 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver(
{ text: emptyText as unknown as string },
@@ -869,7 +1090,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(deliverReplies).not.toHaveBeenCalled();
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
},
);
@@ -1264,6 +1484,45 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(deliverReplies).not.toHaveBeenCalled();
});
it("edits stop-created preview when final text is shorter than buffered draft", async () => {
let answerMessageId: number | undefined;
const answerDraftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => answerMessageId),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockImplementation(async () => {
answerMessageId = 999;
}),
forceNewMessage: vi.fn(),
};
const reasoningDraftStream = createDraftStream();
createTelegramDraftStream
.mockImplementationOnce(() => answerDraftStream)
.mockImplementationOnce(() => reasoningDraftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({
text: "Let me check that file and confirm details for you.",
});
await dispatcherOptions.deliver({ text: "Let me check that file." }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" });
await dispatchWithContext({ context: createContext(), streamMode: "block" });
expect(editMessageTelegram).toHaveBeenCalledWith(
123,
999,
"Let me check that file.",
expect.any(Object),
);
expect(deliverReplies).not.toHaveBeenCalled();
});
it("does not edit preview message when final payload is an error", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
@@ -1336,6 +1595,21 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.clear).toHaveBeenCalledTimes(1);
});
it("skips final payload when text is undefined", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: undefined as unknown as string }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({ context: createContext() });
expect(deliverReplies).not.toHaveBeenCalled();
expect(draftStream.clear).toHaveBeenCalledTimes(1);
});
it("falls back when all finals are skipped and clears preview", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);

View File

@@ -1,10 +1,10 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { Chat, Message } from "@grammyjs/types";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
import { withEnvAsync } from "../test-utils/env.js";
import { useFrozenTime, useRealTime } from "../test-utils/frozen-time.js";
import {
answerCallbackQuerySpy,
botCtorSpy,
@@ -38,6 +38,14 @@ const readChannelAllowFromStore = getReadChannelAllowFromStoreMock();
const upsertChannelPairingRequest = getUpsertChannelPairingRequestMock();
const ORIGINAL_TZ = process.env.TZ;
const mockChat = (chat: Pick<Chat, "id"> & Partial<Pick<Chat, "type" | "is_forum">>): Chat =>
chat as Chat;
const mockMessage = (message: Pick<Message, "chat"> & Partial<Message>): Message =>
({
message_id: 1,
date: 0,
...message,
}) as Message;
const TELEGRAM_TEST_TIMINGS = {
mediaGroupFlushMs: 20,
textFragmentGapMs: 30,
@@ -115,6 +123,97 @@ describe("createTelegramBot", () => {
expect(sequentializeSpy).toHaveBeenCalledTimes(1);
expect(middlewareUseSpy).toHaveBeenCalledWith(sequentializeSpy.mock.results[0]?.value);
expect(sequentializeKey).toBe(getTelegramSequentialKey);
expect(
getTelegramSequentialKey({ message: mockMessage({ chat: mockChat({ id: 123 }) }) }),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: mockMessage({
chat: mockChat({ id: 123, type: "private" }),
message_thread_id: 9,
}),
}),
).toBe("telegram:123:topic:9");
expect(
getTelegramSequentialKey({
message: mockMessage({
chat: mockChat({ id: 123, type: "supergroup" }),
message_thread_id: 9,
}),
}),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123, type: "supergroup", is_forum: true }) }),
}),
).toBe("telegram:123:topic:1");
expect(
getTelegramSequentialKey({
update: { message: mockMessage({ chat: mockChat({ id: 555 }) }) },
}),
).toBe("telegram:555");
expect(
getTelegramSequentialKey({
channelPost: mockMessage({ chat: mockChat({ id: -100777111222, type: "channel" }) }),
}),
).toBe("telegram:-100777111222");
expect(
getTelegramSequentialKey({
update: {
channel_post: mockMessage({ chat: mockChat({ id: -100777111223, type: "channel" }) }),
},
}),
).toBe("telegram:-100777111223");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "/stop" }),
}),
).toBe("telegram:123:control");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "/status" }),
}),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop" }),
}),
).toBe("telegram:123:control");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop please" }),
}),
).toBe("telegram:123:control");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "do not do that" }),
}),
).toBe("telegram:123:control");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "остановись" }),
}),
).toBe("telegram:123:control");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "halt" }),
}),
).toBe("telegram:123:control");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort" }),
}),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort now" }),
}),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "please do not do that" }),
}),
).toBe("telegram:123");
});
it("routes callback_query payloads as messages and answers callbacks", async () => {
createTelegramBot({ token: "tok" });
@@ -1932,7 +2031,7 @@ describe("createTelegramBot", () => {
},
});
useFrozenTime("2026-02-20T00:00:00.000Z");
vi.useFakeTimers();
try {
createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
const handler = getOnHandler("channel_post") as (
@@ -1972,7 +2071,7 @@ describe("createTelegramBot", () => {
expect(payload.RawBody).toContain(part1.slice(0, 32));
expect(payload.RawBody).toContain(part2.slice(0, 32));
} finally {
useRealTime();
vi.useRealTimers();
}
});
it("drops oversized channel_post media instead of dispatching a placeholder message", async () => {

View File

@@ -1,9 +1,11 @@
import { sequentialize } from "@grammyjs/runner";
import { apiThrottler } from "@grammyjs/transformer-throttler";
import { type Message, type UserFromGetMe } from "@grammyjs/types";
import type { ApiClientOptions } from "grammy";
import { Bot, webhookCallback } from "grammy";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { isAbortRequestText } from "../auto-reply/reply/abort.js";
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js";
import {
isNativeCommandsExplicitlyDisabled,
@@ -32,10 +34,13 @@ import {
resolveTelegramUpdateId,
type TelegramUpdateKeyContext,
} from "./bot-updates.js";
import { buildTelegramGroupPeerId, resolveTelegramStreamMode } from "./bot/helpers.js";
import {
buildTelegramGroupPeerId,
resolveTelegramForumThreadId,
resolveTelegramStreamMode,
} from "./bot/helpers.js";
import { resolveTelegramFetch } from "./fetch.js";
import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js";
import { getTelegramSequentialKey } from "./sequential-key.js";
export type TelegramBotOptions = {
token: string;
@@ -58,7 +63,55 @@ export type TelegramBotOptions = {
};
};
export { getTelegramSequentialKey };
export function getTelegramSequentialKey(ctx: {
chat?: { id?: number };
me?: UserFromGetMe;
message?: Message;
channelPost?: Message;
editedChannelPost?: Message;
update?: {
message?: Message;
edited_message?: Message;
channel_post?: Message;
edited_channel_post?: Message;
callback_query?: { message?: Message };
message_reaction?: { chat?: { id?: number } };
};
}): string {
// Handle reaction updates
const reaction = ctx.update?.message_reaction;
if (reaction?.chat?.id) {
return `telegram:${reaction.chat.id}`;
}
const msg =
ctx.message ??
ctx.channelPost ??
ctx.editedChannelPost ??
ctx.update?.message ??
ctx.update?.edited_message ??
ctx.update?.channel_post ??
ctx.update?.edited_channel_post ??
ctx.update?.callback_query?.message;
const chatId = msg?.chat?.id ?? ctx.chat?.id;
const rawText = msg?.text ?? msg?.caption;
const botUsername = ctx.me?.username;
if (isAbortRequestText(rawText, botUsername ? { botUsername } : undefined)) {
if (typeof chatId === "number") {
return `telegram:${chatId}:control`;
}
return "telegram:control";
}
const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup";
const messageThreadId = msg?.message_thread_id;
const isForum = msg?.chat?.is_forum;
const threadId = isGroup
? resolveTelegramForumThreadId({ isForum, messageThreadId })
: messageThreadId;
if (typeof chatId === "number") {
return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`;
}
return "telegram:unknown";
}
export function createTelegramBot(opts: TelegramBotOptions) {
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();

View File

@@ -1,218 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../auto-reply/types.js";
import { createLaneTextDeliverer, type DraftLaneState, type LaneName } from "./lane-delivery.js";
type MockStreamState = {
stream: NonNullable<DraftLaneState["stream"]>;
setMessageId: (value: number | undefined) => void;
};
function createMockStream(initialMessageId?: number): MockStreamState {
let messageId = initialMessageId;
const stream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => messageId),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
forceNewMessage: vi.fn(),
previewMode: vi.fn().mockReturnValue("message"),
previewRevision: vi.fn().mockReturnValue(0),
} as unknown as NonNullable<DraftLaneState["stream"]>;
return {
stream,
setMessageId: (value) => {
messageId = value;
},
};
}
function createHarness(params?: {
answerMessageId?: number;
draftMaxChars?: number;
answerMessageIdAfterStop?: number;
}) {
const answer = createMockStream(params?.answerMessageId);
const reasoning = createMockStream();
const lanes: Record<LaneName, DraftLaneState> = {
answer: { stream: answer.stream, lastPartialText: "", hasStreamedMessage: false },
reasoning: { stream: reasoning.stream, lastPartialText: "", hasStreamedMessage: false },
};
const sendPayload = vi.fn().mockResolvedValue(true);
const flushDraftLane = vi.fn().mockImplementation(async (lane: DraftLaneState) => {
await lane.stream?.flush();
});
const stopDraftLane = vi.fn().mockImplementation(async (lane: DraftLaneState) => {
if (lane === lanes.answer && params?.answerMessageIdAfterStop !== undefined) {
answer.setMessageId(params.answerMessageIdAfterStop);
}
await lane.stream?.stop();
});
const editPreview = vi.fn().mockResolvedValue(undefined);
const deletePreviewMessage = vi.fn().mockResolvedValue(undefined);
const log = vi.fn();
const markDelivered = vi.fn();
const finalizedPreviewByLane: Record<LaneName, boolean> = { answer: false, reasoning: false };
const archivedAnswerPreviews: Array<{ messageId: number; textSnapshot: string }> = [];
const deliverLaneText = createLaneTextDeliverer({
lanes,
archivedAnswerPreviews,
finalizedPreviewByLane,
draftMaxChars: params?.draftMaxChars ?? 4_096,
applyTextToPayload: (payload: ReplyPayload, text: string) => ({ ...payload, text }),
sendPayload,
flushDraftLane,
stopDraftLane,
editPreview,
deletePreviewMessage,
log,
markDelivered,
});
return {
deliverLaneText,
lanes,
answer,
sendPayload,
flushDraftLane,
stopDraftLane,
editPreview,
log,
markDelivered,
};
}
describe("createLaneTextDeliverer", () => {
it("finalizes text-only replies by editing an existing preview message", async () => {
const harness = createHarness({ answerMessageId: 999 });
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Hello final",
payload: { text: "Hello final" },
infoKind: "final",
});
expect(result).toBe("preview-finalized");
expect(harness.editPreview).toHaveBeenCalledWith(
expect.objectContaining({
laneName: "answer",
messageId: 999,
text: "Hello final",
context: "final",
}),
);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.stopDraftLane).toHaveBeenCalledTimes(1);
});
it("primes stop-created previews with final text before editing", async () => {
const harness = createHarness({ answerMessageIdAfterStop: 777 });
harness.lanes.answer.lastPartialText = "no";
const result = await harness.deliverLaneText({
laneName: "answer",
text: "no problem",
payload: { text: "no problem" },
infoKind: "final",
});
expect(result).toBe("preview-finalized");
expect(harness.answer.stream.update).toHaveBeenCalledWith("no problem");
expect(harness.editPreview).toHaveBeenCalledWith(
expect.objectContaining({
laneName: "answer",
messageId: 777,
text: "no problem",
}),
);
expect(harness.sendPayload).not.toHaveBeenCalled();
});
it("treats stop-created preview edit failures as delivered", async () => {
const harness = createHarness({ answerMessageIdAfterStop: 777 });
harness.editPreview.mockRejectedValue(new Error("500: edit failed after stop flush"));
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Short final",
payload: { text: "Short final" },
infoKind: "final",
});
expect(result).toBe("preview-finalized");
expect(harness.editPreview).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("treating as delivered"));
});
it("falls back to normal delivery when editing an existing preview fails", async () => {
const harness = createHarness({ answerMessageId: 999 });
harness.editPreview.mockRejectedValue(new Error("500: preview edit failed"));
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Hello final",
payload: { text: "Hello final" },
infoKind: "final",
});
expect(result).toBe("sent");
expect(harness.editPreview).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Hello final" }),
);
});
it("falls back to normal delivery when stop-created preview has no message id", async () => {
const harness = createHarness();
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Short final",
payload: { text: "Short final" },
infoKind: "final",
});
expect(result).toBe("sent");
expect(harness.editPreview).not.toHaveBeenCalled();
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Short final" }),
);
});
it("keeps existing preview when final text regresses", async () => {
const harness = createHarness({ answerMessageId: 999 });
harness.lanes.answer.lastPartialText = "Recovered final answer.";
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Recovered final answer",
payload: { text: "Recovered final answer" },
infoKind: "final",
});
expect(result).toBe("preview-finalized");
expect(harness.editPreview).not.toHaveBeenCalled();
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
});
it("falls back to normal delivery when final text exceeds preview edit limit", async () => {
const harness = createHarness({ answerMessageId: 999, draftMaxChars: 20 });
const longText = "x".repeat(50);
const result = await harness.deliverLaneText({
laneName: "answer",
text: longText,
payload: { text: longText },
infoKind: "final",
});
expect(result).toBe("sent");
expect(harness.editPreview).not.toHaveBeenCalled();
expect(harness.sendPayload).toHaveBeenCalledWith(expect.objectContaining({ text: longText }));
expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("preview final too long"));
});
});

View File

@@ -1,92 +0,0 @@
import type { Chat, Message } from "@grammyjs/types";
import { describe, expect, it } from "vitest";
import { getTelegramSequentialKey } from "./sequential-key.js";
const mockChat = (chat: Pick<Chat, "id"> & Partial<Pick<Chat, "type" | "is_forum">>): Chat =>
chat as Chat;
const mockMessage = (message: Pick<Message, "chat"> & Partial<Message>): Message =>
({
message_id: 1,
date: 0,
...message,
}) as Message;
describe("getTelegramSequentialKey", () => {
it.each([
[{ message: mockMessage({ chat: mockChat({ id: 123 }) }) }, "telegram:123"],
[
{
message: mockMessage({
chat: mockChat({ id: 123, type: "private" }),
message_thread_id: 9,
}),
},
"telegram:123:topic:9",
],
[
{
message: mockMessage({
chat: mockChat({ id: 123, type: "supergroup" }),
message_thread_id: 9,
}),
},
"telegram:123",
],
[
{
message: mockMessage({
chat: mockChat({ id: 123, type: "supergroup", is_forum: true }),
}),
},
"telegram:123:topic:1",
],
[{ update: { message: mockMessage({ chat: mockChat({ id: 555 }) }) } }, "telegram:555"],
[
{
channelPost: mockMessage({ chat: mockChat({ id: -100777111222, type: "channel" }) }),
},
"telegram:-100777111222",
],
[
{
update: {
channel_post: mockMessage({ chat: mockChat({ id: -100777111223, type: "channel" }) }),
},
},
"telegram:-100777111223",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/stop" }) },
"telegram:123:control",
],
[{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/status" }) }, "telegram:123"],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop please" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "do not do that" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "остановись" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "halt" }) },
"telegram:123:control",
],
[{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort" }) }, "telegram:123"],
[{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort now" }) }, "telegram:123"],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "please do not do that" }) },
"telegram:123",
],
])("resolves key %#", (input, expected) => {
expect(getTelegramSequentialKey(input)).toBe(expected);
});
});

View File

@@ -1,54 +0,0 @@
import { type Message, type UserFromGetMe } from "@grammyjs/types";
import { isAbortRequestText } from "../auto-reply/reply/abort.js";
import { resolveTelegramForumThreadId } from "./bot/helpers.js";
export type TelegramSequentialKeyContext = {
chat?: { id?: number };
me?: UserFromGetMe;
message?: Message;
channelPost?: Message;
editedChannelPost?: Message;
update?: {
message?: Message;
edited_message?: Message;
channel_post?: Message;
edited_channel_post?: Message;
callback_query?: { message?: Message };
message_reaction?: { chat?: { id?: number } };
};
};
export function getTelegramSequentialKey(ctx: TelegramSequentialKeyContext): string {
const reaction = ctx.update?.message_reaction;
if (reaction?.chat?.id) {
return `telegram:${reaction.chat.id}`;
}
const msg =
ctx.message ??
ctx.channelPost ??
ctx.editedChannelPost ??
ctx.update?.message ??
ctx.update?.edited_message ??
ctx.update?.channel_post ??
ctx.update?.edited_channel_post ??
ctx.update?.callback_query?.message;
const chatId = msg?.chat?.id ?? ctx.chat?.id;
const rawText = msg?.text ?? msg?.caption;
const botUsername = ctx.me?.username;
if (isAbortRequestText(rawText, botUsername ? { botUsername } : undefined)) {
if (typeof chatId === "number") {
return `telegram:${chatId}:control`;
}
return "telegram:control";
}
const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup";
const messageThreadId = msg?.message_thread_id;
const isForum = msg?.chat?.is_forum;
const threadId = isGroup
? resolveTelegramForumThreadId({ isForum, messageThreadId })
: messageThreadId;
if (typeof chatId === "number") {
return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`;
}
return "telegram:unknown";
}

View File

@@ -1895,6 +1895,8 @@
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--secondary);
overflow-wrap: normal;
word-break: keep-all;
}
:root[data-theme="light"] .chat-text :where(:not(pre) > code) {