mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-18 12:02:02 +08:00
Compare commits
3 Commits
optimize-c
...
fix/ci-tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85b0c5aea5 | ||
|
|
6edfb61d29 | ||
|
|
eb5caaf5b9 |
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -36,6 +36,7 @@ jobs:
|
||||
run_windows: ${{ steps.manifest.outputs.run_windows }}
|
||||
has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }}
|
||||
changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }}
|
||||
changed_paths_json: ${{ steps.manifest.outputs.changed_paths_json }}
|
||||
run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
|
||||
run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
|
||||
checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }}
|
||||
@@ -108,8 +109,16 @@ jobs:
|
||||
run: |
|
||||
node --input-type=module <<'EOF'
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { listChangedExtensionIds } from "./scripts/lib/changed-extensions.mjs";
|
||||
import {
|
||||
listChangedExtensionIds,
|
||||
listChangedPathsForScope,
|
||||
} from "./scripts/lib/changed-extensions.mjs";
|
||||
|
||||
const changedPaths = listChangedPathsForScope({
|
||||
base: process.env.BASE_SHA,
|
||||
head: "HEAD",
|
||||
fallbackBaseRef: process.env.BASE_REF,
|
||||
});
|
||||
const extensionIds = listChangedExtensionIds({
|
||||
base: process.env.BASE_SHA,
|
||||
head: "HEAD",
|
||||
@@ -117,9 +126,11 @@ jobs:
|
||||
unavailableBaseBehavior: "all",
|
||||
});
|
||||
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
|
||||
const changedPathsJson = JSON.stringify(changedPaths);
|
||||
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `changed_paths_json=${changedPathsJson}\n`, "utf8");
|
||||
EOF
|
||||
|
||||
- name: Build CI manifest
|
||||
@@ -135,6 +146,7 @@ jobs:
|
||||
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
|
||||
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
|
||||
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
|
||||
OPENCLAW_CI_CHANGED_PATHS_JSON: ${{ steps.changed_extensions.outputs.changed_paths_json || '[]' }}
|
||||
run: |
|
||||
node --input-type=module <<'EOF'
|
||||
import { appendFileSync } from "node:fs";
|
||||
@@ -203,6 +215,7 @@ jobs:
|
||||
run_windows: runWindows,
|
||||
has_changed_extensions: hasChangedExtensions,
|
||||
changed_extensions_matrix: changedExtensionsMatrix,
|
||||
changed_paths_json: process.env.OPENCLAW_CI_CHANGED_PATHS_JSON ?? "[]",
|
||||
run_build_artifacts: runNode,
|
||||
run_checks_fast: runNode,
|
||||
checks_fast_core_matrix: createMatrix(
|
||||
@@ -956,6 +969,8 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 4
|
||||
OPENCLAW_EXTENSION_BOUNDARY_CHANGED_EXTENSIONS_MATRIX: ${{ needs.preflight.outputs.changed_extensions_matrix }}
|
||||
OPENCLAW_EXTENSION_BOUNDARY_CHANGED_PATHS_JSON: ${{ needs.preflight.outputs.changed_paths_json }}
|
||||
run: pnpm run test:extensions:package-boundary
|
||||
|
||||
- name: Enforce safe external URL opening policy
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -25,22 +25,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/streaming: clear the compaction replay guard after visible non-final boundaries so a post-tool assistant reply rotates to a fresh preview instead of editing the pre-compaction message. (#67993) Thanks @obviyus.
|
||||
- Matrix: fix `sessions_spawn --thread` subagent session spawning — thread binding creation, cleanup on session end, and completion-message delivery target resolution now work end-to-end. (#67643) Thanks @eejohnso-ops and @gumadeiras.
|
||||
- macOS/webchat: enable Undo and Redo in the composer text input by turning on the native `NSTextView` undo manager. (#34962) Thanks @tylerbittner.
|
||||
- macOS/remote SSH: require an already-trusted host key on the macOS remote command, gateway probe, port tunnel, and pairing probe paths by switching `StrictHostKeyChecking=accept-new` to `StrictHostKeyChecking=yes` and centralizing the shared SSH option fragments in `CommandResolver`, so first-time macOS remote connections no longer silently accept an unknown host key and must be trusted ahead of time via `~/.ssh/known_hosts`. (#68199)
|
||||
- CLI/configure: show the channel picker before probing statuses and let remove mode delete configured channel blocks directly from config. (#68007) Thanks @gumadeiras.
|
||||
- OpenAI Codex/OAuth: keep OpenClaw as the canonical owner for imported Codex CLI OAuth sessions, stop writing refreshed credentials back into `.codex`, and prefer fresher OpenClaw credentials over stale imported CLI state so refresh recovery stays stable. Thanks @vincentkoc.
|
||||
- OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc.
|
||||
- Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc.
|
||||
- OpenAI Codex/OAuth: keep external CLI OAuth imports runtime-only by overlaying fresher Codex CLI credentials without mutating `auth-profiles.json`, so `.codex` stays a bootstrap/runtime input instead of becoming durable OpenClaw state. Thanks @vincentkoc.
|
||||
- OpenAI Codex/OAuth: drop legacy CLI-manager routing from the remaining bootstrap path so Codex and MiniMax CLI imports are matched by their canonical OpenClaw profile ids instead of stale `managedBy` metadata. Thanks @vincentkoc.
|
||||
- OpenAI Codex/OAuth: only bootstrap from external CLI OAuth when the local OpenClaw profile is missing or unusable, so healthy local sessions are no longer overridden by fresher `.codex` tokens. Thanks @vincentkoc.
|
||||
- OpenAI Codex/OAuth: rename the external CLI bootstrap helper, reuse the same usable-oauth check across runtime fallback paths, and add debug logs plus health coverage so bootstrap decisions stay legible. Thanks @vincentkoc.
|
||||
- Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras.
|
||||
- Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201)
|
||||
- Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210)
|
||||
- Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke.
|
||||
- Failover/google: only treat `INTERNAL` status payloads as retryable timeouts when they also carry a `500` code, so malformed non-500 payloads do not enter the retry path. (#68238) Thanks @altaywtf and @Openbling.
|
||||
- Agents/tools: filter bundled MCP/LSP tools through the final owner-only and tool-policy pipeline after merging them into the effective tool list, so existing allowlists, deny rules, sandbox policy, subagent policy, and owner-only restrictions apply to bundled tools the same way they apply to core tools. (#68195)
|
||||
- Gateway/assistant media: require `operator.read` scope for assistant-media file and metadata requests on identity-bearing HTTP auth paths so callers without a read scope can no longer access assistant media. (#68175) Thanks @eleqtrizit.
|
||||
|
||||
## 2026.4.15
|
||||
|
||||
|
||||
@@ -3,12 +3,6 @@ import Foundation
|
||||
enum CommandResolver {
|
||||
private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath"
|
||||
private static let helperName = "openclaw"
|
||||
static let strictHostKeyCheckingSSHOptions = [
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
]
|
||||
static let updateHostKeysSSHOptions = [
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
|
||||
static func gatewayEntrypoint(in root: URL) -> String? {
|
||||
let distEntry = root.appendingPathComponent("dist/index.js").path
|
||||
@@ -403,7 +397,9 @@ enum CommandResolver {
|
||||
"""
|
||||
let options: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
] + self.strictHostKeyCheckingSSHOptions + self.updateHostKeysSSHOptions
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
let args = self.sshArguments(
|
||||
target: parsed,
|
||||
identity: settings.identity,
|
||||
|
||||
@@ -483,7 +483,8 @@ final class NodePairingApprovalPrompter {
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-o", "NumberOfPasswordPrompts=0",
|
||||
"-o", "PreferredAuthentications=publickey",
|
||||
] + CommandResolver.strictHostKeyCheckingSSHOptions
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
]
|
||||
guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -200,7 +200,9 @@ enum RemoteGatewayProbe {
|
||||
let options = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=5",
|
||||
] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
let args = CommandResolver.sshArguments(
|
||||
target: parsed,
|
||||
identity: identity,
|
||||
|
||||
@@ -73,12 +73,14 @@ final class RemotePortTunnel {
|
||||
let options: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
"-o", "ServerAliveInterval=15",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-o", "TCPKeepAlive=yes",
|
||||
"-N",
|
||||
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
|
||||
] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions
|
||||
]
|
||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let args = CommandResolver.sshArguments(
|
||||
target: parsed,
|
||||
|
||||
@@ -164,9 +164,6 @@ import Testing
|
||||
} else {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
#expect(cmd.contains("StrictHostKeyChecking=yes"))
|
||||
#expect(!cmd.contains("StrictHostKeyChecking=accept-new"))
|
||||
#expect(cmd.contains("UpdateHostKeys=yes"))
|
||||
#expect(cmd.contains("-i"))
|
||||
#expect(cmd.contains("/tmp/id_ed25519"))
|
||||
if let script = cmd.last {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
052943a9f1eb82a49452b6715f4c08faeb650d16a36c150a3c726ff392ecad0d plugin-sdk-api-baseline.json
|
||||
a5077395f009f5064331dc1c38bb2d6d2864299d3c1fbd9e40956c1700fa253c plugin-sdk-api-baseline.jsonl
|
||||
e3df4c13b4dcdc07809775c56eed15c3ab924db191a08fb5a7b48d6f73001966 plugin-sdk-api-baseline.json
|
||||
2bb30ad45d5b382e92fd6b8a240a47f7679c59f9b524e54420879fadc28264b8 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js";
|
||||
export { buildAnthropicProvider } from "./register.runtime.js";
|
||||
export {
|
||||
createAnthropicBetaHeadersWrapper,
|
||||
createAnthropicFastModeWrapper,
|
||||
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
upsertAuthProfile,
|
||||
validateAnthropicSetupToken,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
cloneFirstTemplateModel,
|
||||
type ProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import * as claudeCliAuth from "./cli-auth-seam.js";
|
||||
@@ -398,10 +395,11 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAnthropicProvider(): ProviderPlugin {
|
||||
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
|
||||
const providerId = "anthropic";
|
||||
const defaultAnthropicModel = DEFAULT_ANTHROPIC_MODEL;
|
||||
return {
|
||||
api.registerCliBackend(buildAnthropicCliBackend());
|
||||
api.registerProvider({
|
||||
id: providerId,
|
||||
label: "Anthropic",
|
||||
docsPath: "/providers/models",
|
||||
@@ -507,11 +505,6 @@ export function buildAnthropicProvider(): ProviderPlugin {
|
||||
store: ctx.store,
|
||||
profileId: ctx.profileId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
|
||||
api.registerCliBackend(buildAnthropicCliBackend());
|
||||
api.registerProvider(buildAnthropicProvider());
|
||||
});
|
||||
api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
GatewayIntents,
|
||||
@@ -221,9 +221,6 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", "");
|
||||
vi.stubEnv("OPENCLAW_DEBUG_PROXY_URL", "");
|
||||
vi.stubGlobal("fetch", globalFetchMock);
|
||||
vi.useRealTimers();
|
||||
baseRegisterClientSpy.mockClear();
|
||||
@@ -239,11 +236,6 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
resetLastAgent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("uses safe gateway metadata lookup without proxy", async () => {
|
||||
const runtime = createRuntime();
|
||||
const plugin = createDiscordGatewayPlugin({
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDiscordPluginBase } from "./shared.js";
|
||||
|
||||
describe("createDiscordPluginBase", () => {
|
||||
it("owns Discord native command name overrides", () => {
|
||||
const plugin = createDiscordPluginBase({ setup: {} as never });
|
||||
|
||||
expect(
|
||||
plugin.commands?.resolveNativeCommandName?.({
|
||||
commandKey: "tts",
|
||||
defaultName: "tts",
|
||||
}),
|
||||
).toBe("voice");
|
||||
expect(
|
||||
plugin.commands?.resolveNativeCommandName?.({
|
||||
commandKey: "status",
|
||||
defaultName: "status",
|
||||
}),
|
||||
).toBe("status");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js";
|
||||
import { __testing, createExaWebSearchProvider } from "./exa-web-search-provider.js";
|
||||
|
||||
describe("exa web search provider", () => {
|
||||
@@ -16,31 +15,6 @@ describe("exa web search provider", () => {
|
||||
expect(applied.plugins?.entries?.exa?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the lightweight contract surface aligned with provider metadata", () => {
|
||||
const provider = createExaWebSearchProvider();
|
||||
const contractProvider = createContractExaWebSearchProvider();
|
||||
if (!contractProvider.applySelectionConfig) {
|
||||
throw new Error("Expected contract applySelectionConfig to be defined");
|
||||
}
|
||||
const applied = contractProvider.applySelectionConfig({});
|
||||
|
||||
expect(contractProvider).toMatchObject({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
hint: provider.hint,
|
||||
onboardingScopes: provider.onboardingScopes,
|
||||
credentialLabel: provider.credentialLabel,
|
||||
envVars: provider.envVars,
|
||||
placeholder: provider.placeholder,
|
||||
signupUrl: provider.signupUrl,
|
||||
docsUrl: provider.docsUrl,
|
||||
autoDetectOrder: provider.autoDetectOrder,
|
||||
credentialPath: provider.credentialPath,
|
||||
});
|
||||
expect(contractProvider.createTool({ config: {}, searchConfig: {} })).toBeNull();
|
||||
expect(applied.plugins?.entries?.exa?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers scoped configured api keys over environment fallbacks", () => {
|
||||
expect(__testing.resolveExaApiKey({ apiKey: "exa-secret" })).toBe("exa-secret");
|
||||
});
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
|
||||
export function createExaWebSearchProvider(): WebSearchProviderPlugin {
|
||||
const credentialPath = "plugins.entries.exa.config.webSearch.apiKey";
|
||||
|
||||
return {
|
||||
id: "exa",
|
||||
label: "Exa Search",
|
||||
hint: "Neural + keyword search with date filters and content extraction",
|
||||
onboardingScopes: ["text-inference"],
|
||||
credentialLabel: "Exa API key",
|
||||
envVars: ["EXA_API_KEY"],
|
||||
placeholder: "exa-...",
|
||||
signupUrl: "https://exa.ai/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 65,
|
||||
credentialPath,
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath,
|
||||
searchCredential: { type: "scoped", scopeId: "exa" },
|
||||
configuredCredential: { pluginId: "exa" },
|
||||
selectionPluginId: "exa",
|
||||
}),
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
@@ -25,14 +25,9 @@ vi.mock("./bot.js", () => ({
|
||||
handleFeishuMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: createFeishuClientMock,
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendCardFeishu: sendCardFeishuMock,
|
||||
sendMessageFeishu: sendMessageFeishuMock,
|
||||
@@ -94,13 +89,6 @@ describe("Feishu Card Action Handler", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createFeishuClientMock.mockReset().mockReturnValue({
|
||||
im: {
|
||||
chat: {
|
||||
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "group" } }),
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(handleFeishuMessage)
|
||||
.mockReset()
|
||||
.mockResolvedValue(undefined as never);
|
||||
@@ -366,142 +354,6 @@ describe("Feishu Card Action Handler", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves DM chat type from the Feishu chat API when card context omits it", async () => {
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
im: {
|
||||
chat: {
|
||||
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "p2p" } }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const event = createCardActionEvent({
|
||||
token: "tok9b",
|
||||
chatId: "oc_dm_chat_123",
|
||||
actionValue: { text: "/help" },
|
||||
});
|
||||
|
||||
await handleFeishuCardAction({ cfg, event, runtime });
|
||||
|
||||
expect(handleFeishuMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({
|
||||
message: expect.objectContaining({
|
||||
chat_id: "oc_dm_chat_123",
|
||||
chat_type: "p2p",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses resolved DM chat type when building approval cards without stored context", async () => {
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
im: {
|
||||
chat: {
|
||||
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_mode: "p2p" } }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const event = createCardActionEvent({
|
||||
token: "tok9c",
|
||||
chatId: "oc_dm_chat_234",
|
||||
actionValue: createFeishuCardInteractionEnvelope({
|
||||
k: "meta",
|
||||
a: FEISHU_APPROVAL_REQUEST_ACTION,
|
||||
m: {
|
||||
command: "/new",
|
||||
prompt: "Start a fresh session?",
|
||||
},
|
||||
c: {
|
||||
u: "u123",
|
||||
h: "oc_dm_chat_234",
|
||||
e: Date.now() + 60_000,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
|
||||
|
||||
expect(sendCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
card: expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
elements: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
tag: "action",
|
||||
actions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
value: expect.objectContaining({
|
||||
c: expect.objectContaining({
|
||||
t: "p2p",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to p2p when Feishu chat API returns an error", async () => {
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
im: {
|
||||
chat: {
|
||||
get: vi.fn().mockResolvedValue({ code: 99, msg: "not found" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const event = createCardActionEvent({
|
||||
token: "tok9d",
|
||||
chatId: "oc_unknown_chat_456",
|
||||
actionValue: { text: "/help" },
|
||||
});
|
||||
|
||||
await handleFeishuCardAction({ cfg, event, runtime });
|
||||
|
||||
expect(handleFeishuMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({
|
||||
message: expect.objectContaining({
|
||||
chat_type: "p2p",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to p2p when Feishu chat API throws", async () => {
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
im: {
|
||||
chat: {
|
||||
get: vi.fn().mockRejectedValue(new Error("network failure")),
|
||||
},
|
||||
},
|
||||
});
|
||||
const event = createCardActionEvent({
|
||||
token: "tok9e",
|
||||
chatId: "oc_broken_chat_789",
|
||||
actionValue: { text: "/help" },
|
||||
});
|
||||
|
||||
await handleFeishuCardAction({ cfg, event, runtime });
|
||||
|
||||
expect(handleFeishuMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({
|
||||
message: expect.objectContaining({
|
||||
chat_type: "p2p",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops duplicate structured callback tokens", async () => {
|
||||
const event = createStructuredQuickActionEvent({
|
||||
token: "tok10",
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
|
||||
import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import {
|
||||
createApprovalCard,
|
||||
FEISHU_APPROVAL_CANCEL_ACTION,
|
||||
@@ -105,7 +104,7 @@ function releaseFeishuCardActionToken(params: { token: string; accountId: string
|
||||
function buildSyntheticMessageEvent(
|
||||
event: FeishuCardActionEvent,
|
||||
content: string,
|
||||
chatType: "p2p" | "group",
|
||||
chatType?: "p2p" | "group",
|
||||
): FeishuMessageEvent {
|
||||
return {
|
||||
sender: {
|
||||
@@ -118,7 +117,7 @@ function buildSyntheticMessageEvent(
|
||||
message: {
|
||||
message_id: `card-action-${event.token}`,
|
||||
chat_id: event.context.chat_id || event.operator.open_id,
|
||||
chat_type: chatType,
|
||||
chat_type: chatType ?? (event.context.chat_id ? "group" : "p2p"),
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: content }),
|
||||
},
|
||||
@@ -137,124 +136,20 @@ async function dispatchSyntheticCommand(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
event: FeishuCardActionEvent;
|
||||
command: string;
|
||||
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
|
||||
botOpenId?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
accountId?: string;
|
||||
chatType?: "p2p" | "group";
|
||||
}): Promise<void> {
|
||||
const resolvedChatType = await resolveCardActionChatType({
|
||||
event: params.event,
|
||||
account: params.account,
|
||||
chatType: params.chatType,
|
||||
log: params.runtime?.log ?? console.log,
|
||||
});
|
||||
await handleFeishuMessage({
|
||||
cfg: params.cfg,
|
||||
event: buildSyntheticMessageEvent(params.event, params.command, resolvedChatType),
|
||||
event: buildSyntheticMessageEvent(params.event, params.command, params.chatType),
|
||||
botOpenId: params.botOpenId,
|
||||
runtime: params.runtime,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
// Feishu's im.chat.get returns two fields:
|
||||
// chat_mode: conversation type — "p2p" | "group" | "topic"
|
||||
// chat_type: privacy classification — "private" | "public"
|
||||
// We check chat_mode first because it directly indicates conversation type.
|
||||
// "private" maps to "p2p" as the safe-failure direction (restrictive DM
|
||||
// policy) — a private group chat misclassified as p2p is safer than the
|
||||
// reverse. "topic" and "public" are treated as group semantics.
|
||||
function normalizeResolvedCardActionChatType(value: unknown): "p2p" | "group" | undefined {
|
||||
if (value === "group" || value === "topic" || value === "public") {
|
||||
return "group";
|
||||
}
|
||||
if (value === "p2p" || value === "private") {
|
||||
return "p2p";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolvedChatTypeCache = new Map<string, { value: "p2p" | "group"; expiresAt: number }>();
|
||||
const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000;
|
||||
const CHAT_TYPE_CACHE_MAX_SIZE = 5_000;
|
||||
|
||||
function pruneChatTypeCache(now: number): void {
|
||||
for (const [key, entry] of resolvedChatTypeCache.entries()) {
|
||||
if (entry.expiresAt <= now) {
|
||||
resolvedChatTypeCache.delete(key);
|
||||
}
|
||||
}
|
||||
if (resolvedChatTypeCache.size > CHAT_TYPE_CACHE_MAX_SIZE) {
|
||||
const excess = resolvedChatTypeCache.size - CHAT_TYPE_CACHE_MAX_SIZE;
|
||||
const iter = resolvedChatTypeCache.keys();
|
||||
for (let i = 0; i < excess; i++) {
|
||||
const key = iter.next().value;
|
||||
if (key !== undefined) {
|
||||
resolvedChatTypeCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeLogValue(v: string): string {
|
||||
return v.replace(/[\r\n]/g, " ").slice(0, 500);
|
||||
}
|
||||
|
||||
async function resolveCardActionChatType(params: {
|
||||
event: FeishuCardActionEvent;
|
||||
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
|
||||
chatType?: "p2p" | "group";
|
||||
log: (message: string) => void;
|
||||
}): Promise<"p2p" | "group"> {
|
||||
const explicitChatType = normalizeResolvedCardActionChatType(params.chatType);
|
||||
if (explicitChatType) {
|
||||
return explicitChatType;
|
||||
}
|
||||
|
||||
const chatId = params.event.context.chat_id?.trim();
|
||||
if (!chatId) {
|
||||
return "p2p";
|
||||
}
|
||||
|
||||
const cacheKey = `${params.account.accountId}:${chatId}`;
|
||||
const now = Date.now();
|
||||
pruneChatTypeCache(now);
|
||||
const cached = resolvedChatTypeCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = (await createFeishuClient(params.account).im.chat.get({
|
||||
path: { chat_id: chatId },
|
||||
})) as { code?: number; msg?: string; data?: { chat_type?: unknown; chat_mode?: unknown } };
|
||||
if (response.code === 0) {
|
||||
const resolvedChatType =
|
||||
normalizeResolvedCardActionChatType(response.data?.chat_mode) ??
|
||||
normalizeResolvedCardActionChatType(response.data?.chat_type);
|
||||
if (resolvedChatType) {
|
||||
resolvedChatTypeCache.set(cacheKey, { value: resolvedChatType, expiresAt: now + CHAT_TYPE_CACHE_TTL_MS });
|
||||
return resolvedChatType;
|
||||
}
|
||||
params.log(
|
||||
`feishu[${params.account.accountId}]: card action missing chat type for chat; defaulting to p2p`,
|
||||
);
|
||||
} else {
|
||||
params.log(
|
||||
`feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(response.msg ?? "unknown error")}; defaulting to p2p`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "unknown";
|
||||
params.log(
|
||||
`feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(message)}; defaulting to p2p`,
|
||||
);
|
||||
}
|
||||
|
||||
return "p2p";
|
||||
}
|
||||
|
||||
async function sendInvalidInteractionNotice(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
event: FeishuCardActionEvent;
|
||||
@@ -351,12 +246,7 @@ export async function handleFeishuCardAction(params: {
|
||||
prompt,
|
||||
sessionKey: envelope.c?.s,
|
||||
expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS,
|
||||
chatType: await resolveCardActionChatType({
|
||||
event,
|
||||
account,
|
||||
chatType: envelope.c?.t,
|
||||
log,
|
||||
}),
|
||||
chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"),
|
||||
confirmLabel: command === "/reset" ? "Reset" : "Confirm",
|
||||
}),
|
||||
accountId,
|
||||
@@ -392,11 +282,10 @@ export async function handleFeishuCardAction(params: {
|
||||
cfg,
|
||||
event,
|
||||
command,
|
||||
account,
|
||||
botOpenId: params.botOpenId,
|
||||
runtime,
|
||||
accountId,
|
||||
chatType: envelope.c?.t,
|
||||
chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"),
|
||||
});
|
||||
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
|
||||
return;
|
||||
@@ -422,7 +311,6 @@ export async function handleFeishuCardAction(params: {
|
||||
cfg,
|
||||
event,
|
||||
command: content,
|
||||
account,
|
||||
botOpenId: params.botOpenId,
|
||||
runtime,
|
||||
accountId,
|
||||
|
||||
@@ -2,8 +2,11 @@ import {
|
||||
resolveProviderHttpRequestConfig,
|
||||
type ProviderRequestTransportOverrides,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
applyAgentDefaultModelPrimary,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { parseGoogleOauthApiKey } from "./oauth-token-shared.js";
|
||||
export { applyGoogleGeminiModelDefault, GOOGLE_GEMINI_DEFAULT_MODEL } from "./onboard.js";
|
||||
import {
|
||||
DEFAULT_GOOGLE_API_BASE_URL,
|
||||
normalizeGoogleApiBaseUrl,
|
||||
@@ -21,8 +24,6 @@ export {
|
||||
shouldNormalizeGoogleGenerativeAiProviderConfig,
|
||||
shouldNormalizeGoogleProviderConfig,
|
||||
} from "./provider-policy.js";
|
||||
export { buildGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
|
||||
export { buildGoogleProvider } from "./provider-registration.js";
|
||||
|
||||
export function parseGeminiAuth(apiKey: string): { headers: Record<string, string> } {
|
||||
const parsed = apiKey.startsWith("{") ? parseGoogleOauthApiKey(apiKey) : null;
|
||||
@@ -87,3 +88,27 @@ export function resolveGoogleGenerativeAiHttpRequestConfig(params: {
|
||||
transport: params.transport,
|
||||
});
|
||||
}
|
||||
|
||||
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
|
||||
|
||||
export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): {
|
||||
next: OpenClawConfig;
|
||||
changed: boolean;
|
||||
} {
|
||||
const current = cfg.agents?.defaults?.model as unknown;
|
||||
const currentPrimary =
|
||||
typeof current === "string"
|
||||
? current.trim() || undefined
|
||||
: current &&
|
||||
typeof current === "object" &&
|
||||
typeof (current as { primary?: unknown }).primary === "string"
|
||||
? ((current as { primary: string }).primary || "").trim() || undefined
|
||||
: undefined;
|
||||
if (currentPrimary === GOOGLE_GEMINI_DEFAULT_MODEL) {
|
||||
return { next: cfg, changed: false };
|
||||
}
|
||||
return {
|
||||
next: applyAgentDefaultModelPrimary(cfg, GOOGLE_GEMINI_DEFAULT_MODEL),
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
ProviderFetchUsageSnapshotContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth-result";
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
|
||||
import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage";
|
||||
import { formatGoogleOauthApiKey, parseGoogleUsageToken } from "./oauth-token-shared.js";
|
||||
@@ -30,8 +29,8 @@ async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) {
|
||||
return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID);
|
||||
}
|
||||
|
||||
export function buildGoogleGeminiCliProvider(): ProviderPlugin {
|
||||
return {
|
||||
export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
docsPath: "/providers/models",
|
||||
@@ -129,9 +128,5 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
|
||||
};
|
||||
},
|
||||
fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) {
|
||||
api.registerProvider(buildGoogleGeminiCliProvider());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import {
|
||||
applyAgentDefaultModelPrimary,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
|
||||
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
|
||||
|
||||
export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): {
|
||||
next: OpenClawConfig;
|
||||
changed: boolean;
|
||||
} {
|
||||
const current = cfg.agents?.defaults?.model as unknown;
|
||||
const currentPrimary =
|
||||
typeof current === "string"
|
||||
? current.trim() || undefined
|
||||
: current &&
|
||||
typeof current === "object" &&
|
||||
typeof (current as { primary?: unknown }).primary === "string"
|
||||
? ((current as { primary: string }).primary || "").trim() || undefined
|
||||
: undefined;
|
||||
if (currentPrimary === GOOGLE_GEMINI_DEFAULT_MODEL) {
|
||||
return { next: cfg, changed: false };
|
||||
}
|
||||
return {
|
||||
next: applyAgentDefaultModelPrimary(cfg, GOOGLE_GEMINI_DEFAULT_MODEL),
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeGoogleModelId } from "./model-id.js";
|
||||
import { GOOGLE_GEMINI_DEFAULT_MODEL, applyGoogleGeminiModelDefault } from "./onboard.js";
|
||||
import {
|
||||
GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
applyGoogleGeminiModelDefault,
|
||||
normalizeGoogleProviderConfig,
|
||||
normalizeGoogleModelId,
|
||||
resolveGoogleGenerativeAiTransport,
|
||||
} from "./api.js";
|
||||
import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js";
|
||||
import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js";
|
||||
import {
|
||||
normalizeGoogleProviderConfig,
|
||||
resolveGoogleGenerativeAiTransport,
|
||||
} from "./provider-policy.js";
|
||||
|
||||
export function buildGoogleProvider(): ProviderPlugin {
|
||||
return {
|
||||
export function registerGoogleProvider(api: OpenClawPluginApi) {
|
||||
api.registerProvider({
|
||||
id: "google",
|
||||
label: "Google AI Studio",
|
||||
docsPath: "/providers/models",
|
||||
@@ -50,9 +50,5 @@ export function buildGoogleProvider(): ProviderPlugin {
|
||||
}),
|
||||
...GOOGLE_GEMINI_PROVIDER_HOOKS,
|
||||
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerGoogleProvider(api: OpenClawPluginApi) {
|
||||
api.registerProvider(buildGoogleProvider());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1 +1,28 @@
|
||||
export { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
|
||||
export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
|
||||
const credentialPath = "plugins.entries.google.config.webSearch.apiKey";
|
||||
|
||||
return {
|
||||
id: "gemini",
|
||||
label: "Gemini (Google Search)",
|
||||
hint: "Requires Google Gemini API key · Google Search grounding",
|
||||
onboardingScopes: ["text-inference"],
|
||||
credentialLabel: "Google Gemini API key",
|
||||
envVars: ["GEMINI_API_KEY"],
|
||||
placeholder: "AIza...",
|
||||
signupUrl: "https://aistudio.google.com/apikey",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 20,
|
||||
credentialPath,
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath,
|
||||
searchCredential: { type: "scoped", scopeId: "gemini" },
|
||||
configuredCredential: { pluginId: "google" },
|
||||
}),
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export {
|
||||
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
|
||||
} from "./src/media-contract.js";
|
||||
export {
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "./media-contract-api.js";
|
||||
} from "./src/media-contract.js";
|
||||
export {
|
||||
__testing as imessageConversationBindingTesting,
|
||||
createIMessageConversationBindingManager,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export {
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
|
||||
} from "./src/media-contract.js";
|
||||
@@ -10,7 +10,6 @@ export {
|
||||
OPENAI_DEFAULT_TTS_VOICE,
|
||||
} from "./default-models.js";
|
||||
export { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
|
||||
export { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js";
|
||||
export { buildOpenAIProvider } from "./openai-provider.js";
|
||||
export { buildOpenAIRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
|
||||
export { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js";
|
||||
|
||||
@@ -96,49 +96,6 @@ describe("readOpenAICodexCliOAuthProfile", () => {
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
it("allows the runtime-only Codex CLI profile when the stored default already matches", () => {
|
||||
const accessToken = buildJwt({
|
||||
exp: Math.floor(Date.now() / 1000) + 600,
|
||||
"https://api.openai.com/profile": {
|
||||
email: "codex@example.com",
|
||||
},
|
||||
});
|
||||
vi.spyOn(fs, "readFileSync").mockReturnValue(
|
||||
JSON.stringify({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
access_token: accessToken,
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct_123",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const firstParse = readOpenAICodexCliOAuthProfile({
|
||||
store: { version: 1, profiles: {} },
|
||||
});
|
||||
expect(firstParse).not.toBeNull();
|
||||
|
||||
const parsed = readOpenAICodexCliOAuthProfile({
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: firstParse!.credential,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
credential: {
|
||||
access: accessToken,
|
||||
refresh: "refresh-token",
|
||||
accountId: "acct_123",
|
||||
email: "codex@example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null without logging when the Codex CLI auth file is missing", () => {
|
||||
const error = Object.assign(new Error("missing"), {
|
||||
code: "ENOENT",
|
||||
|
||||
@@ -67,6 +67,7 @@ function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean
|
||||
a.provider === b.provider &&
|
||||
a.access === b.access &&
|
||||
a.refresh === b.refresh &&
|
||||
a.expires === b.expires &&
|
||||
a.clientId === b.clientId &&
|
||||
a.email === b.email &&
|
||||
a.displayName === b.displayName &&
|
||||
|
||||
@@ -72,7 +72,6 @@ import {
|
||||
runQaDockerScaffoldCommand,
|
||||
runQaDockerUpCommand,
|
||||
runQaCharacterEvalCommand,
|
||||
runQaCoverageReportCommand,
|
||||
runQaManualLaneCommand,
|
||||
runQaParityReportCommand,
|
||||
runQaSuiteCommand,
|
||||
@@ -337,13 +336,6 @@ describe("qa cli runtime", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("prints a markdown coverage report from scenario metadata", async () => {
|
||||
await runQaCoverageReportCommand({ repoRoot: process.cwd() });
|
||||
|
||||
expect(stdoutWrite).toHaveBeenCalledWith(expect.stringContaining("# QA Coverage Inventory"));
|
||||
expect(stdoutWrite).toHaveBeenCalledWith(expect.stringContaining("memory.recall"));
|
||||
});
|
||||
|
||||
it("resolves character eval paths and passes model refs through", async () => {
|
||||
await runQaCharacterEvalCommand({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { resolveQaParityPackScenarioIds } from "./agentic-parity.js";
|
||||
import { runQaCharacterEval, type QaCharacterModelOptions } from "./character-eval.js";
|
||||
import { resolveRepoRelativeOutputDir } from "./cli-paths.js";
|
||||
import { buildQaCoverageInventory, renderQaCoverageMarkdownReport } from "./coverage-report.js";
|
||||
import { buildQaDockerHarnessImage, writeQaDockerHarnessFiles } from "./docker-harness.js";
|
||||
import { runQaDockerUp } from "./docker-up.runtime.js";
|
||||
import type { QaCliBackendAuthMode } from "./gateway-child.js";
|
||||
@@ -37,7 +36,6 @@ import {
|
||||
type QaProviderMode,
|
||||
type QaProviderModeInput,
|
||||
} from "./run-config.js";
|
||||
import { readQaScenarioPack } from "./scenario-catalog.js";
|
||||
import { runQaSuiteFromRuntime } from "./suite-launch.runtime.js";
|
||||
|
||||
type InterruptibleServer = {
|
||||
@@ -444,29 +442,6 @@ export async function runQaParityReportCommand(opts: {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runQaCoverageReportCommand(opts: {
|
||||
repoRoot?: string;
|
||||
output?: string;
|
||||
json?: boolean;
|
||||
}) {
|
||||
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
|
||||
const inventory = buildQaCoverageInventory(readQaScenarioPack().scenarios);
|
||||
const outputPath = opts.output ? path.resolve(repoRoot, opts.output) : undefined;
|
||||
const body = opts.json
|
||||
? `${JSON.stringify(inventory, null, 2)}\n`
|
||||
: renderQaCoverageMarkdownReport(inventory);
|
||||
|
||||
if (outputPath) {
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, body, "utf8");
|
||||
process.stdout.write(`QA coverage report: ${outputPath}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(body);
|
||||
}
|
||||
|
||||
export async function runQaCharacterEvalCommand(opts: {
|
||||
repoRoot?: string;
|
||||
outputDir?: string;
|
||||
|
||||
@@ -44,14 +44,12 @@ const {
|
||||
runQaCredentialsAddCommand,
|
||||
runQaCredentialsListCommand,
|
||||
runQaCredentialsRemoveCommand,
|
||||
runQaCoverageReportCommand,
|
||||
runQaProviderServerCommand,
|
||||
runQaTelegramCommand,
|
||||
} = vi.hoisted(() => ({
|
||||
runQaCredentialsAddCommand: vi.fn(),
|
||||
runQaCredentialsListCommand: vi.fn(),
|
||||
runQaCredentialsRemoveCommand: vi.fn(),
|
||||
runQaCoverageReportCommand: vi.fn(),
|
||||
runQaProviderServerCommand: vi.fn(),
|
||||
runQaTelegramCommand: vi.fn(),
|
||||
}));
|
||||
@@ -74,7 +72,6 @@ vi.mock("./cli.runtime.js", () => ({
|
||||
runQaCredentialsAddCommand,
|
||||
runQaCredentialsListCommand,
|
||||
runQaCredentialsRemoveCommand,
|
||||
runQaCoverageReportCommand,
|
||||
runQaProviderServerCommand,
|
||||
}));
|
||||
|
||||
@@ -88,7 +85,6 @@ describe("qa cli registration", () => {
|
||||
runQaCredentialsAddCommand.mockReset();
|
||||
runQaCredentialsListCommand.mockReset();
|
||||
runQaCredentialsRemoveCommand.mockReset();
|
||||
runQaCoverageReportCommand.mockReset();
|
||||
runQaProviderServerCommand.mockReset();
|
||||
runQaTelegramCommand.mockReset();
|
||||
listQaRunnerCliContributions
|
||||
@@ -105,30 +101,10 @@ describe("qa cli registration", () => {
|
||||
const qa = program.commands.find((command) => command.name() === "qa");
|
||||
expect(qa).toBeDefined();
|
||||
expect(qa?.commands.map((command) => command.name())).toEqual(
|
||||
expect.arrayContaining([TEST_QA_RUNNER.commandName, "telegram", "credentials", "coverage"]),
|
||||
expect.arrayContaining([TEST_QA_RUNNER.commandName, "telegram", "credentials"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes coverage report flags into the qa runtime command", async () => {
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"openclaw",
|
||||
"qa",
|
||||
"coverage",
|
||||
"--repo-root",
|
||||
"/tmp/openclaw-repo",
|
||||
"--output",
|
||||
".artifacts/qa-coverage.md",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(runQaCoverageReportCommand).toHaveBeenCalledWith({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
output: ".artifacts/qa-coverage.md",
|
||||
json: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates discovered qa runner registration through the generic host seam", () => {
|
||||
const [{ registration }] = listQaRunnerCliContributions.mock.results[0]?.value;
|
||||
expect(registration.register).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -60,12 +60,6 @@ async function runQaParityReport(opts: {
|
||||
const runtime = await loadQaLabCliRuntime();
|
||||
await runtime.runQaParityReportCommand(opts);
|
||||
}
|
||||
|
||||
async function runQaCoverageReport(opts: { repoRoot?: string; output?: string; json?: boolean }) {
|
||||
const runtime = await loadQaLabCliRuntime();
|
||||
await runtime.runQaCoverageReportCommand(opts);
|
||||
}
|
||||
|
||||
async function runQaCharacterEval(opts: {
|
||||
repoRoot?: string;
|
||||
outputDir?: string;
|
||||
@@ -308,15 +302,6 @@ export function registerQaLabCli(program: Command) {
|
||||
},
|
||||
);
|
||||
|
||||
qa.command("coverage")
|
||||
.description("Print the markdown scenario coverage inventory")
|
||||
.option("--repo-root <path>", "Repository root to target when writing --output")
|
||||
.option("--output <path>", "Write the coverage inventory to this path")
|
||||
.option("--json", "Print JSON instead of Markdown", false)
|
||||
.action(async (opts: { repoRoot?: string; output?: string; json?: boolean }) => {
|
||||
await runQaCoverageReport(opts);
|
||||
});
|
||||
|
||||
qa.command("character-eval")
|
||||
.description("Run the character QA scenario across live models and write a judged report")
|
||||
.option("--repo-root <path>", "Repository root to target when running from a neutral cwd")
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildQaCoverageInventory, renderQaCoverageMarkdownReport } from "./coverage-report.js";
|
||||
import { readQaScenarioPack } from "./scenario-catalog.js";
|
||||
|
||||
describe("qa coverage report", () => {
|
||||
it("groups scenario coverage metadata by theme and surface", () => {
|
||||
const inventory = buildQaCoverageInventory(readQaScenarioPack().scenarios);
|
||||
|
||||
expect(inventory.scenarioCount).toBeGreaterThan(0);
|
||||
expect(inventory.coverageIdCount).toBeGreaterThan(0);
|
||||
expect(inventory.primaryCoverageIdCount).toBeGreaterThan(0);
|
||||
expect(inventory.secondaryCoverageIdCount).toBeGreaterThan(0);
|
||||
expect(inventory.overlappingCoverage.length).toBeGreaterThan(0);
|
||||
expect(inventory.missingCoverage).toEqual([]);
|
||||
expect(inventory.byTheme.memory.some((feature) => feature.id === "memory.recall")).toBe(true);
|
||||
expect(inventory.bySurface.memory.some((feature) => feature.id === "memory.recall")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders a compact markdown inventory", () => {
|
||||
const report = renderQaCoverageMarkdownReport(
|
||||
buildQaCoverageInventory(readQaScenarioPack().scenarios),
|
||||
);
|
||||
|
||||
expect(report).toContain("# QA Coverage Inventory");
|
||||
expect(report).toContain("- Missing coverage metadata: 0");
|
||||
expect(report).toContain("- Overlapping coverage IDs:");
|
||||
expect(report).toContain("memory.recall");
|
||||
expect(report).toContain("primary: memory-recall (qa/scenarios/memory/memory-recall.md)");
|
||||
expect(report).toContain("secondary: active-memory-preprompt-recall");
|
||||
});
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
import type { QaSeedScenarioWithSource } from "./scenario-catalog.js";
|
||||
|
||||
export type QaCoverageScenarioSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
sourcePath: string;
|
||||
theme: string;
|
||||
surfaces: string[];
|
||||
risk: string;
|
||||
};
|
||||
|
||||
export type QaCoverageIntent = "primary" | "secondary";
|
||||
|
||||
export type QaCoverageScenarioReference = QaCoverageScenarioSummary & {
|
||||
intent: QaCoverageIntent;
|
||||
};
|
||||
|
||||
export type QaCoverageFeatureSummary = {
|
||||
id: string;
|
||||
scenarios: QaCoverageScenarioReference[];
|
||||
};
|
||||
|
||||
export type QaCoverageInventory = {
|
||||
scenarioCount: number;
|
||||
coverageIdCount: number;
|
||||
primaryCoverageIdCount: number;
|
||||
secondaryCoverageIdCount: number;
|
||||
features: QaCoverageFeatureSummary[];
|
||||
overlappingCoverage: QaCoverageFeatureSummary[];
|
||||
missingCoverage: QaCoverageScenarioSummary[];
|
||||
byTheme: Record<string, QaCoverageFeatureSummary[]>;
|
||||
bySurface: Record<string, QaCoverageFeatureSummary[]>;
|
||||
};
|
||||
|
||||
function scenarioTheme(sourcePath: string) {
|
||||
const parts = sourcePath.split("/");
|
||||
return parts[2] ?? "unknown";
|
||||
}
|
||||
|
||||
function scenarioSurfaces(scenario: QaSeedScenarioWithSource) {
|
||||
return scenario.surfaces && scenario.surfaces.length > 0 ? scenario.surfaces : [scenario.surface];
|
||||
}
|
||||
|
||||
function scenarioRisk(scenario: QaSeedScenarioWithSource) {
|
||||
return scenario.risk ?? scenario.riskLevel ?? "unassigned";
|
||||
}
|
||||
|
||||
function summarizeScenario(scenario: QaSeedScenarioWithSource): QaCoverageScenarioSummary {
|
||||
return {
|
||||
id: scenario.id,
|
||||
title: scenario.title,
|
||||
sourcePath: scenario.sourcePath,
|
||||
theme: scenarioTheme(scenario.sourcePath),
|
||||
surfaces: scenarioSurfaces(scenario),
|
||||
risk: scenarioRisk(scenario),
|
||||
};
|
||||
}
|
||||
|
||||
function sortFeatures(features: readonly QaCoverageFeatureSummary[]) {
|
||||
return features.toSorted((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
export function buildQaCoverageInventory(
|
||||
scenarios: readonly QaSeedScenarioWithSource[],
|
||||
): QaCoverageInventory {
|
||||
const byCoverageId = new Map<string, QaCoverageFeatureSummary>();
|
||||
const primaryCoverageIds = new Set<string>();
|
||||
const secondaryCoverageIds = new Set<string>();
|
||||
const missingCoverage: QaCoverageScenarioSummary[] = [];
|
||||
|
||||
const addCoverage = (
|
||||
scenario: QaSeedScenarioWithSource,
|
||||
coverageIds: readonly string[] | undefined,
|
||||
intent: QaCoverageIntent,
|
||||
) => {
|
||||
const summary = summarizeScenario(scenario);
|
||||
for (const coverageId of coverageIds ?? []) {
|
||||
const feature = byCoverageId.get(coverageId) ?? {
|
||||
id: coverageId,
|
||||
scenarios: [],
|
||||
};
|
||||
feature.scenarios.push({ ...summary, intent });
|
||||
byCoverageId.set(coverageId, feature);
|
||||
if (intent === "primary") {
|
||||
primaryCoverageIds.add(coverageId);
|
||||
} else {
|
||||
secondaryCoverageIds.add(coverageId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
if (!scenario.coverage) {
|
||||
missingCoverage.push(summarizeScenario(scenario));
|
||||
continue;
|
||||
}
|
||||
addCoverage(scenario, scenario.coverage.primary, "primary");
|
||||
addCoverage(scenario, scenario.coverage.secondary, "secondary");
|
||||
}
|
||||
|
||||
const features = sortFeatures([...byCoverageId.values()]);
|
||||
const overlappingCoverage = features.filter((feature) => feature.scenarios.length > 1);
|
||||
const byTheme: Record<string, QaCoverageFeatureSummary[]> = {};
|
||||
const bySurface: Record<string, QaCoverageFeatureSummary[]> = {};
|
||||
|
||||
for (const feature of features) {
|
||||
const themes = new Set(feature.scenarios.map((scenario) => scenario.theme));
|
||||
for (const theme of themes) {
|
||||
byTheme[theme] ??= [];
|
||||
byTheme[theme].push({
|
||||
...feature,
|
||||
scenarios: feature.scenarios.filter((scenario) => scenario.theme === theme),
|
||||
});
|
||||
}
|
||||
const surfaces = new Set(feature.scenarios.flatMap((scenario) => scenario.surfaces));
|
||||
for (const surface of surfaces) {
|
||||
bySurface[surface] ??= [];
|
||||
bySurface[surface].push({
|
||||
...feature,
|
||||
scenarios: feature.scenarios.filter((scenario) => scenario.surfaces.includes(surface)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scenarioCount: scenarios.length,
|
||||
coverageIdCount: features.length,
|
||||
primaryCoverageIdCount: primaryCoverageIds.size,
|
||||
secondaryCoverageIdCount: secondaryCoverageIds.size,
|
||||
features,
|
||||
overlappingCoverage,
|
||||
missingCoverage,
|
||||
byTheme,
|
||||
bySurface,
|
||||
};
|
||||
}
|
||||
|
||||
function pushFeatureLines(lines: string[], features: readonly QaCoverageFeatureSummary[]) {
|
||||
for (const feature of sortFeatures(features)) {
|
||||
const scenarios = feature.scenarios
|
||||
.map((scenario) => `${scenario.intent}: ${scenario.id} (${scenario.sourcePath})`)
|
||||
.join(", ");
|
||||
lines.push(`- ${feature.id}: ${scenarios}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderQaCoverageMarkdownReport(inventory: QaCoverageInventory): string {
|
||||
const lines: string[] = [
|
||||
"# QA Coverage Inventory",
|
||||
"",
|
||||
`- Scenarios: ${inventory.scenarioCount}`,
|
||||
`- Coverage IDs: ${inventory.coverageIdCount}`,
|
||||
`- Primary coverage IDs: ${inventory.primaryCoverageIdCount}`,
|
||||
`- Secondary coverage IDs: ${inventory.secondaryCoverageIdCount}`,
|
||||
`- Overlapping coverage IDs: ${inventory.overlappingCoverage.length}`,
|
||||
`- Missing coverage metadata: ${inventory.missingCoverage.length}`,
|
||||
"",
|
||||
"## By Theme",
|
||||
"",
|
||||
];
|
||||
|
||||
for (const theme of Object.keys(inventory.byTheme).toSorted()) {
|
||||
lines.push(`### ${theme}`, "");
|
||||
pushFeatureLines(lines, inventory.byTheme[theme] ?? []);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("## By Surface", "");
|
||||
for (const surface of Object.keys(inventory.bySurface).toSorted()) {
|
||||
lines.push(`### ${surface}`, "");
|
||||
pushFeatureLines(lines, inventory.bySurface[surface] ?? []);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (inventory.overlappingCoverage.length > 0) {
|
||||
lines.push("## Overlap", "");
|
||||
pushFeatureLines(lines, inventory.overlappingCoverage);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (inventory.missingCoverage.length > 0) {
|
||||
lines.push("## Missing Metadata", "");
|
||||
for (const scenario of inventory.missingCoverage.toSorted((left, right) =>
|
||||
left.id.localeCompare(right.id),
|
||||
)) {
|
||||
lines.push(`- ${scenario.id}: ${scenario.sourcePath}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
@@ -268,41 +268,38 @@ describe("buildQaRuntimeEnv", () => {
|
||||
expect(env.CODEX_HOME).toBe("/custom/codex-home");
|
||||
});
|
||||
|
||||
it.each(["mock-openai", "aimock"] as const)(
|
||||
"scrubs direct and live provider keys in %s mode",
|
||||
(providerMode) => {
|
||||
const env = buildQaRuntimeEnv({
|
||||
...createParams({
|
||||
ANTHROPIC_API_KEY: "anthropic-live",
|
||||
ANTHROPIC_OAUTH_TOKEN: "anthropic-oauth",
|
||||
GEMINI_API_KEY: "gemini-live",
|
||||
GEMINI_API_KEYS: "gemini-a gemini-b",
|
||||
GOOGLE_API_KEY: "google-live",
|
||||
OPENAI_API_KEY: "openai-live",
|
||||
OPENAI_API_KEYS: "openai-a,openai-b",
|
||||
CODEX_HOME: "/host/.codex",
|
||||
OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live",
|
||||
OPENCLAW_LIVE_ANTHROPIC_KEYS: "anthropic-a,anthropic-b",
|
||||
OPENCLAW_LIVE_GEMINI_KEY: "gemini-live",
|
||||
OPENCLAW_LIVE_OPENAI_KEY: "openai-live",
|
||||
}),
|
||||
providerMode,
|
||||
});
|
||||
it("scrubs direct and live provider keys in mock mode", () => {
|
||||
const env = buildQaRuntimeEnv({
|
||||
...createParams({
|
||||
ANTHROPIC_API_KEY: "anthropic-live",
|
||||
ANTHROPIC_OAUTH_TOKEN: "anthropic-oauth",
|
||||
GEMINI_API_KEY: "gemini-live",
|
||||
GEMINI_API_KEYS: "gemini-a gemini-b",
|
||||
GOOGLE_API_KEY: "google-live",
|
||||
OPENAI_API_KEY: "openai-live",
|
||||
OPENAI_API_KEYS: "openai-a,openai-b",
|
||||
CODEX_HOME: "/host/.codex",
|
||||
OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live",
|
||||
OPENCLAW_LIVE_ANTHROPIC_KEYS: "anthropic-a,anthropic-b",
|
||||
OPENCLAW_LIVE_GEMINI_KEY: "gemini-live",
|
||||
OPENCLAW_LIVE_OPENAI_KEY: "openai-live",
|
||||
}),
|
||||
providerMode: "mock-openai",
|
||||
});
|
||||
|
||||
expect(env.OPENAI_API_KEY).toBeUndefined();
|
||||
expect(env.OPENAI_API_KEYS).toBeUndefined();
|
||||
expect(env.CODEX_HOME).toBeUndefined();
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
expect(env.ANTHROPIC_OAUTH_TOKEN).toBeUndefined();
|
||||
expect(env.GEMINI_API_KEY).toBeUndefined();
|
||||
expect(env.GEMINI_API_KEYS).toBeUndefined();
|
||||
expect(env.GOOGLE_API_KEY).toBeUndefined();
|
||||
expect(env.OPENCLAW_LIVE_OPENAI_KEY).toBeUndefined();
|
||||
expect(env.OPENCLAW_LIVE_ANTHROPIC_KEY).toBeUndefined();
|
||||
expect(env.OPENCLAW_LIVE_ANTHROPIC_KEYS).toBeUndefined();
|
||||
expect(env.OPENCLAW_LIVE_GEMINI_KEY).toBeUndefined();
|
||||
},
|
||||
);
|
||||
expect(env.OPENAI_API_KEY).toBeUndefined();
|
||||
expect(env.OPENAI_API_KEYS).toBeUndefined();
|
||||
expect(env.CODEX_HOME).toBeUndefined();
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
expect(env.ANTHROPIC_OAUTH_TOKEN).toBeUndefined();
|
||||
expect(env.GEMINI_API_KEY).toBeUndefined();
|
||||
expect(env.GEMINI_API_KEYS).toBeUndefined();
|
||||
expect(env.GOOGLE_API_KEY).toBeUndefined();
|
||||
expect(env.OPENCLAW_LIVE_OPENAI_KEY).toBeUndefined();
|
||||
expect(env.OPENCLAW_LIVE_ANTHROPIC_KEY).toBeUndefined();
|
||||
expect(env.OPENCLAW_LIVE_ANTHROPIC_KEYS).toBeUndefined();
|
||||
expect(env.OPENCLAW_LIVE_GEMINI_KEY).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats restart socket closures as retryable gateway call errors", () => {
|
||||
expect(__testing.isRetryableGatewayCallError("gateway closed (1006 abnormal closure)")).toBe(
|
||||
|
||||
@@ -79,32 +79,4 @@ describe("qa aimock server", () => {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats OpenAI Codex model refs as OpenAI-compatible snapshots", async () => {
|
||||
const server = await startQaAimockServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "openai-codex/gpt-5.4",
|
||||
stream: false,
|
||||
input: [makeResponsesInput("hello codex-compatible aimock")],
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const debug = await fetch(`${server.baseUrl}/debug/last-request`);
|
||||
expect(debug.status).toBe(200);
|
||||
expect(await debug.json()).toMatchObject({
|
||||
model: "openai-codex/gpt-5.4",
|
||||
providerVariant: "openai",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,8 +27,6 @@ describe("qa scenario catalog", () => {
|
||||
expect(pack.scenarios.some((scenario) => scenario.id === "character-vibes-c3po")).toBe(true);
|
||||
expect(pack.scenarios.every((scenario) => scenario.execution?.kind === "flow")).toBe(true);
|
||||
expect(pack.scenarios.some((scenario) => scenario.execution.flow?.steps.length)).toBe(true);
|
||||
expect(pack.scenarios.every((scenario) => scenario.coverage?.primary.length)).toBe(true);
|
||||
expect(readQaScenarioById("memory-recall").coverage?.primary).toContain("memory.recall");
|
||||
});
|
||||
|
||||
it("exposes bootstrap data from the markdown pack", () => {
|
||||
|
||||
@@ -51,44 +51,6 @@ const qaScenarioExecutionSchema = z.object({
|
||||
config: qaScenarioConfigSchema.optional(),
|
||||
});
|
||||
|
||||
const qaCoverageIdSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/, {
|
||||
message: "coverage ids must use lowercase dotted or dashed tokens",
|
||||
});
|
||||
|
||||
const qaCoverageIdListSchema = z.array(qaCoverageIdSchema).min(1);
|
||||
|
||||
const qaScenarioCoverageSchema = z
|
||||
.object({
|
||||
primary: qaCoverageIdListSchema,
|
||||
secondary: qaCoverageIdListSchema.optional(),
|
||||
})
|
||||
.superRefine((coverage, ctx) => {
|
||||
const seen = new Set<string>();
|
||||
const coverageEntries = [
|
||||
["primary", coverage.primary],
|
||||
["secondary", coverage.secondary],
|
||||
] as const;
|
||||
for (const [intent, ids] of coverageEntries) {
|
||||
if (!ids) {
|
||||
continue;
|
||||
}
|
||||
for (const [index, id] of ids.entries()) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
continue;
|
||||
}
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [intent, index],
|
||||
message: `duplicate coverage id: ${id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const qaScenarioGatewayRuntimeSchema = z.object({
|
||||
forwardHostHome: z.boolean().optional(),
|
||||
});
|
||||
@@ -176,9 +138,6 @@ const qaSeedScenarioSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
surface: z.string().trim().min(1),
|
||||
category: z.string().trim().min(1).optional(),
|
||||
coverage: qaScenarioCoverageSchema.optional(),
|
||||
surfaces: z.array(z.string().trim().min(1)).min(1).optional(),
|
||||
risk: z.enum(["low", "medium", "high"]).optional(),
|
||||
capabilities: z.array(z.string().trim().min(1)).optional(),
|
||||
lane: z.record(z.string(), z.union([z.boolean(), z.string()])).optional(),
|
||||
riskLevel: z.string().trim().min(1).optional(),
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSlackPluginBase, setSlackChannelAllowlist } from "./shared.js";
|
||||
|
||||
describe("createSlackPluginBase", () => {
|
||||
it("owns Slack native command name overrides", () => {
|
||||
const plugin = createSlackPluginBase({
|
||||
setup: {} as never,
|
||||
setupWizard: {} as never,
|
||||
});
|
||||
|
||||
expect(
|
||||
plugin.commands?.resolveNativeCommandName?.({
|
||||
commandKey: "status",
|
||||
defaultName: "status",
|
||||
}),
|
||||
).toBe("agentstatus");
|
||||
expect(
|
||||
plugin.commands?.resolveNativeCommandName?.({
|
||||
commandKey: "tts",
|
||||
defaultName: "tts",
|
||||
}),
|
||||
).toBe("tts");
|
||||
});
|
||||
});
|
||||
import { setSlackChannelAllowlist } from "./shared.js";
|
||||
|
||||
describe("setSlackChannelAllowlist", () => {
|
||||
it("writes canonical enabled entries for setup-generated channel allowlists", () => {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { detectTelegramLegacyStateMigrations } from "./src/state-migrations.js";
|
||||
@@ -17,9 +17,6 @@
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"setupFeatures": {
|
||||
"legacyStateMigrations": true
|
||||
},
|
||||
"channel": {
|
||||
"id": "telegram",
|
||||
"label": "Telegram",
|
||||
|
||||
@@ -9,10 +9,6 @@ export default defineBundledChannelSetupEntry({
|
||||
specifier: "./setup-plugin-api.js",
|
||||
exportName: "telegramSetupPlugin",
|
||||
},
|
||||
legacyStateMigrations: {
|
||||
specifier: "./legacy-state-migrations-api.js",
|
||||
exportName: "detectTelegramLegacyStateMigrations",
|
||||
},
|
||||
secrets: {
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
|
||||
const DEFAULT_AGENT_ID = "main";
|
||||
|
||||
function normalizeAgentId(value: string | undefined | null): string {
|
||||
const normalized = (value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+/g, "")
|
||||
.replace(/-+$/g, "");
|
||||
return normalized || DEFAULT_AGENT_ID;
|
||||
}
|
||||
|
||||
function normalizeChannelId(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function resolveDefaultAgentId(cfg: OpenClawConfig): string {
|
||||
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
const chosen = (agents.find((agent) => agent?.default) ?? agents[0])?.id;
|
||||
return normalizeAgentId(chosen);
|
||||
}
|
||||
|
||||
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
|
||||
if (key) {
|
||||
ids.add(normalizeAccountId(key));
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function resolveBindingAccount(params: {
|
||||
binding: unknown;
|
||||
channelId: string;
|
||||
}): { agentId: string; accountId: string } | null {
|
||||
if (!params.binding || typeof params.binding !== "object") {
|
||||
return null;
|
||||
}
|
||||
const binding = params.binding as {
|
||||
agentId?: unknown;
|
||||
match?: { channel?: unknown; accountId?: unknown };
|
||||
};
|
||||
if (normalizeChannelId(binding.match?.channel) !== params.channelId) {
|
||||
return null;
|
||||
}
|
||||
const accountId = typeof binding.match?.accountId === "string" ? binding.match.accountId : "";
|
||||
if (!accountId.trim() || accountId.trim() === "*") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
agentId: normalizeAgentId(typeof binding.agentId === "string" ? binding.agentId : undefined),
|
||||
accountId: normalizeAccountId(accountId),
|
||||
};
|
||||
}
|
||||
|
||||
function listBoundAccountIds(cfg: OpenClawConfig, channelId: string): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const binding of cfg.bindings ?? []) {
|
||||
const resolved = resolveBindingAccount({ binding, channelId });
|
||||
if (resolved) {
|
||||
ids.add(resolved.accountId);
|
||||
}
|
||||
}
|
||||
return [...ids].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function resolveDefaultAgentBoundAccountId(cfg: OpenClawConfig, channelId: string): string | null {
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
for (const binding of cfg.bindings ?? []) {
|
||||
const resolved = resolveBindingAccount({ binding, channelId });
|
||||
if (resolved?.agentId === defaultAgentId) {
|
||||
return resolved.accountId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function combineAccountIds(params: {
|
||||
configuredAccountIds: readonly string[];
|
||||
additionalAccountIds: readonly string[];
|
||||
}): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const id of [...params.configuredAccountIds, ...params.additionalAccountIds]) {
|
||||
ids.add(normalizeAccountId(id));
|
||||
}
|
||||
if (ids.size === 0) {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
return [...ids].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function resolveListedDefaultAccountId(params: {
|
||||
accountIds: readonly string[];
|
||||
configuredDefaultAccountId: string | null | undefined;
|
||||
}): string {
|
||||
const configured = normalizeOptionalAccountId(params.configuredDefaultAccountId);
|
||||
if (configured && params.accountIds.includes(configured)) {
|
||||
return configured;
|
||||
}
|
||||
if (params.accountIds.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return params.accountIds[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
|
||||
return combineAccountIds({
|
||||
configuredAccountIds: listConfiguredAccountIds(cfg),
|
||||
additionalAccountIds: listBoundAccountIds(cfg, "telegram"),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDefaultTelegramAccountSelection(cfg: OpenClawConfig): {
|
||||
accountId: string;
|
||||
accountIds: string[];
|
||||
shouldWarnMissingDefault: boolean;
|
||||
} {
|
||||
const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
|
||||
if (boundDefault) {
|
||||
return {
|
||||
accountId: boundDefault,
|
||||
accountIds: listTelegramAccountIds(cfg),
|
||||
shouldWarnMissingDefault: false,
|
||||
};
|
||||
}
|
||||
const accountIds = listTelegramAccountIds(cfg);
|
||||
const resolved = resolveListedDefaultAccountId({
|
||||
accountIds,
|
||||
configuredDefaultAccountId: cfg.channels?.telegram?.defaultAccount,
|
||||
});
|
||||
return {
|
||||
accountId: resolved,
|
||||
accountIds,
|
||||
shouldWarnMissingDefault:
|
||||
resolved === accountIds[0] &&
|
||||
!accountIds.includes(DEFAULT_ACCOUNT_ID) &&
|
||||
accountIds.length > 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
|
||||
return resolveDefaultTelegramAccountSelection(cfg).accountId;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import util from "node:util";
|
||||
import {
|
||||
createAccountActionGate,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
listCombinedAccountIds,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
resolveAccountEntry,
|
||||
resolveListedDefaultAccountId,
|
||||
resolveAccountWithDefaultFallback,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/account-core";
|
||||
@@ -11,13 +14,13 @@ import type {
|
||||
TelegramAccountConfig,
|
||||
TelegramActionConfig,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
listBoundAccountIds,
|
||||
resolveDefaultAgentBoundAccountId,
|
||||
} from "openclaw/plugin-sdk/routing";
|
||||
import { formatSetExplicitDefaultInstruction } from "openclaw/plugin-sdk/routing";
|
||||
import { createSubsystemLogger, isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
listTelegramAccountIds as listSelectedTelegramAccountIds,
|
||||
resolveDefaultTelegramAccountSelection,
|
||||
} from "./account-selection.js";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { TelegramTransport } from "./fetch.js";
|
||||
import { resolveTelegramToken } from "./token.js";
|
||||
|
||||
@@ -64,8 +67,22 @@ export type TelegramMediaRuntimeOptions = {
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
|
||||
if (key) {
|
||||
ids.add(normalizeAccountId(key));
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const ids = listSelectedTelegramAccountIds(cfg);
|
||||
const ids = listCombinedAccountIds({
|
||||
configuredAccountIds: listConfiguredAccountIds(cfg),
|
||||
additionalAccountIds: listBoundAccountIds(cfg, "telegram"),
|
||||
fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
debugAccounts("listTelegramAccountIds", ids);
|
||||
return ids;
|
||||
}
|
||||
@@ -78,15 +95,26 @@ export function resetMissingDefaultWarnFlag(): void {
|
||||
}
|
||||
|
||||
export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
|
||||
const selection = resolveDefaultTelegramAccountSelection(cfg);
|
||||
if (selection.shouldWarnMissingDefault && !emittedMissingDefaultWarn) {
|
||||
const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
|
||||
if (boundDefault) {
|
||||
return boundDefault;
|
||||
}
|
||||
const ids = listTelegramAccountIds(cfg);
|
||||
const resolved = resolveListedDefaultAccountId({
|
||||
accountIds: ids,
|
||||
configuredDefaultAccountId: normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount),
|
||||
});
|
||||
if (resolved !== ids[0] || ids.includes(DEFAULT_ACCOUNT_ID) || ids.length <= 1) {
|
||||
return resolved;
|
||||
}
|
||||
if (ids.length > 1 && !emittedMissingDefaultWarn) {
|
||||
emittedMissingDefaultWarn = true;
|
||||
getLog().warn(
|
||||
`channels.telegram: accounts.default is missing; falling back to "${selection.accountId}". ` +
|
||||
`channels.telegram: accounts.default is missing; falling back to "${ids[0]}". ` +
|
||||
`${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`,
|
||||
);
|
||||
}
|
||||
return selection.accountId;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function resolveTelegramAccountConfig(
|
||||
|
||||
@@ -119,10 +119,6 @@ function installPerKeySequentializer(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function mockTelegramConfigWrites() {
|
||||
return vi.spyOn(configRuntime, "writeConfigFile").mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
describe("createTelegramBot", () => {
|
||||
beforeAll(() => {
|
||||
process.env.TZ = "UTC";
|
||||
@@ -1469,7 +1465,6 @@ describe("createTelegramBot", () => {
|
||||
});
|
||||
|
||||
it("retries group migration updates after a bubbled handler failure", async () => {
|
||||
const writeConfigFileSpy = mockTelegramConfigWrites();
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
@@ -1519,17 +1514,12 @@ describe("createTelegramBot", () => {
|
||||
loadConfig.mockImplementationOnce(() => {
|
||||
throw new Error("cfg boom");
|
||||
});
|
||||
try {
|
||||
await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom");
|
||||
const loadConfigCallsAfterFailure = loadConfig.mock.calls.length;
|
||||
await runMiddlewareChain(ctx);
|
||||
await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom");
|
||||
const loadConfigCallsAfterFailure = loadConfig.mock.calls.length;
|
||||
await runMiddlewareChain(ctx);
|
||||
|
||||
expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1);
|
||||
expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure);
|
||||
expect(writeConfigFileSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
writeConfigFileSpy.mockRestore();
|
||||
}
|
||||
expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1);
|
||||
expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure);
|
||||
});
|
||||
|
||||
const groupPolicyCases: Array<{
|
||||
@@ -3120,7 +3110,6 @@ describe("createTelegramBot", () => {
|
||||
});
|
||||
|
||||
it("retries group migration updates after a bubbled handler failure", async () => {
|
||||
const writeConfigFileSpy = mockTelegramConfigWrites();
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
@@ -3170,17 +3159,12 @@ describe("createTelegramBot", () => {
|
||||
loadConfig.mockImplementationOnce(() => {
|
||||
throw new Error("cfg boom");
|
||||
});
|
||||
try {
|
||||
await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom");
|
||||
const loadConfigCallsAfterFailure = loadConfig.mock.calls.length;
|
||||
await runMiddlewareChain(ctx);
|
||||
await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom");
|
||||
const loadConfigCallsAfterFailure = loadConfig.mock.calls.length;
|
||||
await runMiddlewareChain(ctx);
|
||||
|
||||
expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1);
|
||||
expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure);
|
||||
expect(writeConfigFileSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
writeConfigFileSpy.mockRestore();
|
||||
}
|
||||
expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1);
|
||||
expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure);
|
||||
});
|
||||
|
||||
it("retries reaction updates after a bubbled enqueue failure", async () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing-paths";
|
||||
import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveDefaultTelegramAccountId } from "./account-selection.js";
|
||||
import { resolveDefaultTelegramAccountId } from "./accounts.js";
|
||||
|
||||
function fileExists(pathValue: string): boolean {
|
||||
try {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertBundledChannelEntries } from "../../test/helpers/bundled-channel-entry.ts";
|
||||
import entry from "./index.js";
|
||||
import setupEntry from "./setup-entry.js";
|
||||
|
||||
describe("twitch bundled entries", () => {
|
||||
assertBundledChannelEntries({
|
||||
entry,
|
||||
expectedId: "twitch",
|
||||
expectedName: "Twitch",
|
||||
setupEntry,
|
||||
});
|
||||
|
||||
it("loads the setup-only channel plugin", () => {
|
||||
const plugin = setupEntry.loadSetupPlugin?.();
|
||||
|
||||
expect(plugin?.id).toBe("twitch");
|
||||
expect(plugin?.setupWizard).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,6 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"install": {
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./setup-plugin-api.js",
|
||||
exportName: "twitchSetupPlugin",
|
||||
},
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
// Keep bundled setup entry imports narrow so setup loads do not pull the
|
||||
// broader Twitch channel plugin surface.
|
||||
export { twitchSetupPlugin } from "./src/setup-surface.js";
|
||||
@@ -54,30 +54,6 @@ describe("getAccountConfig", () => {
|
||||
expect(result?.username).toBe("secondbot");
|
||||
});
|
||||
|
||||
it("normalizes account ids without reading inherited account properties", () => {
|
||||
const accounts = Object.create({
|
||||
inherited: {
|
||||
username: "inherited-bot",
|
||||
accessToken: "oauth:inherited",
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
accounts.Secondary = {
|
||||
username: "secondbot",
|
||||
accessToken: "oauth:secondary",
|
||||
};
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(getAccountConfig(cfg, "SECONDARY\r\n")).toMatchObject({ username: "secondbot" });
|
||||
expect(getAccountConfig(cfg, "inherited")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-existent account ID", () => {
|
||||
const result = getAccountConfig(mockMultiAccountConfig, "nonexistent");
|
||||
|
||||
@@ -144,21 +120,6 @@ describe("listAccountIds", () => {
|
||||
} as Parameters<typeof listAccountIds>[0]),
|
||||
).toEqual(["default", "secondary"]);
|
||||
});
|
||||
|
||||
it("normalizes configured account ids", () => {
|
||||
expect(
|
||||
listAccountIds({
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
Secondary: { username: "secondbot" },
|
||||
"Alerts\r\n\u001b[31m": { username: "alerts" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof listAccountIds>[0]),
|
||||
).toEqual(["alerts-31m", "secondary"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultTwitchAccountId", () => {
|
||||
@@ -202,32 +163,4 @@ describe("resolveTwitchAccountContext", () => {
|
||||
expect(context.accountId).toBe("secondary");
|
||||
expect(context.account?.username).toBe("second-bot");
|
||||
});
|
||||
|
||||
it("keeps account and token lookup aligned after account id normalization", () => {
|
||||
const context = resolveTwitchAccountContext(
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
Secondary: {
|
||||
username: "second-bot",
|
||||
accessToken: "oauth:second-token",
|
||||
clientId: "second-client",
|
||||
channel: "#second",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof resolveTwitchAccountContext>[0],
|
||||
"secondary",
|
||||
);
|
||||
|
||||
expect(context.accountId).toBe("secondary");
|
||||
expect(context.account?.username).toBe("second-bot");
|
||||
expect(context.tokenResolution).toEqual({
|
||||
token: "oauth:second-token",
|
||||
source: "config",
|
||||
});
|
||||
expect(context.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
listCombinedAccountIds,
|
||||
normalizeAccountId,
|
||||
resolveNormalizedAccountEntry,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import { listCombinedAccountIds } from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveTwitchToken, type TwitchTokenResolution } from "./token.js";
|
||||
import type { TwitchAccountConfig } from "./types.js";
|
||||
@@ -40,19 +36,14 @@ export function getAccountConfig(
|
||||
}
|
||||
|
||||
const cfg = coreConfig as OpenClawConfig;
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const twitch = cfg.channels?.twitch;
|
||||
// Access accounts via unknown to handle union type (single-account vs multi-account)
|
||||
const twitchRaw = twitch as Record<string, unknown> | undefined;
|
||||
const accounts = twitchRaw?.accounts as Record<string, TwitchAccountConfig> | undefined;
|
||||
|
||||
// For default account, check base-level config first
|
||||
if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
const accountFromAccounts = resolveNormalizedAccountEntry(
|
||||
accounts,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
);
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID];
|
||||
|
||||
// Base-level properties that can form an implicit default account
|
||||
const baseLevel = {
|
||||
@@ -96,12 +87,11 @@ export function getAccountConfig(
|
||||
}
|
||||
|
||||
// For non-default accounts, only check accounts object
|
||||
const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId);
|
||||
if (!account) {
|
||||
if (!accounts || !accounts[accountId]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return account;
|
||||
return accounts[accountId] as TwitchAccountConfig | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,19 +113,16 @@ export function listAccountIds(cfg: OpenClawConfig): string[] {
|
||||
typeof twitchRaw.channel === "string");
|
||||
|
||||
return listCombinedAccountIds({
|
||||
configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) =>
|
||||
normalizeAccountId(accountId),
|
||||
),
|
||||
configuredAccountIds: Object.keys(accountMap ?? {}),
|
||||
implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDefaultTwitchAccountId(cfg: OpenClawConfig): string {
|
||||
const preferredRaw =
|
||||
const preferred =
|
||||
typeof cfg.channels?.twitch?.defaultAccount === "string"
|
||||
? cfg.channels.twitch.defaultAccount.trim()
|
||||
: "";
|
||||
const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : "";
|
||||
const ids = listAccountIds(cfg);
|
||||
if (preferred && ids.includes(preferred)) {
|
||||
return preferred;
|
||||
@@ -150,9 +137,7 @@ export function resolveTwitchAccountContext(
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string | null,
|
||||
): ResolvedTwitchAccountContext {
|
||||
const resolvedAccountId = accountId?.trim()
|
||||
? normalizeAccountId(accountId)
|
||||
: resolveDefaultTwitchAccountId(cfg);
|
||||
const resolvedAccountId = accountId?.trim() || resolveDefaultTwitchAccountId(cfg);
|
||||
const account = getAccountConfig(cfg, resolvedAccountId);
|
||||
const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
|
||||
return {
|
||||
|
||||
@@ -20,8 +20,6 @@ import {
|
||||
promptRefreshTokenSetup,
|
||||
promptToken,
|
||||
promptUsername,
|
||||
setTwitchAccount,
|
||||
twitchSetupPlugin,
|
||||
twitchSetupWizard,
|
||||
} from "./setup-surface.js";
|
||||
import type { TwitchAccountConfig } from "./types.js";
|
||||
@@ -29,13 +27,10 @@ import type { TwitchAccountConfig } from "./types.js";
|
||||
// Mock the helpers we're testing
|
||||
const mockPromptText = vi.fn();
|
||||
const mockPromptConfirm = vi.fn();
|
||||
const mockPromptNote = vi.fn();
|
||||
const mockPrompter: WizardPrompter = {
|
||||
text: mockPromptText,
|
||||
confirm: mockPromptConfirm,
|
||||
note: mockPromptNote,
|
||||
} as unknown as WizardPrompter;
|
||||
const originalEnvToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
|
||||
|
||||
const mockAccount: TwitchAccountConfig = {
|
||||
username: "testbot",
|
||||
@@ -50,11 +45,6 @@ describe("setup surface helpers", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnvToken === undefined) {
|
||||
delete process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = originalEnvToken;
|
||||
}
|
||||
// Don't restoreAllMocks as it breaks module-level mocks
|
||||
});
|
||||
|
||||
@@ -208,7 +198,7 @@ describe("setup surface helpers", () => {
|
||||
expect(defaultAccount?.clientId).toBe("test-client-id");
|
||||
});
|
||||
|
||||
it("skips env-token shortcut for non-default accounts", async () => {
|
||||
it("writes env-token setup to the configured default account", async () => {
|
||||
mockPromptConfirm.mockReset().mockResolvedValue(true as never);
|
||||
mockPromptText
|
||||
.mockReset()
|
||||
@@ -230,9 +220,12 @@ describe("setup surface helpers", () => {
|
||||
{} as Parameters<typeof configureWithEnvToken>[5],
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockPromptConfirm).not.toHaveBeenCalled();
|
||||
expect(mockPromptText).not.toHaveBeenCalled();
|
||||
const secondaryAccount = result?.cfg.channels?.twitch?.accounts?.secondary as
|
||||
| { username?: string; clientId?: string }
|
||||
| undefined;
|
||||
expect(secondaryAccount?.username).toBe("secondary-bot");
|
||||
expect(secondaryAccount?.clientId).toBe("secondary-client");
|
||||
expect(result?.cfg.channels?.twitch?.accounts?.default).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,256 +251,5 @@ describe("setup surface helpers", () => {
|
||||
|
||||
expect(lines).toEqual(["Twitch (secondary): configured"]);
|
||||
});
|
||||
|
||||
it("reports status for the requested account override", async () => {
|
||||
const lines = twitchSetupWizard.status?.resolveStatusLines?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: {
|
||||
username: "default-bot",
|
||||
accessToken: "oauth:default",
|
||||
clientId: "default-client",
|
||||
channel: "#default",
|
||||
},
|
||||
secondary: {
|
||||
username: "secondary-bot",
|
||||
accessToken: "oauth:secondary",
|
||||
clientId: "secondary-client",
|
||||
channel: "#secondary",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "secondary",
|
||||
configured: true,
|
||||
} as never);
|
||||
|
||||
expect(lines).toEqual(["Twitch (secondary): configured"]);
|
||||
});
|
||||
|
||||
it("reports env-token default account setup as configured", async () => {
|
||||
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:fromenv";
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: {
|
||||
username: "env-bot",
|
||||
accessToken: "",
|
||||
clientId: "env-client",
|
||||
channel: "#env",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<NonNullable<typeof twitchSetupWizard.status>["resolveConfigured"]>[0]["cfg"];
|
||||
|
||||
expect(twitchSetupWizard.status?.resolveConfigured({ cfg })).toBe(true);
|
||||
const account = twitchSetupPlugin.config.resolveAccount(cfg, "default");
|
||||
expect(await twitchSetupPlugin.config.isConfigured?.(account, cfg)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup wizard account routing", () => {
|
||||
it("rejects reserved account ids before using them as config keys", () => {
|
||||
expect(() =>
|
||||
setTwitchAccount(
|
||||
{} as Parameters<typeof setTwitchAccount>[0],
|
||||
{
|
||||
username: "reserved-bot",
|
||||
accessToken: "oauth:reserved",
|
||||
clientId: "reserved-client",
|
||||
channel: "#reserved",
|
||||
},
|
||||
"__proto__",
|
||||
),
|
||||
).toThrow("Invalid Twitch account id");
|
||||
|
||||
expect(Object.prototype).not.toHaveProperty("username");
|
||||
});
|
||||
|
||||
it("rejects reserved account ids before env-token writes", async () => {
|
||||
await expect(
|
||||
configureWithEnvToken(
|
||||
{} as Parameters<typeof configureWithEnvToken>[0],
|
||||
mockPrompter,
|
||||
null,
|
||||
"oauth:fromenv",
|
||||
false,
|
||||
{} as Parameters<typeof configureWithEnvToken>[5],
|
||||
"__proto__",
|
||||
),
|
||||
).rejects.toThrow("Invalid Twitch account id");
|
||||
|
||||
expect(mockPromptConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes account ids before rendering status lines", () => {
|
||||
expect(
|
||||
twitchSetupWizard.status?.resolveStatusLines?.({
|
||||
cfg: {},
|
||||
accountId: "Alerts\r\n\u001b[31m",
|
||||
configured: false,
|
||||
} as never),
|
||||
).toEqual(["Twitch (alerts-31m): needs username, token, and clientId"]);
|
||||
});
|
||||
|
||||
it("reports account-scoped DM policy config keys", () => {
|
||||
expect(
|
||||
twitchSetupWizard.dmPolicy?.resolveConfigKeys?.(
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
},
|
||||
},
|
||||
} as Parameters<
|
||||
NonNullable<NonNullable<typeof twitchSetupWizard.dmPolicy>["resolveConfigKeys"]>
|
||||
>[0],
|
||||
undefined,
|
||||
),
|
||||
).toEqual({
|
||||
policyKey: "channels.twitch.accounts.secondary.allowedRoles",
|
||||
allowFromKey: "channels.twitch.accounts.secondary.allowFrom",
|
||||
});
|
||||
|
||||
expect(twitchSetupWizard.dmPolicy?.resolveConfigKeys?.({} as never, "alerts")).toEqual({
|
||||
policyKey: "channels.twitch.accounts.alerts.allowedRoles",
|
||||
allowFromKey: "channels.twitch.accounts.alerts.allowFrom",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes to the requested account when defaultAccount is not created yet", async () => {
|
||||
mockPromptText
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce("secondary-bot" as never)
|
||||
.mockResolvedValueOnce("oauth:secondary" as never)
|
||||
.mockResolvedValueOnce("secondary-client" as never)
|
||||
.mockResolvedValueOnce("#secondary" as never);
|
||||
mockPromptConfirm.mockReset().mockResolvedValue(false as never);
|
||||
|
||||
const result = await twitchSetupWizard.finalize?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
accounts: {
|
||||
default: {
|
||||
username: "default-bot",
|
||||
accessToken: "oauth:default",
|
||||
clientId: "default-client",
|
||||
channel: "#default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0]["cfg"],
|
||||
accountId: "secondary",
|
||||
credentialValues: {},
|
||||
runtime: {} as Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0]["runtime"],
|
||||
prompter: mockPrompter,
|
||||
options: {},
|
||||
forceAllowFrom: false,
|
||||
});
|
||||
|
||||
const twitch = result?.cfg?.channels?.twitch;
|
||||
expect(twitch?.accounts?.secondary?.username).toBe("secondary-bot");
|
||||
expect(twitch?.accounts?.secondary?.accessToken).toBe("oauth:secondary");
|
||||
expect(twitch?.accounts?.default?.username).toBe("default-bot");
|
||||
});
|
||||
|
||||
it("persists a token instead of using env-token shortcut for non-default finalize", async () => {
|
||||
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:fromenv";
|
||||
mockPromptText
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce("secondary-bot" as never)
|
||||
.mockResolvedValueOnce("oauth:persisted" as never)
|
||||
.mockResolvedValueOnce("secondary-client" as never)
|
||||
.mockResolvedValueOnce("#secondary" as never);
|
||||
mockPromptConfirm.mockReset().mockResolvedValue(false as never);
|
||||
|
||||
const result = await twitchSetupWizard.finalize?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {},
|
||||
},
|
||||
},
|
||||
} as Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0]["cfg"],
|
||||
accountId: "secondary",
|
||||
credentialValues: {},
|
||||
runtime: {} as Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0]["runtime"],
|
||||
prompter: mockPrompter,
|
||||
options: {},
|
||||
forceAllowFrom: false,
|
||||
});
|
||||
|
||||
const twitch = result?.cfg?.channels?.twitch;
|
||||
expect(twitch?.accounts?.secondary?.accessToken).toBe("oauth:persisted");
|
||||
expect(mockPromptConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mockPromptConfirm).toHaveBeenCalledWith({
|
||||
message: "Enable automatic token refresh (requires client secret and refresh token)?",
|
||||
initialValue: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup-only plugin config", () => {
|
||||
it("lists all configured Twitch accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
accounts: {
|
||||
default: {
|
||||
username: "default-bot",
|
||||
accessToken: "oauth:default",
|
||||
clientId: "default-client",
|
||||
channel: "#default",
|
||||
},
|
||||
secondary: {
|
||||
username: "secondary-bot",
|
||||
accessToken: "oauth:secondary",
|
||||
clientId: "secondary-client",
|
||||
channel: "#secondary",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof twitchSetupPlugin.config.listAccountIds>[0];
|
||||
|
||||
expect(twitchSetupPlugin.config.listAccountIds(cfg)).toEqual(["default", "secondary"]);
|
||||
expect(twitchSetupPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
|
||||
});
|
||||
|
||||
it("normalizes exposed account ids", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
Secondary: {
|
||||
username: "secondary-bot",
|
||||
accessToken: "oauth:secondary",
|
||||
clientId: "secondary-client",
|
||||
channel: "#secondary",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof twitchSetupPlugin.config.listAccountIds>[0];
|
||||
|
||||
expect(twitchSetupPlugin.config.listAccountIds(cfg)).toEqual(["secondary"]);
|
||||
expect(twitchSetupPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
|
||||
expect(twitchSetupPlugin.config.resolveAccount(cfg, "SECONDARY\r\n").accountId).toBe(
|
||||
"secondary",
|
||||
);
|
||||
expect(twitchSetupPlugin.config.resolveAccount(cfg, "SECONDARY\r\n").username).toBe(
|
||||
"secondary-bot",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
* Twitch setup wizard surface for CLI setup.
|
||||
*/
|
||||
|
||||
import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { getChatChannelMeta, type ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
formatDocsLink,
|
||||
type ChannelSetupAdapter,
|
||||
@@ -11,37 +9,16 @@ import {
|
||||
type ChannelSetupWizard,
|
||||
type OpenClawConfig,
|
||||
type WizardPrompter,
|
||||
normalizeAccountId,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
getAccountConfig,
|
||||
listAccountIds,
|
||||
resolveDefaultTwitchAccountId,
|
||||
resolveTwitchAccountContext,
|
||||
} from "./config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, getAccountConfig, resolveDefaultTwitchAccountId } from "./config.js";
|
||||
import type { TwitchAccountConfig, TwitchRole } from "./types.js";
|
||||
import { isAccountConfigured } from "./utils/twitch.js";
|
||||
|
||||
const channel = "twitch" as const;
|
||||
const INVALID_ACCOUNT_ID_MESSAGE = "Invalid Twitch account id";
|
||||
|
||||
function normalizeRequestedSetupAccountId(accountId: string): string {
|
||||
const normalized = normalizeOptionalAccountId(accountId);
|
||||
if (!normalized) {
|
||||
throw new Error(INVALID_ACCOUNT_ID_MESSAGE);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveSetupAccountId(cfg: OpenClawConfig, requestedAccountId?: string): string {
|
||||
const requested = requestedAccountId?.trim();
|
||||
if (requested) {
|
||||
return normalizeRequestedSetupAccountId(requested);
|
||||
}
|
||||
|
||||
function resolveSetupAccountId(cfg: OpenClawConfig): string {
|
||||
const preferred = cfg.channels?.twitch?.defaultAccount?.trim();
|
||||
return preferred ? normalizeAccountId(preferred) : resolveDefaultTwitchAccountId(cfg);
|
||||
return preferred || resolveDefaultTwitchAccountId(cfg);
|
||||
}
|
||||
|
||||
export function setTwitchAccount(
|
||||
@@ -49,10 +26,7 @@ export function setTwitchAccount(
|
||||
account: Partial<TwitchAccountConfig>,
|
||||
accountId: string = resolveSetupAccountId(cfg),
|
||||
): OpenClawConfig {
|
||||
const resolvedAccountId = accountId.trim()
|
||||
? normalizeRequestedSetupAccountId(accountId)
|
||||
: resolveSetupAccountId(cfg);
|
||||
const existing = getAccountConfig(cfg, resolvedAccountId);
|
||||
const existing = getAccountConfig(cfg, accountId);
|
||||
const merged: TwitchAccountConfig = {
|
||||
username: account.username ?? existing?.username ?? "",
|
||||
accessToken: account.accessToken ?? existing?.accessToken ?? "",
|
||||
@@ -81,7 +55,7 @@ export function setTwitchAccount(
|
||||
...((
|
||||
(cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined
|
||||
)?.accounts as Record<string, unknown> | undefined),
|
||||
[resolvedAccountId]: merged,
|
||||
[accountId]: merged,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -218,15 +192,7 @@ export async function configureWithEnvToken(
|
||||
envToken: string,
|
||||
forceAllowFrom: boolean,
|
||||
dmPolicy: ChannelSetupDmPolicy,
|
||||
accountId: string = resolveSetupAccountId(cfg),
|
||||
): Promise<{ cfg: OpenClawConfig } | null> {
|
||||
const resolvedAccountId = accountId.trim()
|
||||
? normalizeRequestedSetupAccountId(accountId)
|
||||
: resolveSetupAccountId(cfg);
|
||||
if (resolvedAccountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const useEnv = await prompter.confirm({
|
||||
message: "Twitch env var OPENCLAW_TWITCH_ACCESS_TOKEN detected. Use env token?",
|
||||
initialValue: true,
|
||||
@@ -238,25 +204,15 @@ export async function configureWithEnvToken(
|
||||
const username = await promptUsername(prompter, account);
|
||||
const clientId = await promptClientId(prompter, account);
|
||||
|
||||
const cfgWithAccount = setTwitchAccount(
|
||||
cfg,
|
||||
{
|
||||
username,
|
||||
clientId,
|
||||
accessToken: "",
|
||||
enabled: true,
|
||||
},
|
||||
resolvedAccountId,
|
||||
);
|
||||
const cfgWithAccount = setTwitchAccount(cfg, {
|
||||
username,
|
||||
clientId,
|
||||
accessToken: "",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
if (forceAllowFrom && dmPolicy.promptAllowFrom) {
|
||||
return {
|
||||
cfg: await dmPolicy.promptAllowFrom({
|
||||
cfg: cfgWithAccount,
|
||||
prompter,
|
||||
accountId: resolvedAccountId,
|
||||
}),
|
||||
};
|
||||
return { cfg: await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) };
|
||||
}
|
||||
|
||||
return { cfg: cfgWithAccount };
|
||||
@@ -266,10 +222,9 @@ function setTwitchAccessControl(
|
||||
cfg: OpenClawConfig,
|
||||
allowedRoles: TwitchRole[],
|
||||
requireMention: boolean,
|
||||
accountId?: string,
|
||||
): OpenClawConfig {
|
||||
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
||||
const account = getAccountConfig(cfg, resolvedAccountId);
|
||||
const accountId = resolveSetupAccountId(cfg);
|
||||
const account = getAccountConfig(cfg, accountId);
|
||||
if (!account) {
|
||||
return cfg;
|
||||
}
|
||||
@@ -281,15 +236,12 @@ function setTwitchAccessControl(
|
||||
allowedRoles,
|
||||
requireMention,
|
||||
},
|
||||
resolvedAccountId,
|
||||
accountId,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTwitchGroupPolicy(
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string,
|
||||
): "open" | "allowlist" | "disabled" {
|
||||
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
|
||||
function resolveTwitchGroupPolicy(cfg: OpenClawConfig): "open" | "allowlist" | "disabled" {
|
||||
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
|
||||
if (account?.allowedRoles?.includes("all")) {
|
||||
return "open";
|
||||
}
|
||||
@@ -302,27 +254,19 @@ function resolveTwitchGroupPolicy(
|
||||
function setTwitchGroupPolicy(
|
||||
cfg: OpenClawConfig,
|
||||
policy: "open" | "allowlist" | "disabled",
|
||||
accountId?: string,
|
||||
): OpenClawConfig {
|
||||
const allowedRoles: TwitchRole[] =
|
||||
policy === "open" ? ["all"] : policy === "allowlist" ? ["moderator", "vip"] : [];
|
||||
return setTwitchAccessControl(cfg, allowedRoles, true, accountId);
|
||||
return setTwitchAccessControl(cfg, allowedRoles, true);
|
||||
}
|
||||
|
||||
const twitchDmPolicy: ChannelSetupDmPolicy = {
|
||||
label: "Twitch",
|
||||
channel,
|
||||
policyKey: "channels.twitch.accounts.default.allowedRoles",
|
||||
allowFromKey: "channels.twitch.accounts.default.allowFrom",
|
||||
resolveConfigKeys: (cfg, accountId) => {
|
||||
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
||||
return {
|
||||
policyKey: `channels.twitch.accounts.${resolvedAccountId}.allowedRoles`,
|
||||
allowFromKey: `channels.twitch.accounts.${resolvedAccountId}.allowFrom`,
|
||||
};
|
||||
},
|
||||
getCurrent: (cfg, accountId) => {
|
||||
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
|
||||
policyKey: "channels.twitch.allowedRoles",
|
||||
allowFromKey: "channels.twitch.accounts.<default>.allowFrom",
|
||||
getCurrent: (cfg) => {
|
||||
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
|
||||
if (account?.allowedRoles?.includes("all")) {
|
||||
return "open";
|
||||
}
|
||||
@@ -331,14 +275,14 @@ const twitchDmPolicy: ChannelSetupDmPolicy = {
|
||||
}
|
||||
return "disabled";
|
||||
},
|
||||
setPolicy: (cfg, policy, accountId) => {
|
||||
setPolicy: (cfg, policy) => {
|
||||
const allowedRoles: TwitchRole[] =
|
||||
policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"];
|
||||
return setTwitchAccessControl(cfg, allowedRoles, true, accountId);
|
||||
return setTwitchAccessControl(cfg, allowedRoles, true);
|
||||
},
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
||||
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
||||
const account = getAccountConfig(cfg, resolvedAccountId);
|
||||
promptAllowFrom: async ({ cfg, prompter }) => {
|
||||
const accountId = resolveSetupAccountId(cfg);
|
||||
const account = getAccountConfig(cfg, accountId);
|
||||
const existingAllowFrom = account?.allowFrom ?? [];
|
||||
|
||||
const entry = await prompter.text({
|
||||
@@ -358,7 +302,7 @@ const twitchDmPolicy: ChannelSetupDmPolicy = {
|
||||
...(account ?? undefined),
|
||||
allowFrom,
|
||||
},
|
||||
resolvedAccountId,
|
||||
accountId,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -367,16 +311,16 @@ const twitchGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
|
||||
label: "Twitch chat",
|
||||
placeholder: "",
|
||||
skipAllowlistEntries: true,
|
||||
currentPolicy: ({ cfg, accountId }) => resolveTwitchGroupPolicy(cfg, accountId),
|
||||
currentEntries: ({ cfg, accountId }) => {
|
||||
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
|
||||
currentPolicy: ({ cfg }) => resolveTwitchGroupPolicy(cfg),
|
||||
currentEntries: ({ cfg }) => {
|
||||
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
|
||||
return account?.allowFrom ?? [];
|
||||
},
|
||||
updatePrompt: ({ cfg, accountId }) => {
|
||||
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
|
||||
updatePrompt: ({ cfg }) => {
|
||||
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
|
||||
return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length);
|
||||
},
|
||||
setPolicy: ({ cfg, accountId, policy }) => setTwitchGroupPolicy(cfg, policy, accountId),
|
||||
setPolicy: ({ cfg, policy }) => setTwitchGroupPolicy(cfg, policy),
|
||||
resolveAllowlist: async () => [],
|
||||
applyAllowlist: ({ cfg }) => cfg,
|
||||
};
|
||||
@@ -395,28 +339,29 @@ export const twitchSetupAdapter: ChannelSetupAdapter = {
|
||||
|
||||
export const twitchSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
resolveAccountIdForConfigure: ({ cfg, accountOverride }) =>
|
||||
resolveSetupAccountId(cfg, accountOverride),
|
||||
resolveAccountIdForConfigure: ({ defaultAccountId }) => defaultAccountId,
|
||||
resolveShouldPromptAccountIds: () => false,
|
||||
status: {
|
||||
configuredLabel: "configured",
|
||||
unconfiguredLabel: "needs username, token, and clientId",
|
||||
configuredHint: "configured",
|
||||
unconfiguredHint: "needs setup",
|
||||
resolveConfigured: ({ cfg, accountId }) => {
|
||||
return resolveTwitchAccountContext(cfg, resolveSetupAccountId(cfg, accountId)).configured;
|
||||
resolveConfigured: ({ cfg }) => {
|
||||
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
|
||||
return account ? isAccountConfigured(account) : false;
|
||||
},
|
||||
resolveStatusLines: ({ cfg, accountId }) => {
|
||||
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
||||
const configured = resolveTwitchAccountContext(cfg, resolvedAccountId).configured;
|
||||
resolveStatusLines: ({ cfg }) => {
|
||||
const accountId = resolveSetupAccountId(cfg);
|
||||
const account = getAccountConfig(cfg, accountId);
|
||||
const configured = account ? isAccountConfigured(account) : false;
|
||||
return [
|
||||
`Twitch${resolvedAccountId !== DEFAULT_ACCOUNT_ID ? ` (${resolvedAccountId})` : ""}: ${configured ? "configured" : "needs username, token, and clientId"}`,
|
||||
`Twitch${accountId !== DEFAULT_ACCOUNT_ID ? ` (${accountId})` : ""}: ${configured ? "configured" : "needs username, token, and clientId"}`,
|
||||
];
|
||||
},
|
||||
},
|
||||
credentials: [],
|
||||
finalize: async ({ cfg, accountId: requestedAccountId, prompter, forceAllowFrom }) => {
|
||||
const accountId = resolveSetupAccountId(cfg, requestedAccountId);
|
||||
finalize: async ({ cfg, prompter, forceAllowFrom }) => {
|
||||
const accountId = resolveSetupAccountId(cfg);
|
||||
const account = getAccountConfig(cfg, accountId);
|
||||
|
||||
if (!account || !isAccountConfigured(account)) {
|
||||
@@ -425,7 +370,7 @@ export const twitchSetupWizard: ChannelSetupWizard = {
|
||||
|
||||
const envToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN?.trim();
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID && envToken && !account?.accessToken) {
|
||||
if (envToken && !account?.accessToken) {
|
||||
const envResult = await configureWithEnvToken(
|
||||
cfg,
|
||||
prompter,
|
||||
@@ -433,7 +378,6 @@ export const twitchSetupWizard: ChannelSetupWizard = {
|
||||
envToken,
|
||||
forceAllowFrom,
|
||||
twitchDmPolicy,
|
||||
accountId,
|
||||
);
|
||||
if (envResult) {
|
||||
return envResult;
|
||||
@@ -462,7 +406,7 @@ export const twitchSetupWizard: ChannelSetupWizard = {
|
||||
|
||||
const cfgWithAllowFrom =
|
||||
forceAllowFrom && twitchDmPolicy.promptAllowFrom
|
||||
? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter, accountId })
|
||||
? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter })
|
||||
: cfgWithAccount;
|
||||
|
||||
return { cfg: cfgWithAllowFrom };
|
||||
@@ -482,39 +426,3 @@ export const twitchSetupWizard: ChannelSetupWizard = {
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null };
|
||||
|
||||
export const twitchSetupPlugin: ChannelPlugin<ResolvedTwitchAccount> = {
|
||||
id: channel,
|
||||
meta: getChatChannelMeta(channel),
|
||||
capabilities: {
|
||||
chatTypes: ["group"],
|
||||
},
|
||||
config: {
|
||||
listAccountIds: (cfg) => listAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => {
|
||||
const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultTwitchAccountId(cfg));
|
||||
const account = getAccountConfig(cfg, resolvedAccountId);
|
||||
if (!account) {
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
username: "",
|
||||
accessToken: "",
|
||||
clientId: "",
|
||||
channel: "",
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
...account,
|
||||
};
|
||||
},
|
||||
defaultAccountId: (cfg) => resolveDefaultTwitchAccountId(cfg),
|
||||
isConfigured: (account, cfg) => resolveTwitchAccountContext(cfg, account?.accountId).configured,
|
||||
isEnabled: (account) => account.enabled !== false,
|
||||
},
|
||||
setup: twitchSetupAdapter,
|
||||
setupWizard: twitchSetupWizard,
|
||||
};
|
||||
|
||||
@@ -65,27 +65,6 @@ describe("token", () => {
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("should resolve token from normalized account id", () => {
|
||||
const result = resolveTwitchToken(
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
Secondary: {
|
||||
username: "secondary",
|
||||
accessToken: "oauth:secondary-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
{ accountId: "secondary" },
|
||||
);
|
||||
|
||||
expect(result.token).toBe("oauth:secondary-token");
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("should prioritize config token over env var (simplified config)", () => {
|
||||
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
||||
|
||||
|
||||
@@ -9,12 +9,8 @@
|
||||
* 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
resolveNormalizedAccountEntry,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
|
||||
export type TwitchTokenSource = "env" | "config" | "none";
|
||||
|
||||
@@ -60,8 +56,10 @@ export function resolveTwitchToken(
|
||||
|
||||
// Get merged account config (handles both simplified and multi-account patterns)
|
||||
const twitchCfg = cfg?.channels?.twitch;
|
||||
const accounts = twitchCfg?.accounts as Record<string, Record<string, unknown>> | undefined;
|
||||
const accountCfg = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
|
||||
const accountCfg =
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? (twitchCfg?.accounts?.[DEFAULT_ACCOUNT_ID] as Record<string, unknown> | undefined)
|
||||
: (twitchCfg?.accounts?.[accountId] as Record<string, unknown> | undefined);
|
||||
|
||||
// For default account, also check base-level config
|
||||
let token: string | undefined;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./src/session-contract.js";
|
||||
|
||||
export const whatsappLegacySessionSurface = {
|
||||
isLegacyGroupSessionKey,
|
||||
canonicalizeLegacySessionKey,
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { detectWhatsAppLegacyStateMigrations } from "./src/state-migrations.js";
|
||||
@@ -25,10 +25,6 @@
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"setupFeatures": {
|
||||
"legacyStateMigrations": true,
|
||||
"legacySessionSurfaces": true
|
||||
},
|
||||
"channel": {
|
||||
"id": "whatsapp",
|
||||
"label": "WhatsApp",
|
||||
|
||||
@@ -10,12 +10,4 @@ export default defineBundledChannelSetupEntry({
|
||||
specifier: "./setup-plugin-api.js",
|
||||
exportName: "whatsappSetupPlugin",
|
||||
},
|
||||
legacyStateMigrations: {
|
||||
specifier: "./legacy-state-migrations-api.js",
|
||||
exportName: "detectWhatsAppLegacyStateMigrations",
|
||||
},
|
||||
legacySessionSurface: {
|
||||
specifier: "./legacy-session-surface-api.js",
|
||||
exportName: "whatsappLegacySessionSurface",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
function extractLegacyWhatsAppGroupId(key: string): string | null {
|
||||
const trimmed = key.trim();
|
||||
|
||||
@@ -640,10 +640,6 @@
|
||||
"types": "./dist/plugin-sdk/channel-pairing.d.ts",
|
||||
"default": "./dist/plugin-sdk/channel-pairing.js"
|
||||
},
|
||||
"./plugin-sdk/channel-pairing-paths": {
|
||||
"types": "./dist/plugin-sdk/channel-pairing-paths.d.ts",
|
||||
"default": "./dist/plugin-sdk/channel-pairing-paths.js"
|
||||
},
|
||||
"./plugin-sdk/channel-policy": {
|
||||
"types": "./dist/plugin-sdk/channel-policy.d.ts",
|
||||
"default": "./dist/plugin-sdk/channel-policy.js"
|
||||
|
||||
@@ -368,10 +368,11 @@ describe("chunkMarkdown", () => {
|
||||
});
|
||||
it("does not break surrogate pairs when splitting long CJK lines", () => {
|
||||
// "𠀀" (U+20000) is a surrogate pair: 2 UTF-16 code units per character.
|
||||
// With an odd token budget, the fine-split must not cut inside a pair.
|
||||
// A line of 500 such characters = 1000 UTF-16 code units.
|
||||
// With tokens=99 (odd), the fine-split must not cut inside a pair.
|
||||
const surrogateChar = "\u{20000}"; // 𠀀
|
||||
const longLine = surrogateChar.repeat(120);
|
||||
const chunks = chunkMarkdown(longLine, { tokens: 31, overlap: 0 });
|
||||
const longLine = surrogateChar.repeat(500);
|
||||
const chunks = chunkMarkdown(longLine, { tokens: 99, overlap: 0 });
|
||||
for (const chunk of chunks) {
|
||||
// No chunk should contain the Unicode replacement character U+FFFD,
|
||||
// which would indicate a broken surrogate pair.
|
||||
|
||||
@@ -13,6 +13,5 @@ Key workflow:
|
||||
|
||||
- `qa suite` is the executable frontier subset / regression loop.
|
||||
- `qa manual` is the scoped personality and style probe after the executable subset is green.
|
||||
- `qa coverage` prints the scenario coverage inventory from scenario frontmatter.
|
||||
|
||||
Keep this folder in git. Add new scenarios here before wiring them into automation.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: instruction-followthrough-repo-contract
|
||||
title: Instruction followthrough repo contract
|
||||
surface: repo-contract
|
||||
coverage:
|
||||
primary:
|
||||
- agents.instructions
|
||||
secondary:
|
||||
- runtime.first-action
|
||||
objective: Verify the agent reads repo instruction files first, follows the required tool order, and completes the first feasible action instead of stopping at a plan.
|
||||
successCriteria:
|
||||
- Agent reads the seeded instruction files before writing the requested artifact.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: subagent-fanout-synthesis
|
||||
title: Subagent fanout synthesis
|
||||
surface: subagents
|
||||
coverage:
|
||||
primary:
|
||||
- agents.subagents
|
||||
secondary:
|
||||
- agents.synthesis
|
||||
objective: Verify the agent can delegate multiple bounded subagent tasks and fold both results back into one parent reply.
|
||||
successCriteria:
|
||||
- Parent flow launches at least two bounded subagent tasks.
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
id: subagent-handoff
|
||||
title: Subagent handoff
|
||||
surface: subagents
|
||||
coverage:
|
||||
primary:
|
||||
- agents.subagents
|
||||
objective: Verify the agent can delegate a bounded task to a subagent and fold the result back into the main thread.
|
||||
successCriteria:
|
||||
- Agent launches a bounded subagent task.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: channel-chat-baseline
|
||||
title: Channel baseline conversation
|
||||
surface: channel
|
||||
coverage:
|
||||
primary:
|
||||
- channels.group-messages
|
||||
secondary:
|
||||
- channels.qa-channel
|
||||
objective: Verify the QA agent can respond correctly in a shared channel and respect mention-driven group semantics.
|
||||
successCriteria:
|
||||
- Agent replies in the shared channel transcript.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: dm-chat-baseline
|
||||
title: DM baseline conversation
|
||||
surface: dm
|
||||
coverage:
|
||||
primary:
|
||||
- channels.dm
|
||||
secondary:
|
||||
- channels.qa-channel
|
||||
objective: Verify the QA agent can chat coherently in a DM, explain the QA setup, and stay in character.
|
||||
successCriteria:
|
||||
- Agent replies in DM without channel routing mistakes.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: reaction-edit-delete
|
||||
title: Reaction, edit, delete lifecycle
|
||||
surface: message-actions
|
||||
coverage:
|
||||
primary:
|
||||
- channels.message-actions
|
||||
secondary:
|
||||
- channels.qa-channel
|
||||
objective: Verify the agent can use channel-owned message actions and that the QA transcript reflects them.
|
||||
successCriteria:
|
||||
- Agent adds at least one reaction.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: thread-follow-up
|
||||
title: Threaded follow-up
|
||||
surface: thread
|
||||
coverage:
|
||||
primary:
|
||||
- channels.threads
|
||||
secondary:
|
||||
- channels.qa-channel
|
||||
objective: Verify the agent can keep follow-up work inside a thread and not leak context into the root channel.
|
||||
successCriteria:
|
||||
- Agent creates or uses a thread for deeper work.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: character-vibes-c3po
|
||||
title: "Nervous release protocol chat"
|
||||
surface: character
|
||||
coverage:
|
||||
primary:
|
||||
- character.persona
|
||||
secondary:
|
||||
- workspace.artifacts
|
||||
objective: Capture a natural multi-turn C-3PO-flavored character conversation with real workspace help so another model can later grade naturalness, vibe, and funniness from the raw transcript.
|
||||
successCriteria:
|
||||
- Agent gets a natural multi-turn conversation, and any missed replies stay visible in the transcript instead of aborting capture.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: character-vibes-gollum
|
||||
title: "Late-night deploy helper chat"
|
||||
surface: character
|
||||
coverage:
|
||||
primary:
|
||||
- character.persona
|
||||
secondary:
|
||||
- workspace.artifacts
|
||||
objective: Capture a natural multi-turn character conversation with real workspace help so another model can later grade naturalness, vibe, and funniness from the raw transcript.
|
||||
successCriteria:
|
||||
- Agent gets a natural multi-turn conversation, and any missed replies stay visible in the transcript instead of aborting capture.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: config-apply-restart-wakeup
|
||||
title: Config apply restart wake-up
|
||||
surface: config
|
||||
coverage:
|
||||
primary:
|
||||
- config.restart-apply
|
||||
secondary:
|
||||
- runtime.gateway-restart
|
||||
objective: Verify a restart-required config.apply restarts cleanly and delivers the post-restart wake message back into the QA channel.
|
||||
successCriteria:
|
||||
- config.apply schedules a restart-required change.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: config-patch-hot-apply
|
||||
title: Config patch skill disable
|
||||
surface: config
|
||||
coverage:
|
||||
primary:
|
||||
- config.hot-apply
|
||||
secondary:
|
||||
- plugins.skills
|
||||
objective: Verify config.patch can disable a workspace skill and the restarted gateway exposes the new disabled state cleanly.
|
||||
successCriteria:
|
||||
- config.patch succeeds for the skill toggle change.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: config-restart-capability-flip
|
||||
title: Config restart capability flip
|
||||
surface: config
|
||||
coverage:
|
||||
primary:
|
||||
- config.restart-apply
|
||||
secondary:
|
||||
- plugins.capabilities
|
||||
objective: Verify a restart-triggering config change flips capability inventory and the same session successfully uses the newly restored tool after wake-up.
|
||||
successCriteria:
|
||||
- Capability is absent before the restart-triggering patch.
|
||||
|
||||
@@ -5,24 +5,13 @@ Single source of truth for repo-backed QA suite bootstrap data.
|
||||
|
||||
- `index.md` defines pack-level bootstrap data
|
||||
- each nested `*.md` scenario defines one runnable test via `qa-scenario` + `qa-flow`
|
||||
- scenario markdown may also define coverage IDs, category metadata, required plugins,
|
||||
lane filters, and gateway config patching
|
||||
- scenario markdown may also define category metadata, required plugins, lane filters,
|
||||
and gateway config patching
|
||||
|
||||
- kickoff mission
|
||||
- QA operator identity
|
||||
- scenario files under one-level theme directories
|
||||
|
||||
Coverage tracking:
|
||||
|
||||
- add `coverage.primary` IDs to each scenario's `qa-scenario` block
|
||||
- add `coverage.secondary` only when a scenario intentionally protects another behavior
|
||||
- keep IDs behavior-shaped, broad enough to reuse, lowercase, and dotted or dashed
|
||||
- prefer reusing an existing feature ID over minting a scenario-shaped ID
|
||||
- avoid copying the scenario title into coverage IDs
|
||||
- use `pnpm openclaw qa coverage` to render the current inventory
|
||||
- treat the old `coverage: ["id"]` / `coverage: - id` list shape as invalid
|
||||
- keep source-path tracking in the report, not in the scenario schema
|
||||
|
||||
Theme directories:
|
||||
|
||||
- `agents/` - agent behavior, instructions, and subagent flows
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: image-generation-roundtrip
|
||||
title: Image generation roundtrip
|
||||
surface: image-generation
|
||||
coverage:
|
||||
primary:
|
||||
- media.image-generation
|
||||
secondary:
|
||||
- channels.qa-channel
|
||||
objective: Verify a generated image is saved as media, reattached on the next turn, and described correctly through the vision path.
|
||||
successCriteria:
|
||||
- image_generate produces a saved MEDIA artifact.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: image-understanding-attachment
|
||||
title: Image understanding from attachment
|
||||
surface: image-understanding
|
||||
coverage:
|
||||
primary:
|
||||
- media.image-understanding
|
||||
secondary:
|
||||
- channels.qa-channel
|
||||
objective: Verify an attached image reaches the agent model and the agent can describe what it sees.
|
||||
successCriteria:
|
||||
- Agent receives at least one image attachment.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: native-image-generation
|
||||
title: Native image generation
|
||||
surface: image-generation
|
||||
coverage:
|
||||
primary:
|
||||
- media.image-generation
|
||||
secondary:
|
||||
- tools.native-image-generation
|
||||
objective: Verify image_generate appears when configured and returns a real saved media artifact.
|
||||
successCriteria:
|
||||
- image_generate appears in the effective tool inventory.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: active-memory-preprompt-recall
|
||||
title: Active Memory pre-reply recall
|
||||
surface: memory
|
||||
coverage:
|
||||
primary:
|
||||
- memory.active-recall
|
||||
secondary:
|
||||
- memory.recall
|
||||
objective: Verify Active Memory surfaces a memory-only preference before the main reply, and that the same question stays unresolved when the plugin is off.
|
||||
plugins:
|
||||
- active-memory
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
id: memory-dreaming-sweep
|
||||
title: Memory dreaming sweep
|
||||
surface: memory
|
||||
coverage:
|
||||
primary:
|
||||
- memory.dreaming
|
||||
objective: Verify enabling dreaming creates the managed sweep, stages light and REM artifacts, and consolidates repeated recall signals into durable memory.
|
||||
successCriteria:
|
||||
- Dreaming can be enabled and doctor.memory.status reports the managed sweep cron.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: memory-failure-fallback
|
||||
title: Memory failure fallback
|
||||
surface: memory
|
||||
coverage:
|
||||
primary:
|
||||
- memory.failure-handling
|
||||
secondary:
|
||||
- runtime.fallbacks
|
||||
objective: Verify the agent degrades gracefully when memory tools are unavailable and the answer exists only in memory-backed notes.
|
||||
successCriteria:
|
||||
- Memory tools are absent from the effective tool inventory.
|
||||
|
||||
@@ -35,9 +35,6 @@
|
||||
id: memory-recall
|
||||
title: Memory recall after context switch
|
||||
surface: memory
|
||||
coverage:
|
||||
primary:
|
||||
- memory.recall
|
||||
objective: Verify the agent can store a fact, switch topics, then recall the fact accurately later.
|
||||
successCriteria:
|
||||
- Agent acknowledges the seeded fact.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: memory-tools-channel-context
|
||||
title: Memory tools in channel context
|
||||
surface: memory
|
||||
coverage:
|
||||
primary:
|
||||
- memory.tools
|
||||
secondary:
|
||||
- channels.group-messages
|
||||
objective: Verify the agent uses memory_search and memory_get in a shared channel when the answer lives only in memory files, not the live transcript.
|
||||
successCriteria:
|
||||
- Agent uses memory_search before answering.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: session-memory-ranking
|
||||
title: Session memory ranking
|
||||
surface: memory
|
||||
coverage:
|
||||
primary:
|
||||
- memory.ranking
|
||||
secondary:
|
||||
- memory.recall
|
||||
objective: Verify session-transcript memory can outrank stale durable notes and drive the final answer toward the newer fact.
|
||||
successCriteria:
|
||||
- Session memory indexing is enabled for the scenario.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: thread-memory-isolation
|
||||
title: Thread memory isolation
|
||||
surface: memory
|
||||
coverage:
|
||||
primary:
|
||||
- memory.thread-isolation
|
||||
secondary:
|
||||
- channels.threads
|
||||
objective: Verify a memory-backed answer requested inside a thread stays in-thread and does not leak into the root channel.
|
||||
successCriteria:
|
||||
- Agent uses memory tools inside the thread.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: anthropic-opus-api-key-smoke
|
||||
title: Anthropic Opus API key smoke
|
||||
surface: model-provider
|
||||
coverage:
|
||||
primary:
|
||||
- models.provider-auth
|
||||
secondary:
|
||||
- models.anthropic
|
||||
objective: Verify the regular Anthropic Opus lane can complete a quick chat turn using API-key auth.
|
||||
successCriteria:
|
||||
- A live-frontier run fails fast unless the selected primary provider is anthropic.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: anthropic-opus-setup-token-smoke
|
||||
title: Anthropic Opus setup-token smoke
|
||||
surface: model-provider
|
||||
coverage:
|
||||
primary:
|
||||
- models.provider-auth
|
||||
secondary:
|
||||
- models.anthropic
|
||||
objective: Verify the regular Anthropic Opus lane can complete a quick chat turn using setup-token auth.
|
||||
successCriteria:
|
||||
- A live-frontier run fails fast unless the selected primary provider is anthropic.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: claude-cli-provider-capabilities-subscription
|
||||
title: Claude CLI provider capabilities subscription
|
||||
surface: model-provider
|
||||
coverage:
|
||||
primary:
|
||||
- models.provider-capabilities
|
||||
secondary:
|
||||
- models.claude-cli
|
||||
objective: Verify the Claude CLI model-provider lane can use native Claude subscription auth to talk, read an attached image, use bundled MCP tools, and apply workspace skills.
|
||||
successCriteria:
|
||||
- A live-frontier run fails fast unless the selected primary provider is claude-cli.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: claude-cli-provider-capabilities
|
||||
title: Claude CLI provider capabilities API key
|
||||
surface: model-provider
|
||||
coverage:
|
||||
primary:
|
||||
- models.provider-capabilities
|
||||
secondary:
|
||||
- models.claude-cli
|
||||
objective: Verify the Claude CLI model-provider lane can use the Anthropic API key path to talk, read an attached image, use bundled MCP tools, and apply workspace skills.
|
||||
successCriteria:
|
||||
- A live-frontier run fails fast unless the selected primary provider is claude-cli.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: codex-harness-no-meta-leak
|
||||
title: Codex harness no meta leak
|
||||
surface: dm
|
||||
coverage:
|
||||
primary:
|
||||
- models.codex-cli
|
||||
secondary:
|
||||
- runtime.no-meta-leak
|
||||
objective: Verify the Codex app-server harness keeps coordination/meta chatter out of the visible reply.
|
||||
successCriteria:
|
||||
- The scenario forces the Codex embedded harness and disables PI fallback.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: model-switch-follow-up
|
||||
title: Model switch follow-up
|
||||
surface: models
|
||||
coverage:
|
||||
primary:
|
||||
- models.switching
|
||||
secondary:
|
||||
- runtime.session-continuity
|
||||
objective: Verify the agent can switch to a different configured model and continue coherently.
|
||||
successCriteria:
|
||||
- Agent reflects the model switch request.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: model-switch-tool-continuity
|
||||
title: Model switch with tool continuity
|
||||
surface: models
|
||||
coverage:
|
||||
primary:
|
||||
- models.switching
|
||||
secondary:
|
||||
- runtime.tool-continuity
|
||||
objective: Verify switching models preserves session context and tool use instead of dropping into plain-text only behavior.
|
||||
successCriteria:
|
||||
- Alternate model is actually requested.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: bundled-plugin-skill-runtime
|
||||
title: Bundled plugin skill runtime
|
||||
surface: skills
|
||||
coverage:
|
||||
primary:
|
||||
- plugins.skills
|
||||
secondary:
|
||||
- plugins.runtime
|
||||
objective: Verify packaged bundled plugin skills load from dist-runtime instead of being skipped by path-containment checks.
|
||||
successCriteria:
|
||||
- The runtime-packaged bundled plugin tree is used as OPENCLAW_BUNDLED_PLUGINS_DIR.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: mcp-plugin-tools-call
|
||||
title: MCP plugin-tools call
|
||||
surface: mcp
|
||||
coverage:
|
||||
primary:
|
||||
- plugins.mcp-tools
|
||||
secondary:
|
||||
- tools.invocation
|
||||
objective: Verify OpenClaw can expose plugin tools over MCP and a real MCP client can call one successfully.
|
||||
successCriteria:
|
||||
- Plugin tools MCP server lists memory_search.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: skill-install-hot-availability
|
||||
title: Skill install hot availability
|
||||
surface: skills
|
||||
coverage:
|
||||
primary:
|
||||
- plugins.skills
|
||||
secondary:
|
||||
- plugins.hot-install
|
||||
objective: Verify a newly added workspace skill shows up without a broken intermediate state and can influence the next turn immediately.
|
||||
successCriteria:
|
||||
- Skill is absent before install.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: skill-visibility-invocation
|
||||
title: Skill visibility and invocation
|
||||
surface: skills
|
||||
coverage:
|
||||
primary:
|
||||
- plugins.skills
|
||||
secondary:
|
||||
- tools.invocation
|
||||
objective: Verify a workspace skill becomes visible in skills.status and influences the next agent turn.
|
||||
successCriteria:
|
||||
- skills.status reports the seeded skill as visible and eligible.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: approval-turn-tool-followthrough
|
||||
title: Approval turn tool followthrough
|
||||
surface: harness
|
||||
coverage:
|
||||
primary:
|
||||
- runtime.approvals
|
||||
secondary:
|
||||
- tools.followthrough
|
||||
objective: Verify a short approval like "ok do it" triggers immediate tool use instead of fake-progress narration.
|
||||
successCriteria:
|
||||
- Agent can keep the pre-action turn brief.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: compaction-retry-mutating-tool
|
||||
title: Compaction retry after mutating tool
|
||||
surface: runtime
|
||||
coverage:
|
||||
primary:
|
||||
- runtime.compaction
|
||||
secondary:
|
||||
- runtime.retry-policy
|
||||
objective: Verify a real mutating tool step keeps replay-unsafety explicit instead of disappearing into a clean-looking success if the run compacts or retries.
|
||||
successCriteria:
|
||||
- Agent reads the seeded large context before it writes.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: empty-response-recovery-replay-safe-read
|
||||
title: Empty-response recovery after replay-safe read
|
||||
surface: runtime
|
||||
coverage:
|
||||
primary:
|
||||
- runtime.empty-response-recovery
|
||||
secondary:
|
||||
- runtime.retry-policy
|
||||
objective: Verify an empty visible GPT turn after a replay-safe read auto-continues into a visible answer.
|
||||
successCriteria:
|
||||
- Scenario is mock-openai only so live lanes do not pick it up implicitly.
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
id: empty-response-retry-budget-exhausted
|
||||
title: Empty-response retry budget exhausted
|
||||
surface: runtime
|
||||
coverage:
|
||||
primary:
|
||||
- runtime.empty-response-recovery
|
||||
secondary:
|
||||
- runtime.retry-policy
|
||||
objective: Verify repeated empty GPT turns exhaust the retry budget after one continuation attempt.
|
||||
successCriteria:
|
||||
- Scenario is mock-openai only so live lanes do not pick it up implicitly.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user