Compare commits

..

3 Commits

Author SHA1 Message Date
Vincent Koc
85b0c5aea5 fix(ci): preserve gateway watch ready detection 2026-04-17 10:37:40 -07:00
Vincent Koc
6edfb61d29 fix(ci): scope extension boundary package checks 2026-04-17 10:29:38 -07:00
Vincent Koc
eb5caaf5b9 fix(ci): slim gateway watch regression harness 2026-04-17 10:22:56 -07:00
258 changed files with 5157 additions and 8349 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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

View File

@@ -1,5 +1,4 @@
export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js";
export { buildAnthropicProvider } from "./register.runtime.js";
export {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,

View File

@@ -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);
}

View File

@@ -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({

View File

@@ -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");
});
});

View File

@@ -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");
});

View File

@@ -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,
};
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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());
});
}

View File

@@ -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,
};
}

View File

@@ -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());
});
}

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -1,7 +0,0 @@
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
} from "./src/media-contract.js";

View File

@@ -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";

View File

@@ -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",

View File

@@ -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 &&

View File

@@ -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",

View File

@@ -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;

View File

@@ -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);

View File

@@ -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")

View File

@@ -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");
});
});

View File

@@ -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`;
}

View File

@@ -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(

View File

@@ -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();
}
});
});

View File

@@ -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", () => {

View File

@@ -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(),

View File

@@ -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", () => {

View File

@@ -1 +0,0 @@
export { detectTelegramLegacyStateMigrations } from "./src/state-migrations.js";

View File

@@ -17,9 +17,6 @@
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"legacyStateMigrations": true
},
"channel": {
"id": "telegram",
"label": "Telegram",

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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 () => {

View File

@@ -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 {

View File

@@ -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();
});
});

View File

@@ -15,7 +15,6 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"install": {
"minHostVersion": ">=2026.4.10"
},

View File

@@ -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",
},
});

View File

@@ -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";

View File

@@ -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);
});
});

View File

@@ -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 {

View File

@@ -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",
);
});
});
});

View File

@@ -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,
};

View File

@@ -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";

View File

@@ -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;

View File

@@ -1,6 +0,0 @@
import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./src/session-contract.js";
export const whatsappLegacySessionSurface = {
isLegacyGroupSessionKey,
canonicalizeLegacySessionKey,
};

View File

@@ -1 +0,0 @@
export { detectWhatsAppLegacyStateMigrations } from "./src/state-migrations.js";

View File

@@ -25,10 +25,6 @@
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"legacyStateMigrations": true,
"legacySessionSurfaces": true
},
"channel": {
"id": "whatsapp",
"label": "WhatsApp",

View File

@@ -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",
},
});

View File

@@ -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();

View File

@@ -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"

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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