mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-09 23:41:55 +08:00
Compare commits
27 Commits
fix/webcha
...
fix/gemini
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8e5b633bd | ||
|
|
fd4e014854 | ||
|
|
6b7c86467c | ||
|
|
4c32411bee | ||
|
|
91cdb703bd | ||
|
|
04ac688dff | ||
|
|
b29e913efe | ||
|
|
895abc5a64 | ||
|
|
62582fc088 | ||
|
|
57336203d5 | ||
|
|
1929151103 | ||
|
|
6ab9e00e17 | ||
|
|
2380c1b5fd | ||
|
|
493b560dfd | ||
|
|
1dd77e4106 | ||
|
|
4d52dfe85b | ||
|
|
d380ed710d | ||
|
|
03755f8463 | ||
|
|
7fdbf1202e | ||
|
|
70db52de71 | ||
|
|
15a0455d04 | ||
|
|
d3c637d193 | ||
|
|
0fb3f188b2 | ||
|
|
bf6aa7ca67 | ||
|
|
0fd77c9856 | ||
|
|
f77f1d3800 | ||
|
|
7c90ef7c52 |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -223,8 +223,8 @@ jobs:
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
name: "check"
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -41,6 +41,7 @@ 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.
|
||||
@@ -48,9 +49,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/default account resolution: always honor explicit `channels.feishu.defaultAccount` during outbound account selection (including top-level-credential setups where the preferred id is not present in `accounts`), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.
|
||||
- Gemini schema sanitization: coerce malformed JSON Schema `properties` values (`null`, arrays, primitives) to `{}` before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.
|
||||
- Models/openai-completions developer-role compatibility: force `supportsDeveloperRole=false` for non-native endpoints, treat unparseable `baseUrl` values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.
|
||||
- Gemini CLI OAuth/Linux compatibility: send `PLATFORM_UNSPECIFIED` (instead of invalid `LINUX`) in `loadCodeAssist` metadata so Linux OAuth flows no longer fail with provider 400 enum errors. (#32382) Thanks @riftzen-bit.
|
||||
- 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.
|
||||
|
||||
@@ -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; \
|
||||
|
||||
28
docs/ci.md
28
docs/ci.md
@@ -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 | 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 |
|
||||
| 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 |
|
||||
|
||||
## Fail-Fast Order
|
||||
|
||||
|
||||
@@ -313,7 +313,7 @@ See [Configuration Reference](/gateway/configuration-reference).
|
||||
Install and enable plugin:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/acpx
|
||||
openclaw plugins install acpx
|
||||
openclaw config set plugins.entries.acpx.enabled true
|
||||
```
|
||||
|
||||
|
||||
@@ -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,47 +94,15 @@ const mockResolveChunkMode = vi.fn(() => "length");
|
||||
const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
|
||||
|
||||
function createMockRuntime(): PluginRuntime {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
config: {
|
||||
loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"],
|
||||
writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
|
||||
},
|
||||
return createPluginRuntimeMock({
|
||||
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:
|
||||
@@ -143,50 +111,12 @@ 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:
|
||||
@@ -207,8 +137,6 @@ 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"],
|
||||
},
|
||||
@@ -217,12 +145,6 @@ 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:
|
||||
@@ -232,72 +154,18 @@ 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(
|
||||
|
||||
@@ -239,14 +239,15 @@ describe("loginGeminiCliOAuth", () => {
|
||||
"GOOGLE_CLOUD_PROJECT_ID",
|
||||
] as const;
|
||||
|
||||
function getExpectedPlatform(): "WINDOWS" | "MACOS" | "LINUX" {
|
||||
function getExpectedPlatform(): "WINDOWS" | "MACOS" | "PLATFORM_UNSPECIFIED" {
|
||||
if (process.platform === "win32") {
|
||||
return "WINDOWS";
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
return "LINUX";
|
||||
if (process.platform === "darwin") {
|
||||
return "MACOS";
|
||||
}
|
||||
return "MACOS";
|
||||
// Matches updated resolvePlatform() which uses PLATFORM_UNSPECIFIED for Linux
|
||||
return "PLATFORM_UNSPECIFIED";
|
||||
}
|
||||
|
||||
function getRequestUrl(input: string | URL | Request): string {
|
||||
|
||||
@@ -224,14 +224,16 @@ function generatePkce(): { verifier: string; challenge: string } {
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function resolvePlatform(): "WINDOWS" | "MACOS" | "LINUX" {
|
||||
function resolvePlatform(): "WINDOWS" | "MACOS" | "PLATFORM_UNSPECIFIED" {
|
||||
if (process.platform === "win32") {
|
||||
return "WINDOWS";
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
return "LINUX";
|
||||
if (process.platform === "darwin") {
|
||||
return "MACOS";
|
||||
}
|
||||
return "MACOS";
|
||||
// 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";
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
|
||||
248
extensions/test-utils/plugin-runtime-mock.ts
Normal file
248
extensions/test-utils/plugin-runtime-mock.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
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);
|
||||
}
|
||||
21
openclaw.mjs
21
openclaw.mjs
@@ -4,18 +4,27 @@ 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 = () => {
|
||||
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) {
|
||||
if (isSupportedNodeVersion(parseNodeVersion(process.versions.node))) {
|
||||
return;
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
`openclaw: Node.js v${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}+ is required (current: v${process.versions.node}).\n` +
|
||||
`openclaw: Node.js v${MIN_NODE_VERSION}+ is required (current: v${process.versions.node}).\n` +
|
||||
"If you use nvm, run:\n" +
|
||||
" nvm install 22\n" +
|
||||
" nvm use 22\n" +
|
||||
|
||||
@@ -16,6 +16,9 @@ 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:-}"
|
||||
|
||||
@@ -1247,26 +1250,10 @@ install_homebrew() {
|
||||
}
|
||||
|
||||
# Check Node.js version
|
||||
node_major_version() {
|
||||
parse_node_version_components() {
|
||||
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}"
|
||||
@@ -1281,11 +1268,32 @@ node_is_at_least_22_12() {
|
||||
if [[ ! "$minor" =~ ^[0-9]+$ ]]; then
|
||||
return 1
|
||||
fi
|
||||
echo "${major} ${minor}"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ "$major" -gt 22 ]]; then
|
||||
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"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$major" -eq 22 && "$minor" -ge 12 ]]; then
|
||||
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
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
@@ -1343,7 +1351,7 @@ ensure_macos_node22_active() {
|
||||
}
|
||||
|
||||
ensure_node22_active_shell() {
|
||||
if node_is_at_least_22_12; then
|
||||
if node_is_at_least_required; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -1351,7 +1359,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 v22.12+ but this shell is using ${active_version} (${active_path})"
|
||||
ui_error "Active Node.js must be v${NODE_MIN_VERSION}+ but this shell is using ${active_version} (${active_path})"
|
||||
print_active_node_paths || true
|
||||
|
||||
local nvm_detected=0
|
||||
@@ -1380,15 +1388,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_22_12; then
|
||||
if node_is_at_least_required; 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 v22.12+"
|
||||
ui_info "Node.js $(node -v) found, upgrading to v${NODE_MIN_VERSION}+"
|
||||
else
|
||||
ui_info "Node.js found but version could not be parsed; reinstalling v22.12+"
|
||||
ui_info "Node.js found but version could not be parsed; reinstalling v${NODE_MIN_VERSION}+"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -29,6 +29,9 @@ 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 OpenClaw’s 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/...
|
||||
|
||||
@@ -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`
|
||||
: "Command timed out"
|
||||
? `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)."
|
||||
: exit.reason === "no-output-timeout"
|
||||
? "Command timed out waiting for output"
|
||||
: exit.exitSignal != null
|
||||
|
||||
@@ -458,6 +458,9 @@ 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)(
|
||||
|
||||
@@ -61,4 +61,54 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
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";
|
||||
@@ -19,6 +20,12 @@ 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>;
|
||||
@@ -68,7 +75,9 @@ export function applyConfiguredContextWindows(params: {
|
||||
|
||||
const MODEL_CACHE = new Map<string, number>();
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
let configuredWindowsPrimed = false;
|
||||
let configuredConfig: OpenClawConfig | undefined;
|
||||
let configLoadFailures = 0;
|
||||
let nextConfigLoadAttemptAtMs = 0;
|
||||
|
||||
function getCommandPathFromArgv(argv: string[]): string[] {
|
||||
const args = argv.slice(2);
|
||||
@@ -100,33 +109,42 @@ function shouldSkipEagerContextWindowWarmup(argv: string[] = process.argv): bool
|
||||
}
|
||||
|
||||
function primeConfiguredContextWindows(): OpenClawConfig | undefined {
|
||||
if (configuredWindowsPrimed) {
|
||||
if (configuredConfig) {
|
||||
return configuredConfig;
|
||||
}
|
||||
if (Date.now() < nextConfigLoadAttemptAtMs) {
|
||||
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 {
|
||||
// If config can't be loaded, leave cache empty.
|
||||
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.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureContextWindowCacheLoaded(): Promise<void> {
|
||||
const cfg = primeConfiguredContextWindows();
|
||||
if (loadPromise) {
|
||||
return loadPromise;
|
||||
}
|
||||
loadPromise = (async () => {
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = primeConfiguredContextWindows();
|
||||
if (!cfg) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
loadPromise = (async () => {
|
||||
try {
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
} catch {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
sanitizeGoogleTurnOrdering,
|
||||
sanitizeSessionMessagesImages,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import { castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
|
||||
|
||||
let testTimestamp = 1;
|
||||
const nextTimestamp = () => testTimestamp++;
|
||||
@@ -93,7 +94,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
});
|
||||
|
||||
it("does not synthesize tool call input when missing", async () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_1", name: "read" }],
|
||||
@@ -111,7 +112,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>> };
|
||||
@@ -122,7 +123,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
});
|
||||
|
||||
it("removes empty assistant text blocks but preserves tool calls", async () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -143,7 +144,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
stopReason: "toolUse",
|
||||
timestamp: nextTimestamp(),
|
||||
},
|
||||
] as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
@@ -153,7 +154,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
});
|
||||
|
||||
it("sanitizes tool ids in strict mode (alphanumeric only)", async () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -171,7 +172,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,
|
||||
@@ -188,7 +189,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
});
|
||||
|
||||
it("sanitizes tool IDs in images-only mode when explicitly enabled", async () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_123|fc_456", name: "read", arguments: {} }],
|
||||
@@ -214,7 +215,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
isError: false,
|
||||
timestamp: nextTimestamp(),
|
||||
},
|
||||
] as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test", {
|
||||
sanitizeMode: "images-only",
|
||||
@@ -236,7 +237,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
}
|
||||
});
|
||||
it("filters whitespace-only assistant text blocks", async () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -257,7 +258,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
stopReason: "stop",
|
||||
timestamp: nextTimestamp(),
|
||||
},
|
||||
] as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
@@ -266,7 +267,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
});
|
||||
});
|
||||
it("drops assistant messages that only contain empty text", async () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{ role: "user", content: "hello", timestamp: nextTimestamp() } satisfies UserMessage,
|
||||
{
|
||||
role: "assistant",
|
||||
@@ -285,7 +286,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
stopReason: "stop",
|
||||
timestamp: nextTimestamp(),
|
||||
} satisfies AssistantMessage,
|
||||
];
|
||||
]);
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
@@ -293,7 +294,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
expect(out[0]?.role).toBe("user");
|
||||
});
|
||||
it("keeps empty assistant error messages", async () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{ role: "user", content: "hello", timestamp: nextTimestamp() } satisfies UserMessage,
|
||||
{
|
||||
role: "assistant",
|
||||
@@ -329,7 +330,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
},
|
||||
timestamp: nextTimestamp(),
|
||||
} satisfies AssistantMessage,
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
@@ -360,7 +361,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
|
||||
describe("thought_signature stripping", () => {
|
||||
it("strips msg_-prefixed thought_signature from assistant message content blocks", async () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -372,7 +373,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
@@ -387,19 +388,19 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
|
||||
describe("sanitizeGoogleTurnOrdering", () => {
|
||||
it("prepends a synthetic user turn when history starts with assistant", () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
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 = [{ role: "user", content: "hi" }] as unknown as AgentMessage[];
|
||||
const input = castAgentMessages([{ role: "user", content: "hi" }]);
|
||||
const out = sanitizeGoogleTurnOrdering(input);
|
||||
expect(out).toBe(input);
|
||||
});
|
||||
|
||||
@@ -2,13 +2,14 @@ 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", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 = () =>
|
||||
@@ -17,7 +18,7 @@ describe("sanitizeSessionHistory openai tool id preservation", () => {
|
||||
]);
|
||||
|
||||
const makeMessages = (withReasoning: boolean): AgentMessage[] => [
|
||||
{
|
||||
castAgentMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
...(withReasoning
|
||||
@@ -31,14 +32,14 @@ describe("sanitizeSessionHistory openai tool id preservation", () => {
|
||||
: []),
|
||||
{ type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} },
|
||||
],
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
}),
|
||||
castAgentMessage({
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_123",
|
||||
toolName: "noop",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
} as unknown as AgentMessage,
|
||||
}),
|
||||
];
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 () => ({
|
||||
@@ -136,12 +137,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[],
|
||||
@@ -258,7 +259,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
setNonGoogleModelApi();
|
||||
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
castAgentMessage({
|
||||
role: "user",
|
||||
content: "forwarded instruction",
|
||||
provenance: {
|
||||
@@ -266,7 +267,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
sourceSessionKey: "agent:main:req",
|
||||
sourceTool: "sessions_send",
|
||||
},
|
||||
} as unknown as AgentMessage,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = await sanitizeSessionHistory({
|
||||
@@ -287,14 +288,14 @@ describe("sanitizeSessionHistory", () => {
|
||||
it("drops stale assistant usage snapshots kept before latest compaction summary", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
const messages = [
|
||||
const messages = castAgentMessages([
|
||||
{ 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);
|
||||
|
||||
@@ -308,7 +309,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
it("preserves fresh assistant usage snapshots created after latest compaction summary", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
const messages = [
|
||||
const messages = castAgentMessages([
|
||||
makeAssistantUsageMessage({
|
||||
text: "pre-compaction answer",
|
||||
usage: makeUsage(120_000, 3_000, 123_000),
|
||||
@@ -319,7 +320,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
text: "fresh answer",
|
||||
usage: makeUsage(1_000, 250, 1_250),
|
||||
}),
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
const result = await sanitizeOpenAIHistory(messages);
|
||||
|
||||
@@ -333,14 +334,14 @@ describe("sanitizeSessionHistory", () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
|
||||
const messages = [
|
||||
const messages = castAgentMessages([
|
||||
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);
|
||||
|
||||
@@ -354,7 +355,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
|
||||
const messages = [
|
||||
const messages = castAgentMessages([
|
||||
makeCompactionSummaryMessage(123_000, new Date(compactionTs).toISOString()),
|
||||
makeAssistantUsageMessage({
|
||||
text: "kept pre-compaction answer",
|
||||
@@ -367,7 +368,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
timestamp: compactionTs + 2_000,
|
||||
usage: makeUsage(1_000, 250, 1_250),
|
||||
}),
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
const result = await sanitizeOpenAIHistory(messages);
|
||||
|
||||
@@ -431,13 +432,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]
|
||||
>,
|
||||
@@ -445,7 +446,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
{
|
||||
name: "invalid or overlong names",
|
||||
makeMessages: () =>
|
||||
[
|
||||
castAgentMessages([
|
||||
makeAssistantMessage(
|
||||
[
|
||||
{
|
||||
@@ -464,7 +465,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
{ stopReason: "toolUse" },
|
||||
),
|
||||
makeUserMessage("hello"),
|
||||
] as AgentMessage[],
|
||||
]),
|
||||
overrides: {} as Partial<Parameters<typeof sanitizeOpenAIHistory>[1]>,
|
||||
},
|
||||
])("drops malformed tool calls: $name", async ({ makeMessages, overrides }) => {
|
||||
|
||||
@@ -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 = [{ role: "assistant", content: "pre" } as unknown as AgentMessage] as const;
|
||||
const current = [{ role: "assistant", content: "current" } as unknown as AgentMessage] as const;
|
||||
const pre = [castAgentMessage({ role: "assistant", content: "pre" })] as const;
|
||||
const current = [castAgentMessage({ role: "assistant", content: "current" })] 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 = [{ role: "assistant", content: "current" } as unknown as AgentMessage] as const;
|
||||
const current = [castAgentMessage({ role: "assistant", content: "current" })] as const;
|
||||
const selected = selectCompactionTimeoutSnapshot({
|
||||
timedOutDuringCompaction: true,
|
||||
preCompactionSnapshot: null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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", () => {
|
||||
@@ -8,14 +9,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 }],
|
||||
} as AgentMessage,
|
||||
{
|
||||
}),
|
||||
castAgentMessage({
|
||||
role: "assistant",
|
||||
content: "got it",
|
||||
} as unknown as AgentMessage,
|
||||
}),
|
||||
];
|
||||
|
||||
const didMutate = pruneProcessedHistoryImages(messages);
|
||||
@@ -31,10 +32,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);
|
||||
@@ -50,10 +51,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);
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
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 = {
|
||||
const assistant = castAgentMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
} as AgentMessage;
|
||||
const user = { role: "user", content: "hi" } as AgentMessage;
|
||||
const malformed = { role: "assistant", content: "not-array" } as unknown as AgentMessage;
|
||||
});
|
||||
const user = castAgentMessage({ role: "user", content: "hi" });
|
||||
const malformed = castAgentMessage({ role: "assistant", content: "not-array" });
|
||||
|
||||
expect(isAssistantMessageWithContent(assistant)).toBe(true);
|
||||
expect(isAssistantMessageWithContent(user)).toBe(false);
|
||||
@@ -20,8 +21,8 @@ describe("isAssistantMessageWithContent", () => {
|
||||
describe("dropThinkingBlocks", () => {
|
||||
it("returns the original reference when no thinking blocks are present", () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "hello" } as AgentMessage,
|
||||
{ role: "assistant", content: [{ type: "text", text: "world" }] } as AgentMessage,
|
||||
castAgentMessage({ role: "user", content: "hello" }),
|
||||
castAgentMessage({ role: "assistant", content: [{ type: "text", text: "world" }] }),
|
||||
];
|
||||
|
||||
const result = dropThinkingBlocks(messages);
|
||||
@@ -30,13 +31,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);
|
||||
@@ -47,10 +48,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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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,
|
||||
@@ -7,35 +8,35 @@ import {
|
||||
} from "./tool-result-context-guard.js";
|
||||
|
||||
function makeUser(text: string): AgentMessage {
|
||||
return {
|
||||
return castAgentMessage({
|
||||
role: "user",
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
} as unknown as AgentMessage;
|
||||
});
|
||||
}
|
||||
|
||||
function makeToolResult(id: string, text: string): AgentMessage {
|
||||
return {
|
||||
return castAgentMessage({
|
||||
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 {
|
||||
return castAgentMessage({
|
||||
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 {
|
||||
return castAgentMessage({
|
||||
role: "toolResult",
|
||||
toolCallId: id,
|
||||
toolName: "read",
|
||||
@@ -49,7 +50,7 @@ function makeToolResultWithDetails(id: string, text: string, detailText: string)
|
||||
},
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
} as unknown as AgentMessage;
|
||||
});
|
||||
}
|
||||
|
||||
function getToolResultText(msg: AgentMessage): string {
|
||||
@@ -199,11 +200,10 @@ describe("installToolResultContextGuard", () => {
|
||||
|
||||
it("wraps an existing transformContext and guards the transformed output", async () => {
|
||||
const agent = makeGuardableAgent((messages) => {
|
||||
return messages.map(
|
||||
(msg) =>
|
||||
({
|
||||
...(msg as unknown as Record<string, unknown>),
|
||||
}) as unknown as AgentMessage,
|
||||
return messages.map((msg) =>
|
||||
castAgentMessage({
|
||||
...(msg as unknown as Record<string, unknown>),
|
||||
}),
|
||||
);
|
||||
});
|
||||
const contextForNextCall = makeTwoToolResultOverflowContext();
|
||||
@@ -254,10 +254,10 @@ describe("installToolResultContextGuard", () => {
|
||||
|
||||
await agent.transformContext?.(contextForNextCall, new AbortController().signal);
|
||||
|
||||
const oldResult = contextForNextCall[1] as unknown as {
|
||||
const oldResult = contextForNextCall[1] as {
|
||||
details?: unknown;
|
||||
};
|
||||
const newResult = contextForNextCall[2] as unknown as {
|
||||
const newResult = contextForNextCall[2] as {
|
||||
details?: unknown;
|
||||
};
|
||||
const oldResultText = getToolResultText(contextForNextCall[1]);
|
||||
|
||||
@@ -23,6 +23,7 @@ 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";
|
||||
@@ -155,8 +156,18 @@ function estimateMessageChars(msg: AgentMessage): number {
|
||||
return 256;
|
||||
}
|
||||
|
||||
function estimateContextChars(messages: AgentMessage[]): number {
|
||||
return messages.reduce((sum, msg) => sum + estimateMessageChars(msg), 0);
|
||||
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 truncateTextToBudget(text: string, maxChars: number): string {
|
||||
@@ -195,12 +206,16 @@ function replaceToolResultText(msg: AgentMessage, text: string): AgentMessage {
|
||||
} as AgentMessage;
|
||||
}
|
||||
|
||||
function truncateToolResultToChars(msg: AgentMessage, maxChars: number): AgentMessage {
|
||||
function truncateToolResultToChars(
|
||||
msg: AgentMessage,
|
||||
maxChars: number,
|
||||
cache: MessageCharEstimateCache,
|
||||
): AgentMessage {
|
||||
if (!isToolResultMessage(msg)) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
const estimatedChars = estimateMessageChars(msg);
|
||||
const estimatedChars = estimateMessageCharsCached(msg, cache);
|
||||
if (estimatedChars <= maxChars) {
|
||||
return msg;
|
||||
}
|
||||
@@ -217,8 +232,9 @@ function truncateToolResultToChars(msg: AgentMessage, maxChars: number): AgentMe
|
||||
function compactExistingToolResultsInPlace(params: {
|
||||
messages: AgentMessage[];
|
||||
charsNeeded: number;
|
||||
cache: MessageCharEstimateCache;
|
||||
}): number {
|
||||
const { messages, charsNeeded } = params;
|
||||
const { messages, charsNeeded, cache } = params;
|
||||
if (charsNeeded <= 0) {
|
||||
return 0;
|
||||
}
|
||||
@@ -230,14 +246,14 @@ function compactExistingToolResultsInPlace(params: {
|
||||
continue;
|
||||
}
|
||||
|
||||
const before = estimateMessageChars(msg);
|
||||
const before = estimateMessageCharsCached(msg, cache);
|
||||
if (before <= PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const compacted = replaceToolResultText(msg, PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
||||
applyMessageMutationInPlace(msg, compacted);
|
||||
const after = estimateMessageChars(msg);
|
||||
applyMessageMutationInPlace(msg, compacted, cache);
|
||||
const after = estimateMessageCharsCached(msg, cache);
|
||||
if (after >= before) {
|
||||
continue;
|
||||
}
|
||||
@@ -251,7 +267,11 @@ function compactExistingToolResultsInPlace(params: {
|
||||
return reduced;
|
||||
}
|
||||
|
||||
function applyMessageMutationInPlace(target: AgentMessage, source: AgentMessage): void {
|
||||
function applyMessageMutationInPlace(
|
||||
target: AgentMessage,
|
||||
source: AgentMessage,
|
||||
cache?: MessageCharEstimateCache,
|
||||
): void {
|
||||
if (target === source) {
|
||||
return;
|
||||
}
|
||||
@@ -264,6 +284,7 @@ function applyMessageMutationInPlace(target: AgentMessage, source: AgentMessage)
|
||||
}
|
||||
}
|
||||
Object.assign(targetRecord, sourceRecord);
|
||||
cache?.delete(target);
|
||||
}
|
||||
|
||||
function enforceToolResultContextBudgetInPlace(params: {
|
||||
@@ -272,17 +293,18 @@ 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);
|
||||
applyMessageMutationInPlace(message, truncated);
|
||||
const truncated = truncateToolResultToChars(message, maxSingleToolResultChars, estimateCache);
|
||||
applyMessageMutationInPlace(message, truncated, estimateCache);
|
||||
}
|
||||
|
||||
let currentChars = estimateContextChars(messages);
|
||||
let currentChars = estimateContextChars(messages, estimateCache);
|
||||
if (currentChars <= contextBudgetChars) {
|
||||
return;
|
||||
}
|
||||
@@ -291,6 +313,7 @@ function enforceToolResultContextBudgetInPlace(params: {
|
||||
compactExistingToolResultsInPlace({
|
||||
messages,
|
||||
charsNeeded: currentChars - contextBudgetChars,
|
||||
cache: estimateCache,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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,
|
||||
@@ -218,11 +219,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);
|
||||
@@ -233,11 +234,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);
|
||||
|
||||
82
src/agents/pi-tools.host-edit.ts
Normal file
82
src/agents/pi-tools.host-edit.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
225
src/agents/pi-tools.params.ts
Normal file
225
src/agents/pi-tools.params.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
89
src/agents/pi-tools.read.host-edit-recovery.test.ts
Normal file
89
src/agents/pi-tools.read.host-edit-recovery.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
@@ -13,11 +13,26 @@ 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];
|
||||
@@ -334,230 +349,6 @@ 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);
|
||||
}
|
||||
@@ -684,7 +475,8 @@ export function createHostWorkspaceEditTool(root: string, options?: { workspaceO
|
||||
const base = createEditTool(root, {
|
||||
operations: createHostEditOperations(root, options),
|
||||
}) as unknown as AnyAgentTool;
|
||||
return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.edit);
|
||||
const withRecovery = wrapHostEditToolWithPostWriteRecovery(base, root);
|
||||
return wrapToolParamNormalization(withRecovery, CLAUDE_PARAM_GROUPS.edit);
|
||||
}
|
||||
|
||||
export function createOpenClawReadTool(
|
||||
|
||||
@@ -2,6 +2,7 @@ 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];
|
||||
|
||||
@@ -388,10 +389,10 @@ describe("installSessionToolResultGuard", () => {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
message: {
|
||||
message: castAgentMessage({
|
||||
...(message as unknown as Record<string, unknown>),
|
||||
content: [{ type: "text", text: "rewritten by hook" }],
|
||||
} as unknown as AgentMessage,
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -425,10 +426,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,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 {
|
||||
return castAgentMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
@@ -23,7 +24,7 @@ function mkSessionsSpawnToolCall(content: string): AgentMessage {
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
} as unknown as AgentMessage;
|
||||
});
|
||||
}
|
||||
|
||||
describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
|
||||
@@ -44,7 +45,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 = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -59,7 +60,7 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = sanitizeToolCallInputs(input);
|
||||
const msg = out[0] as { content?: unknown[] };
|
||||
|
||||
@@ -6,6 +6,7 @@ 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"]);
|
||||
|
||||
@@ -25,7 +26,7 @@ describe("sanitizeToolUseResultPairing", () => {
|
||||
middleMessage?: unknown;
|
||||
secondText?: string;
|
||||
}): AgentMessage[] =>
|
||||
[
|
||||
castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
|
||||
@@ -37,7 +38,7 @@ describe("sanitizeToolUseResultPairing", () => {
|
||||
content: [{ type: "text", text: "first" }],
|
||||
isError: false,
|
||||
},
|
||||
...(opts?.middleMessage ? [opts.middleMessage as AgentMessage] : []),
|
||||
...(opts?.middleMessage ? [castAgentMessage(opts.middleMessage)] : []),
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
@@ -45,10 +46,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 = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -64,7 +65,7 @@ describe("sanitizeToolUseResultPairing", () => {
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = sanitizeToolUseResultPairing(input);
|
||||
expect(out[0]?.role).toBe("assistant");
|
||||
@@ -76,7 +77,7 @@ describe("sanitizeToolUseResultPairing", () => {
|
||||
});
|
||||
|
||||
it("repairs blank tool result names from matching tool calls", () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
|
||||
@@ -88,7 +89,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 {
|
||||
@@ -99,10 +100,10 @@ describe("sanitizeToolUseResultPairing", () => {
|
||||
});
|
||||
|
||||
it("drops duplicate tool results for the same id within a span", () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
...buildDuplicateToolResultInput(),
|
||||
{ role: "user", content: "ok" },
|
||||
] as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = sanitizeToolUseResultPairing(input);
|
||||
expect(out.filter((m) => m.role === "toolResult")).toHaveLength(1);
|
||||
@@ -123,7 +124,7 @@ describe("sanitizeToolUseResultPairing", () => {
|
||||
});
|
||||
|
||||
it("drops orphan tool results that do not match any tool call", () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "toolResult",
|
||||
@@ -136,7 +137,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);
|
||||
@@ -147,14 +148,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 = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -169,14 +170,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 = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -190,14 +191,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 = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -210,7 +211,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 = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_aborted", name: "exec", arguments: {} }],
|
||||
@@ -224,7 +225,7 @@ describe("sanitizeToolUseResultPairing", () => {
|
||||
isError: false,
|
||||
},
|
||||
{ role: "user", content: "retrying" },
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
const result = repairToolUseResultPairing(input);
|
||||
|
||||
@@ -244,12 +245,12 @@ describe("sanitizeToolCallInputs", () => {
|
||||
options?: Parameters<typeof sanitizeToolCallInputs>[1],
|
||||
) {
|
||||
return sanitizeToolCallInputs(
|
||||
[
|
||||
castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content,
|
||||
},
|
||||
] as unknown as AgentMessage[],
|
||||
]),
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -262,13 +263,13 @@ describe("sanitizeToolCallInputs", () => {
|
||||
}
|
||||
|
||||
it("drops tool calls missing input or arguments", () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
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"]);
|
||||
@@ -325,7 +326,7 @@ describe("sanitizeToolCallInputs", () => {
|
||||
});
|
||||
|
||||
it("keeps valid tool calls and preserves text blocks", () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -334,7 +335,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" }>;
|
||||
@@ -384,7 +385,7 @@ describe("sanitizeToolCallInputs", () => {
|
||||
});
|
||||
|
||||
it("preserves toolUse input shape for sessions_spawn when no attachments are present", () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -396,7 +397,7 @@ describe("sanitizeToolCallInputs", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = sanitizeToolCallInputs(input);
|
||||
const toolCalls = getAssistantToolCallBlocks(out) as Array<Record<string, unknown>>;
|
||||
@@ -408,7 +409,7 @@ describe("sanitizeToolCallInputs", () => {
|
||||
});
|
||||
|
||||
it("redacts sessions_spawn attachments for mixed-case and padded tool names", () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -423,7 +424,7 @@ describe("sanitizeToolCallInputs", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = sanitizeToolCallInputs(input);
|
||||
const toolCalls = getAssistantToolCallBlocks(out) as Array<Record<string, unknown>>;
|
||||
@@ -448,7 +449,7 @@ describe("sanitizeToolCallInputs", () => {
|
||||
|
||||
describe("stripToolResultDetails", () => {
|
||||
it("removes details only from toolResult messages", () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
@@ -458,7 +459,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>>;
|
||||
|
||||
@@ -472,7 +473,7 @@ describe("stripToolResultDetails", () => {
|
||||
});
|
||||
|
||||
it("returns the same array reference when there are no toolResult details", () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{ role: "assistant", content: [{ type: "text", text: "a" }] },
|
||||
{
|
||||
role: "toolResult",
|
||||
@@ -481,7 +482,7 @@ describe("stripToolResultDetails", () => {
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
{ role: "user", content: "b" },
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = stripToolResultDetails(input);
|
||||
expect(out).toBe(input);
|
||||
|
||||
66
src/agents/test-helpers/agent-message-fixtures.ts
Normal file
66
src/agents/test-helpers/agent-message-fixtures.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
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: [
|
||||
@@ -26,7 +27,7 @@ const buildDuplicateIdCollisionInput = () =>
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "two" }],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
function expectCollisionIdsRemainDistinct(
|
||||
out: AgentMessage[],
|
||||
@@ -65,7 +66,7 @@ function expectSingleToolCallRewrite(
|
||||
describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
||||
describe("strict mode (default)", () => {
|
||||
it("is a no-op for already-valid non-colliding IDs", () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call1", name: "read", arguments: {} }],
|
||||
@@ -76,14 +77,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 = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call|item:123", name: "read", arguments: {} }],
|
||||
@@ -94,7 +95,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||
expect(out).not.toBe(input);
|
||||
@@ -113,7 +114,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 = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -133,7 +134,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "two" }],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||
const { aId, bId } = expectCollisionIdsRemainDistinct(out, "strict");
|
||||
@@ -144,7 +145,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
||||
|
||||
describe("strict mode (alphanumeric only)", () => {
|
||||
it("strips underscores and hyphens from tool call IDs", () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -162,7 +163,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
||||
toolName: "login",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
|
||||
expect(out).not.toBe(input);
|
||||
@@ -184,7 +185,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
||||
|
||||
describe("strict9 mode (Mistral tool call IDs)", () => {
|
||||
it("is a no-op for already-valid 9-char alphanumeric IDs", () => {
|
||||
const input = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "abc123XYZ", name: "read", arguments: {} }],
|
||||
@@ -195,14 +196,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 = [
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -222,7 +223,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "two" }],
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
]);
|
||||
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict9");
|
||||
expect(out).not.toBe(input);
|
||||
|
||||
@@ -419,7 +419,7 @@ export function resolveAcpInstallCommandHint(cfg: OpenClawConfig): string {
|
||||
if (existsSync(localPath)) {
|
||||
return `openclaw plugins install ${localPath}`;
|
||||
}
|
||||
return "openclaw plugins install @openclaw/acpx";
|
||||
return "openclaw plugins install acpx";
|
||||
}
|
||||
return `Install and enable the plugin that provides ACP backend "${backendId}".`;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,8 @@ 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 });
|
||||
expect(context).toMatchObject({ sessionKey, agentId: "main" });
|
||||
expect(context).toMatchObject({ sessionId: event?.sessionId });
|
||||
});
|
||||
|
||||
it("passes sessionKey to session_end hook context on reset", async () => {
|
||||
@@ -88,8 +89,13 @@ 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 });
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,6 +146,70 @@ 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) {
|
||||
@@ -643,39 +707,24 @@ 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")) {
|
||||
void hookRunner
|
||||
.runSessionEnd(
|
||||
{
|
||||
sessionId: previousSessionEntry.sessionId,
|
||||
sessionKey,
|
||||
messageCount: 0,
|
||||
},
|
||||
{
|
||||
sessionId: previousSessionEntry.sessionId,
|
||||
sessionKey,
|
||||
agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
|
||||
},
|
||||
)
|
||||
.catch(() => {});
|
||||
const payload = buildSessionEndHookPayload({
|
||||
sessionId: previousSessionEntry.sessionId,
|
||||
sessionKey,
|
||||
cfg,
|
||||
});
|
||||
void hookRunner.runSessionEnd(payload.event, payload.context).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Fire session_start for the new session
|
||||
if (hookRunner.hasHooks("session_start")) {
|
||||
void hookRunner
|
||||
.runSessionStart(
|
||||
{
|
||||
sessionId: effectiveSessionId,
|
||||
sessionKey,
|
||||
resumedFrom: previousSessionEntry?.sessionId,
|
||||
},
|
||||
{
|
||||
sessionId: effectiveSessionId,
|
||||
sessionKey,
|
||||
agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
|
||||
},
|
||||
)
|
||||
.catch(() => {});
|
||||
const payload = buildSessionStartHookPayload({
|
||||
sessionId: effectiveSessionId,
|
||||
sessionKey,
|
||||
cfg,
|
||||
resumedFrom: previousSessionEntry?.sessionId,
|
||||
});
|
||||
void hookRunner.runSessionStart(payload.event, payload.context).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildParseArgv,
|
||||
getFlagValue,
|
||||
getCommandPath,
|
||||
getCommandPositionalsWithRootOptions,
|
||||
getCommandPathWithRootOptions,
|
||||
getPrimaryCommand,
|
||||
getPositiveIntFlagValue,
|
||||
@@ -170,6 +171,41 @@ 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",
|
||||
|
||||
@@ -188,6 +188,91 @@ 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[];
|
||||
|
||||
@@ -102,6 +102,38 @@ 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"]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { consumeRootOptionToken, isValueToken } from "../../infra/cli-root-options.js";
|
||||
import { isValueToken } from "../../infra/cli-root-options.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js";
|
||||
import {
|
||||
getCommandPositionalsWithRootOptions,
|
||||
getFlagValue,
|
||||
getPositiveIntFlagValue,
|
||||
getVerboseFlag,
|
||||
hasFlag,
|
||||
} from "../argv.js";
|
||||
|
||||
export type RouteSpec = {
|
||||
match: (path: string[]) => boolean;
|
||||
@@ -100,31 +106,6 @@ 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);
|
||||
@@ -156,8 +137,14 @@ function getFlagValues(argv: string[], name: string): string[] | null {
|
||||
const routeConfigGet: RouteSpec = {
|
||||
match: (path) => path[0] === "config" && path[1] === "get",
|
||||
run: async (argv) => {
|
||||
const positionals = getCommandPositionals(argv);
|
||||
const pathArg = positionals[2];
|
||||
const positionals = getCommandPositionalsWithRootOptions(argv, {
|
||||
commandPath: ["config", "get"],
|
||||
booleanFlags: ["--json"],
|
||||
});
|
||||
if (!positionals || positionals.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
const pathArg = positionals[0];
|
||||
if (!pathArg) {
|
||||
return false;
|
||||
}
|
||||
@@ -171,8 +158,13 @@ const routeConfigGet: RouteSpec = {
|
||||
const routeConfigUnset: RouteSpec = {
|
||||
match: (path) => path[0] === "config" && path[1] === "unset",
|
||||
run: async (argv) => {
|
||||
const positionals = getCommandPositionals(argv);
|
||||
const pathArg = positionals[2];
|
||||
const positionals = getCommandPositionalsWithRootOptions(argv, {
|
||||
commandPath: ["config", "unset"],
|
||||
});
|
||||
if (!positionals || positionals.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
const pathArg = positionals[0];
|
||||
if (!pathArg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { acquireSessionWriteLock } from "../../agents/session-write-lock.js";
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
import { applySessionStoreMigrations } from "./store-migrations.js";
|
||||
import {
|
||||
mergeSessionEntry,
|
||||
mergeSessionEntryPreserveActivity,
|
||||
normalizeSessionRuntimeModelFields,
|
||||
type SessionEntry,
|
||||
} from "./types.js";
|
||||
@@ -738,14 +738,9 @@ export async function recordSessionMetaFromInbound(params: {
|
||||
return null;
|
||||
}
|
||||
const next = existing
|
||||
? 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(),
|
||||
})
|
||||
? // Inbound metadata updates must not refresh activity timestamps;
|
||||
// idle reset evaluation relies on updatedAt from actual session turns.
|
||||
mergeSessionEntryPreserveActivity(existing, patch)
|
||||
: mergeSessionEntry(existing, patch);
|
||||
store[resolved.normalizedKey] = next;
|
||||
for (const legacyKey of resolved.legacyKeys) {
|
||||
|
||||
@@ -225,12 +225,31 @@ export function setSessionRuntimeModel(
|
||||
return true;
|
||||
}
|
||||
|
||||
export function mergeSessionEntry(
|
||||
export type SessionEntryMergePolicy = "touch-activity" | "preserve-activity";
|
||||
|
||||
type MergeSessionEntryOptions = {
|
||||
policy?: SessionEntryMergePolicy;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
function resolveMergedUpdatedAt(
|
||||
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 = Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, Date.now());
|
||||
const updatedAt = resolveMergedUpdatedAt(existing, patch, options);
|
||||
if (!existing) {
|
||||
return normalizeSessionRuntimeModelFields({ ...patch, sessionId, updatedAt });
|
||||
}
|
||||
@@ -248,6 +267,22 @@ export function mergeSessionEntry(
|
||||
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 {
|
||||
|
||||
@@ -27,4 +27,10 @@ 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\\"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,148 +1,14 @@
|
||||
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 { 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 { 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 type { PluginRuntime } from "./types.js";
|
||||
|
||||
let cachedVersion: string | null = null;
|
||||
@@ -162,87 +28,8 @@ 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 {
|
||||
return {
|
||||
const runtime = {
|
||||
version: resolveVersion(),
|
||||
config: createRuntimeConfig(),
|
||||
system: createRuntimeSystem(),
|
||||
@@ -251,226 +38,12 @@ export function createPluginRuntime(): PluginRuntime {
|
||||
stt: { transcribeAudioFile },
|
||||
tools: createRuntimeTools(),
|
||||
channel: createRuntimeChannel(),
|
||||
events: {
|
||||
onAgentEvent,
|
||||
onSessionTranscriptUpdate,
|
||||
},
|
||||
events: createRuntimeEvents(),
|
||||
logging: createRuntimeLogging(),
|
||||
state: { resolveStateDir },
|
||||
};
|
||||
}
|
||||
} satisfies PluginRuntime;
|
||||
|
||||
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),
|
||||
};
|
||||
},
|
||||
};
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export type { PluginRuntime } from "./types.js";
|
||||
|
||||
263
src/plugins/runtime/runtime-channel.ts
Normal file
263
src/plugins/runtime/runtime-channel.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
9
src/plugins/runtime/runtime-config.ts
Normal file
9
src/plugins/runtime/runtime-config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { loadConfig, writeConfigFile } from "../../config/config.js";
|
||||
import type { PluginRuntime } from "./types.js";
|
||||
|
||||
export function createRuntimeConfig(): PluginRuntime["config"] {
|
||||
return {
|
||||
loadConfig,
|
||||
writeConfigFile,
|
||||
};
|
||||
}
|
||||
10
src/plugins/runtime/runtime-events.ts
Normal file
10
src/plugins/runtime/runtime-events.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
21
src/plugins/runtime/runtime-logging.ts
Normal file
21
src/plugins/runtime/runtime-logging.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
17
src/plugins/runtime/runtime-media.ts
Normal file
17
src/plugins/runtime/runtime-media.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
14
src/plugins/runtime/runtime-system.ts
Normal file
14
src/plugins/runtime/runtime-system.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
11
src/plugins/runtime/runtime-tools.ts
Normal file
11
src/plugins/runtime/runtime-tools.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
108
src/plugins/runtime/runtime-whatsapp.ts
Normal file
108
src/plugins/runtime/runtime-whatsapp.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -150,6 +150,7 @@ describe("security audit", () => {
|
||||
let fixtureRoot = "";
|
||||
let caseId = 0;
|
||||
let channelSecurityRoot = "";
|
||||
let sharedChannelSecurityStateDir = "";
|
||||
let sharedCodeSafetyStateDir = "";
|
||||
let sharedCodeSafetyWorkspaceDir = "";
|
||||
let sharedExtensionsStateDir = "";
|
||||
@@ -161,12 +162,24 @@ 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 channelSecurityStateDir = path.join(channelSecurityRoot, `state-${caseId++}`);
|
||||
const credentialsDir = path.join(channelSecurityStateDir, "credentials");
|
||||
const credentialsDir = path.join(sharedChannelSecurityStateDir, "credentials");
|
||||
await fs.rm(credentialsDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
await fs.mkdir(credentialsDir, { recursive: true, mode: 0o700 });
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: channelSecurityStateDir }, () =>
|
||||
fn(channelSecurityStateDir),
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: sharedChannelSecurityStateDir }, () =>
|
||||
fn(sharedChannelSecurityStateDir),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -214,6 +227,11 @@ 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;
|
||||
@@ -682,12 +700,7 @@ description: test skill
|
||||
});
|
||||
|
||||
it("warns when sandbox browser containers have missing or stale hash labels", async () => {
|
||||
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 { stateDir, configPath } = await createFilesystemAuditFixture("browser-hash-labels");
|
||||
|
||||
const execDockerRawFn = (async (args: string[]) => {
|
||||
if (args[0] === "ps") {
|
||||
@@ -736,12 +749,7 @@ description: test skill
|
||||
});
|
||||
|
||||
it("skips sandbox browser hash label checks when docker inspect is unavailable", async () => {
|
||||
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 { stateDir, configPath } = await createFilesystemAuditFixture("browser-hash-labels-skip");
|
||||
|
||||
const execDockerRawFn = (async () => {
|
||||
throw new Error("spawn docker ENOENT");
|
||||
@@ -761,12 +769,9 @@ description: test skill
|
||||
});
|
||||
|
||||
it("flags sandbox browser containers with non-loopback published ports", async () => {
|
||||
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 { stateDir, configPath } = await createFilesystemAuditFixture(
|
||||
"browser-non-loopback-publish",
|
||||
);
|
||||
|
||||
const execDockerRawFn = (async (args: string[]) => {
|
||||
if (args[0] === "ps") {
|
||||
|
||||
@@ -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 { OpenClawConfig } from "../config/config.js";
|
||||
import type { ConfigFileSnapshot, 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,6 +104,10 @@ 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 {
|
||||
@@ -1033,11 +1037,14 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
||||
|
||||
const configSnapshot =
|
||||
opts.includeFilesystem !== false
|
||||
? await readConfigSnapshotForAudit({ env, configPath }).catch(() => null)
|
||||
? opts.configSnapshot !== undefined
|
||||
? opts.configSnapshot
|
||||
: await readConfigSnapshotForAudit({ env, configPath }).catch(() => null)
|
||||
: null;
|
||||
|
||||
if (opts.includeFilesystem !== false) {
|
||||
const codeSafetySummaryCache = new Map<string, Promise<unknown>>();
|
||||
const codeSafetySummaryCache =
|
||||
opts.codeSafetySummaryCache ?? new Map<string, Promise<unknown>>();
|
||||
findings.push(
|
||||
...(await collectFilesystemFindings({
|
||||
stateDir,
|
||||
|
||||
@@ -33,6 +33,13 @@ 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";
|
||||
|
||||
@@ -47,113 +54,6 @@ 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();
|
||||
@@ -572,6 +472,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export { isNonRecoverableSlackAuthError } from "./reconnect-policy.js";
|
||||
|
||||
export const __testing = {
|
||||
resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
|
||||
108
src/slack/monitor/reconnect-policy.ts
Normal file
108
src/slack/monitor/reconnect-policy.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -342,166 +342,6 @@ 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);
|
||||
@@ -565,30 +405,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
expect(draftStream.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it.each([
|
||||
{ label: "default account config", telegramCfg: {} },
|
||||
{ label: "account blockStreaming override", telegramCfg: { blockStreaming: true } },
|
||||
])("disables block streaming when streamMode is off ($label)", async ({ telegramCfg }) => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
@@ -598,6 +418,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "off",
|
||||
telegramCfg,
|
||||
});
|
||||
|
||||
expect(createTelegramDraftStream).not.toHaveBeenCalled();
|
||||
@@ -610,69 +431,27 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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 });
|
||||
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 });
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "off",
|
||||
telegramCfg: { blockStreaming: true },
|
||||
});
|
||||
await dispatchWithContext({ context: createContext(), streamMode });
|
||||
|
||||
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);
|
||||
});
|
||||
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not force new message on first assistant message start", async () => {
|
||||
const draftStream = createDraftStream(999);
|
||||
@@ -1076,7 +855,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) => {
|
||||
setupDraftStreams({ answerMessageId: 999 });
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 999 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver(
|
||||
{ text: emptyText as unknown as string },
|
||||
@@ -1090,6 +869,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
|
||||
expect(deliverReplies).not.toHaveBeenCalled();
|
||||
expect(editMessageTelegram).not.toHaveBeenCalled();
|
||||
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1484,45 +1264,6 @@ 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);
|
||||
@@ -1595,21 +1336,6 @@ 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);
|
||||
|
||||
@@ -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,14 +38,6 @@ 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,
|
||||
@@ -123,97 +115,6 @@ 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" });
|
||||
@@ -2031,7 +1932,7 @@ describe("createTelegramBot", () => {
|
||||
},
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
useFrozenTime("2026-02-20T00:00:00.000Z");
|
||||
try {
|
||||
createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
|
||||
const handler = getOnHandler("channel_post") as (
|
||||
@@ -2071,7 +1972,7 @@ describe("createTelegramBot", () => {
|
||||
expect(payload.RawBody).toContain(part1.slice(0, 32));
|
||||
expect(payload.RawBody).toContain(part2.slice(0, 32));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
useRealTime();
|
||||
}
|
||||
});
|
||||
it("drops oversized channel_post media instead of dispatching a placeholder message", async () => {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
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,
|
||||
@@ -34,13 +32,10 @@ import {
|
||||
resolveTelegramUpdateId,
|
||||
type TelegramUpdateKeyContext,
|
||||
} from "./bot-updates.js";
|
||||
import {
|
||||
buildTelegramGroupPeerId,
|
||||
resolveTelegramForumThreadId,
|
||||
resolveTelegramStreamMode,
|
||||
} from "./bot/helpers.js";
|
||||
import { buildTelegramGroupPeerId, 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;
|
||||
@@ -63,55 +58,7 @@ export type TelegramBotOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
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 { getTelegramSequentialKey };
|
||||
|
||||
export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
|
||||
|
||||
218
src/telegram/lane-delivery.test.ts
Normal file
218
src/telegram/lane-delivery.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
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"));
|
||||
});
|
||||
});
|
||||
92
src/telegram/sequential-key.test.ts
Normal file
92
src/telegram/sequential-key.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
54
src/telegram/sequential-key.ts
Normal file
54
src/telegram/sequential-key.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
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";
|
||||
}
|
||||
@@ -1895,8 +1895,6 @@
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user