mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(qa-channel): bind message actions to authorized targets (#108433)
* fix(qa-channel): bind message actions to authorized targets * fix(qa-channel): close target-binding review gaps * fix(qa-lab): preserve conversation identity in snapshots * fix(qa-channel): canonicalize scoped targets * fix(qa-channel): unify target parsing * fix(qa-channel): close review gaps
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
// Qa Channel tests cover bus client plugin behavior.
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildQaTarget, getQaBusState, parseQaTarget, pollQaBus } from "./bus-client.js";
|
||||
import {
|
||||
buildQaTarget,
|
||||
getQaBusState,
|
||||
parseQaTarget,
|
||||
pollQaBus,
|
||||
resolveQaTargetThread,
|
||||
} from "./bus-client.js";
|
||||
|
||||
const guardedFetchCalls = vi.hoisted(
|
||||
() =>
|
||||
@@ -168,6 +174,46 @@ describe("qa-bus client", () => {
|
||||
).toBe("group:ops-room");
|
||||
});
|
||||
|
||||
it("parses canonical target prefixes consistently and rejects empty ids", () => {
|
||||
expect(parseQaTarget("channel:CaseSensitiveId")).toEqual({
|
||||
chatType: "channel",
|
||||
conversationId: "CaseSensitiveId",
|
||||
});
|
||||
expect(parseQaTarget("dm:Alice")).toEqual({
|
||||
chatType: "direct",
|
||||
conversationId: "Alice",
|
||||
});
|
||||
expect(parseQaTarget("thread:Room/Topic")).toEqual({
|
||||
chatType: "channel",
|
||||
conversationId: "Room",
|
||||
threadId: "Topic",
|
||||
});
|
||||
expect(parseQaTarget("plain-id", { defaultChatType: "channel" })).toEqual({
|
||||
chatType: "channel",
|
||||
conversationId: "plain-id",
|
||||
});
|
||||
for (const target of ["channel:", "group: ", "dm:", "thread:/topic", "thread:room/"]) {
|
||||
expect(() => parseQaTarget(target)).toThrow("invalid qa-channel");
|
||||
}
|
||||
for (const target of ["CHANNEL:room", "Dm:alice", "THREAD:room/topic"]) {
|
||||
expect(() => parseQaTarget(target)).toThrow("qa-channel target prefixes must be lowercase");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects conflicting embedded and explicit thread ids", () => {
|
||||
expect(resolveQaTargetThread({ target: "thread:Room/Topic", threadId: "Topic" })).toEqual({
|
||||
target: {
|
||||
chatType: "channel",
|
||||
conversationId: "Room",
|
||||
threadId: "Topic",
|
||||
},
|
||||
threadId: "Topic",
|
||||
});
|
||||
expect(() => resolveQaTargetThread({ target: "thread:Room/Topic", threadId: "Other" })).toThrow(
|
||||
"qa-channel target conflicts with the explicit threadId",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed JSON responses instead of throwing from the stream callback", async () => {
|
||||
const server = await startJsonServer(() => ({
|
||||
body: '{"cursor":1,"events":[',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { parseQaTarget, type QaTargetParts } from "openclaw/plugin-sdk/qa-channel-protocol";
|
||||
import { readByteStreamWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type {
|
||||
@@ -14,6 +15,8 @@ import type {
|
||||
QaBusToolCall,
|
||||
} from "./protocol.js";
|
||||
|
||||
export { parseQaTarget };
|
||||
|
||||
export type {
|
||||
QaBusAttachment,
|
||||
QaBusConversation,
|
||||
@@ -140,48 +143,19 @@ export function normalizeQaTarget(raw: string): string | undefined {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function parseQaTarget(raw: string): {
|
||||
chatType: "direct" | "channel" | "group";
|
||||
conversationId: string;
|
||||
threadId?: string;
|
||||
} {
|
||||
const normalized = normalizeQaTarget(raw);
|
||||
if (!normalized) {
|
||||
throw new Error("qa-channel target is required");
|
||||
}
|
||||
if (normalized.startsWith("thread:")) {
|
||||
const rest = normalized.slice("thread:".length);
|
||||
const slashIndex = rest.indexOf("/");
|
||||
if (slashIndex <= 0 || slashIndex === rest.length - 1) {
|
||||
throw new Error(`invalid qa-channel thread target: ${normalized}`);
|
||||
}
|
||||
return {
|
||||
chatType: "channel",
|
||||
conversationId: rest.slice(0, slashIndex),
|
||||
threadId: rest.slice(slashIndex + 1),
|
||||
};
|
||||
}
|
||||
if (normalized.startsWith("channel:")) {
|
||||
return {
|
||||
chatType: "channel",
|
||||
conversationId: normalized.slice("channel:".length),
|
||||
};
|
||||
}
|
||||
if (normalized.startsWith("group:")) {
|
||||
return {
|
||||
chatType: "group",
|
||||
conversationId: normalized.slice("group:".length),
|
||||
};
|
||||
}
|
||||
if (normalized.startsWith("dm:")) {
|
||||
return {
|
||||
chatType: "direct",
|
||||
conversationId: normalized.slice("dm:".length),
|
||||
};
|
||||
export function resolveQaTargetThread(params: {
|
||||
target: string;
|
||||
threadId?: string | number | null;
|
||||
}): { target: QaTargetParts; threadId?: string } {
|
||||
const target = parseQaTarget(params.target);
|
||||
const explicitThreadId = params.threadId == null ? "" : String(params.threadId).trim();
|
||||
if (target.threadId && explicitThreadId && target.threadId !== explicitThreadId) {
|
||||
throw new Error("qa-channel target conflicts with the explicit threadId");
|
||||
}
|
||||
const threadId = explicitThreadId || target.threadId;
|
||||
return {
|
||||
chatType: "direct",
|
||||
conversationId: normalized,
|
||||
target,
|
||||
...(threadId ? { threadId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
parseQaTarget,
|
||||
reactToQaBusMessage,
|
||||
readQaBusMessage,
|
||||
resolveQaTargetThread,
|
||||
searchQaBusMessages,
|
||||
sendQaBusMessage,
|
||||
type QaBusMessage,
|
||||
} from "./bus-client.js";
|
||||
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "./runtime-api.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
@@ -56,20 +58,57 @@ function readQaSendText(params: Record<string, unknown>) {
|
||||
function readQaSendTarget(params: Record<string, unknown>) {
|
||||
const explicitTo = readStringParam(params, "to");
|
||||
if (explicitTo) {
|
||||
return explicitTo;
|
||||
return buildQaTarget(parseQaTarget(explicitTo));
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId");
|
||||
if (channelId) {
|
||||
return buildQaTarget({ chatType: "channel", conversationId: channelId });
|
||||
return buildQaTarget(parseQaTarget(channelId, { defaultChatType: "channel" }));
|
||||
}
|
||||
const target = readStringParam(params, "target");
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^(dm|channel|group):|^thread:[^/]+\/.+/i.test(target)) {
|
||||
return target;
|
||||
return buildQaTarget(parseQaTarget(target, { defaultChatType: "channel" }));
|
||||
}
|
||||
|
||||
type QaMessageTarget = {
|
||||
conversationId: string;
|
||||
conversationKind: QaBusMessage["conversation"]["kind"];
|
||||
threadId: string | null;
|
||||
};
|
||||
|
||||
function readQaMessageTarget(
|
||||
params: Record<string, unknown>,
|
||||
action: ChannelMessageActionName,
|
||||
): QaMessageTarget {
|
||||
const rawTarget = readQaSendTarget(params);
|
||||
if (!rawTarget) {
|
||||
throw new Error(`qa-channel ${action} requires a target`);
|
||||
}
|
||||
const parsed = parseQaTarget(rawTarget);
|
||||
const explicitThreadId = readStringParam(params, "threadId");
|
||||
if (parsed.threadId && explicitThreadId && parsed.threadId !== explicitThreadId) {
|
||||
throw new Error(`qa-channel ${action} received conflicting thread targets`);
|
||||
}
|
||||
return {
|
||||
conversationId: parsed.conversationId,
|
||||
conversationKind: parsed.chatType,
|
||||
threadId: explicitThreadId ?? parsed.threadId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function qaMessageMatchesTarget(message: QaBusMessage, target: QaMessageTarget): boolean {
|
||||
return (
|
||||
message.conversation.id === target.conversationId &&
|
||||
message.conversation.kind === target.conversationKind &&
|
||||
(message.threadId ?? null) === target.threadId
|
||||
);
|
||||
}
|
||||
|
||||
function assertQaMessageMatchesTarget(message: QaBusMessage, target: QaMessageTarget): void {
|
||||
if (!qaMessageMatchesTarget(message, target)) {
|
||||
throw new Error("qa-channel message is not in the selected conversation");
|
||||
}
|
||||
return buildQaTarget({ chatType: "channel", conversationId: target });
|
||||
}
|
||||
|
||||
export const qaChannelMessageActions: ChannelMessageActionAdapter = {
|
||||
@@ -87,6 +126,12 @@ export const qaChannelMessageActions: ChannelMessageActionAdapter = {
|
||||
},
|
||||
},
|
||||
}),
|
||||
messageActionTargetAliases: {
|
||||
edit: {
|
||||
aliases: ["messageId"],
|
||||
deliveryTargetAliases: [],
|
||||
},
|
||||
},
|
||||
extractToolSend: ({ args }: { args: Record<string, unknown> }) => {
|
||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||
if (action === "send") {
|
||||
@@ -108,6 +153,18 @@ export const qaChannelMessageActions: ChannelMessageActionAdapter = {
|
||||
const { action, cfg, accountId, params } = context;
|
||||
const account = resolveQaChannelAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
const baseUrl = account.baseUrl;
|
||||
const readBoundMessage = async () => {
|
||||
const target = readQaMessageTarget(params, action);
|
||||
const { message } = await readQaBusMessage({
|
||||
baseUrl,
|
||||
accountId: account.accountId,
|
||||
messageId: readStringParam(params, "messageId", { required: true }),
|
||||
});
|
||||
// QA evidence must not validate a host target while the bus acts on a
|
||||
// foreign immutable message owner.
|
||||
assertQaMessageMatchesTarget(message, target);
|
||||
return message;
|
||||
};
|
||||
|
||||
switch (action) {
|
||||
case "send": {
|
||||
@@ -116,20 +173,23 @@ export const qaChannelMessageActions: ChannelMessageActionAdapter = {
|
||||
if (!to || text === undefined) {
|
||||
throw new Error("qa-channel send requires to/target and message/text");
|
||||
}
|
||||
const parsed = parseQaTarget(to);
|
||||
const threadId = readStringParam(params, "threadId") ?? parsed.threadId;
|
||||
const resolved = resolveQaTargetThread({
|
||||
target: to,
|
||||
threadId: readStringParam(params, "threadId"),
|
||||
});
|
||||
const parsed = resolved.target;
|
||||
const { message } = await sendQaBusMessage({
|
||||
baseUrl,
|
||||
accountId: account.accountId,
|
||||
to: buildQaTarget({
|
||||
chatType: parsed.chatType,
|
||||
conversationId: parsed.conversationId,
|
||||
threadId,
|
||||
threadId: resolved.threadId,
|
||||
}),
|
||||
text,
|
||||
senderId: account.botUserId,
|
||||
senderName: account.botDisplayName,
|
||||
threadId,
|
||||
threadId: resolved.threadId,
|
||||
replyToId: readStringParam(params, "replyTo") ?? readStringParam(params, "replyToId"),
|
||||
});
|
||||
return jsonResult({ message });
|
||||
@@ -181,6 +241,7 @@ export const qaChannelMessageActions: ChannelMessageActionAdapter = {
|
||||
if (!messageId || !emoji) {
|
||||
throw new Error("qa-channel react requires messageId and emoji");
|
||||
}
|
||||
await readBoundMessage();
|
||||
const { message } = await reactToQaBusMessage({
|
||||
baseUrl,
|
||||
accountId: account.accountId,
|
||||
@@ -196,11 +257,7 @@ export const qaChannelMessageActions: ChannelMessageActionAdapter = {
|
||||
if (!messageId) {
|
||||
throw new Error(`qa-channel ${action} requires messageId`);
|
||||
}
|
||||
const { message } = await readQaBusMessage({
|
||||
baseUrl,
|
||||
accountId: account.accountId,
|
||||
messageId,
|
||||
});
|
||||
const message = await readBoundMessage();
|
||||
return jsonResult({ message });
|
||||
}
|
||||
case "edit": {
|
||||
@@ -209,6 +266,7 @@ export const qaChannelMessageActions: ChannelMessageActionAdapter = {
|
||||
if (!messageId || !text) {
|
||||
throw new Error("qa-channel edit requires messageId and text");
|
||||
}
|
||||
await readBoundMessage();
|
||||
const { message } = await editQaBusMessage({
|
||||
baseUrl,
|
||||
accountId: account.accountId,
|
||||
@@ -222,6 +280,7 @@ export const qaChannelMessageActions: ChannelMessageActionAdapter = {
|
||||
if (!messageId) {
|
||||
throw new Error("qa-channel delete requires messageId");
|
||||
}
|
||||
await readBoundMessage();
|
||||
const { message } = await deleteQaBusMessage({
|
||||
baseUrl,
|
||||
accountId: account.accountId,
|
||||
@@ -231,15 +290,20 @@ export const qaChannelMessageActions: ChannelMessageActionAdapter = {
|
||||
}
|
||||
case "search": {
|
||||
const query = readStringParam(params, "query");
|
||||
const channelId = readStringParam(params, "channelId");
|
||||
const rawTarget = readQaSendTarget(params);
|
||||
const target = rawTarget ? readQaMessageTarget(params, action) : undefined;
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
if (!target && threadId) {
|
||||
throw new Error("qa-channel search requires channelId when threadId is provided");
|
||||
}
|
||||
const { messages } = await searchQaBusMessages({
|
||||
baseUrl,
|
||||
input: {
|
||||
accountId: account.accountId,
|
||||
query,
|
||||
conversationId: channelId,
|
||||
threadId,
|
||||
conversationId: target?.conversationId,
|
||||
conversationKind: target?.conversationKind,
|
||||
threadId: target ? target.threadId : undefined,
|
||||
},
|
||||
});
|
||||
return jsonResult({ messages });
|
||||
|
||||
@@ -16,6 +16,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createQaBusState, startQaBusServer } from "../../qa-lab/bus-api.js";
|
||||
import { qaChannelPlugin, setQaChannelRuntime } from "../api.js";
|
||||
import { listQaChannelAccountIds, resolveDefaultQaChannelAccountId } from "./accounts.js";
|
||||
import type { ChannelMessageActionName } from "./runtime-api.js";
|
||||
|
||||
type QaDispatchTurn = Parameters<PluginRuntime["channel"]["inbound"]["dispatchReply"]>[0];
|
||||
|
||||
@@ -39,6 +40,13 @@ describe("QA channel account resolution", () => {
|
||||
expect(listQaChannelAccountIds(cfg)).toEqual(["default", "work"]);
|
||||
expect(resolveDefaultQaChannelAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("declares edit message IDs as current-conversation resource references", () => {
|
||||
expect(qaChannelPlugin.actions?.messageActionTargetAliases?.edit).toEqual({
|
||||
aliases: ["messageId"],
|
||||
deliveryTargetAliases: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function installQaChannelTestRegistry() {
|
||||
@@ -255,6 +263,45 @@ describe("qa-channel plugin", () => {
|
||||
expect(route?.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not append routing metadata to explicit thread targets", async () => {
|
||||
const route = await qaChannelPlugin.messaging?.resolveOutboundSessionRoute?.({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
accountId: "default",
|
||||
target: "thread:qa-room/thread-1",
|
||||
replyToId: "reply-1",
|
||||
threadId: "thread-1",
|
||||
currentSessionKey: "agent:main:qa-channel:channel:thread:qa-room/thread-1:thread:stale",
|
||||
});
|
||||
|
||||
expect(route?.sessionKey).toBe("agent:main:qa-channel:channel:thread:qa-room/thread-1");
|
||||
expect(route?.baseSessionKey).toBe("agent:main:qa-channel:channel:thread:qa-room/thread-1");
|
||||
expect(route?.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects conflicting explicit thread routing metadata", () => {
|
||||
expect(() =>
|
||||
qaChannelPlugin.messaging?.resolveOutboundSessionRoute?.({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
accountId: "default",
|
||||
target: "thread:qa-room/thread-1",
|
||||
threadId: "thread-2",
|
||||
}),
|
||||
).toThrow("qa-channel target conflicts with the explicit threadId");
|
||||
});
|
||||
|
||||
it("rejects non-canonical target prefix casing before routing", () => {
|
||||
expect(() =>
|
||||
qaChannelPlugin.messaging?.resolveOutboundSessionRoute?.({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
accountId: "default",
|
||||
target: "THREAD:qa-room/thread-1",
|
||||
}),
|
||||
).toThrow("qa-channel target prefixes must be lowercase");
|
||||
});
|
||||
|
||||
it("derives group outbound session routes from explicit group targets", async () => {
|
||||
const route = await qaChannelPlugin.messaging?.resolveOutboundSessionRoute?.({
|
||||
cfg: {},
|
||||
@@ -566,6 +613,7 @@ describe("qa-channel plugin", () => {
|
||||
cfg,
|
||||
accountId: "default",
|
||||
params: {
|
||||
to: threadPayload.target,
|
||||
messageId: outbound.id,
|
||||
emoji: "white_check_mark",
|
||||
},
|
||||
@@ -577,6 +625,7 @@ describe("qa-channel plugin", () => {
|
||||
cfg,
|
||||
accountId: "default",
|
||||
params: {
|
||||
to: threadPayload.target,
|
||||
messageId: outbound.id,
|
||||
text: "message (edited)",
|
||||
},
|
||||
@@ -588,6 +637,7 @@ describe("qa-channel plugin", () => {
|
||||
cfg,
|
||||
accountId: "default",
|
||||
params: {
|
||||
to: threadPayload.target,
|
||||
messageId: outbound.id,
|
||||
},
|
||||
});
|
||||
@@ -616,6 +666,7 @@ describe("qa-channel plugin", () => {
|
||||
cfg,
|
||||
accountId: "default",
|
||||
params: {
|
||||
to: threadPayload.target,
|
||||
messageId: outbound.id,
|
||||
},
|
||||
});
|
||||
@@ -625,6 +676,163 @@ describe("qa-channel plugin", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("binds message-id actions and searches to the selected account and conversation", async () => {
|
||||
installQaChannelTestRegistry();
|
||||
const state = createQaBusState();
|
||||
const bus = await startQaBusServer({ state });
|
||||
|
||||
try {
|
||||
const cfg = {
|
||||
channels: {
|
||||
"qa-channel": {
|
||||
baseUrl: bus.baseUrl,
|
||||
accounts: {
|
||||
other: { baseUrl: bus.baseUrl },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const handleAction = requireQaActionHandler();
|
||||
const allowedRoot = state.addOutboundMessage({
|
||||
accountId: "default",
|
||||
to: "channel:allowed",
|
||||
text: "needle allowed root",
|
||||
});
|
||||
const foreignRoot = state.addOutboundMessage({
|
||||
accountId: "default",
|
||||
to: "channel:other",
|
||||
text: "needle foreign root",
|
||||
});
|
||||
const allowedThread = state.addOutboundMessage({
|
||||
accountId: "default",
|
||||
to: "thread:allowed/thread-1",
|
||||
threadId: "thread-1",
|
||||
text: "needle allowed thread",
|
||||
});
|
||||
const foreignAccount = state.addOutboundMessage({
|
||||
accountId: "other",
|
||||
to: "channel:allowed",
|
||||
text: "needle foreign account",
|
||||
});
|
||||
|
||||
const crossedActions: Array<{
|
||||
action: ChannelMessageActionName;
|
||||
params: Record<string, unknown>;
|
||||
}> = [
|
||||
{ action: "read", params: {} },
|
||||
{ action: "reactions", params: {} },
|
||||
{ action: "react", params: { emoji: "eyes" } },
|
||||
{ action: "edit", params: { text: "foreign edit" } },
|
||||
{ action: "delete", params: {} },
|
||||
];
|
||||
for (const testCase of crossedActions) {
|
||||
await expect(
|
||||
handleAction({
|
||||
channel: "qa-channel",
|
||||
action: testCase.action,
|
||||
cfg,
|
||||
accountId: "default",
|
||||
params: {
|
||||
to: "channel:allowed",
|
||||
messageId: foreignRoot.id,
|
||||
...testCase.params,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("qa-channel message is not in the selected conversation");
|
||||
}
|
||||
|
||||
await expect(
|
||||
handleAction({
|
||||
channel: "qa-channel",
|
||||
action: "read",
|
||||
cfg,
|
||||
accountId: "other",
|
||||
params: { to: "channel:allowed", messageId: allowedRoot.id },
|
||||
}),
|
||||
).rejects.toThrow("qa-bus message not found");
|
||||
await expect(
|
||||
handleAction({
|
||||
channel: "qa-channel",
|
||||
action: "read",
|
||||
cfg,
|
||||
accountId: "default",
|
||||
params: { to: "dm:allowed", messageId: allowedRoot.id },
|
||||
}),
|
||||
).rejects.toThrow("qa-channel message is not in the selected conversation");
|
||||
await expect(
|
||||
handleAction({
|
||||
channel: "qa-channel",
|
||||
action: "read",
|
||||
cfg,
|
||||
accountId: "default",
|
||||
params: { to: "channel:allowed", messageId: allowedThread.id },
|
||||
}),
|
||||
).rejects.toThrow("qa-channel message is not in the selected conversation");
|
||||
await expect(
|
||||
handleAction({
|
||||
channel: "qa-channel",
|
||||
action: "read",
|
||||
cfg,
|
||||
accountId: "default",
|
||||
params: { to: "thread:allowed/thread-2", messageId: allowedThread.id },
|
||||
}),
|
||||
).rejects.toThrow("qa-channel message is not in the selected conversation");
|
||||
|
||||
await expect(
|
||||
handleAction({
|
||||
channel: "qa-channel",
|
||||
action: "read",
|
||||
cfg,
|
||||
accountId: "default",
|
||||
params: { to: "thread:allowed/thread-1", messageId: allowedThread.id },
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
const runSearch = async (params: Record<string, unknown>, accountId = "default") => {
|
||||
const result = await handleAction({
|
||||
channel: "qa-channel",
|
||||
action: "search",
|
||||
cfg,
|
||||
accountId,
|
||||
params: { query: "needle", ...params },
|
||||
});
|
||||
return (extractToolPayload(result) as { messages: Array<{ id: string }> }).messages.map(
|
||||
(message) => message.id,
|
||||
);
|
||||
};
|
||||
|
||||
await expect(runSearch({ channelId: "allowed" })).resolves.toEqual([allowedRoot.id]);
|
||||
await expect(runSearch({ channelId: "CHANNEL:allowed" })).rejects.toThrow(
|
||||
"qa-channel target prefixes must be lowercase",
|
||||
);
|
||||
await expect(runSearch({ channelId: "thread:allowed/thread-1" })).resolves.toEqual([
|
||||
allowedThread.id,
|
||||
]);
|
||||
await expect(runSearch({ channelId: "dm:allowed" })).resolves.toEqual([]);
|
||||
await expect(runSearch({ channelId: "allowed" }, "other")).resolves.toEqual([
|
||||
foreignAccount.id,
|
||||
]);
|
||||
await expect(runSearch({})).resolves.toEqual([
|
||||
allowedRoot.id,
|
||||
foreignRoot.id,
|
||||
allowedThread.id,
|
||||
]);
|
||||
await expect(runSearch({ threadId: "thread-1" })).rejects.toThrow(
|
||||
"qa-channel search requires channelId when threadId is provided",
|
||||
);
|
||||
await expect(runSearch({ channelId: "channel:" })).rejects.toThrow(
|
||||
"invalid qa-channel channel target",
|
||||
);
|
||||
|
||||
const unchanged = state.readMessage({ accountId: "default", messageId: foreignRoot.id });
|
||||
expect(unchanged.text).toBe("needle foreign root");
|
||||
expect(unchanged.deleted).not.toBe(true);
|
||||
expect(unchanged.reactions).toEqual([]);
|
||||
} finally {
|
||||
await bus.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes the advertised send action to the qa bus", async () => {
|
||||
installQaChannelTestRegistry();
|
||||
const state = createQaBusState();
|
||||
@@ -655,6 +863,20 @@ describe("qa-channel plugin", () => {
|
||||
const payload = extractToolPayload(result) as { message: { text: string } };
|
||||
expect(payload.message.text).toBe("hello from action");
|
||||
|
||||
await expect(
|
||||
qaChannelPlugin.actions?.handleAction?.({
|
||||
channel: "qa-channel",
|
||||
action: "send",
|
||||
cfg,
|
||||
accountId: "default",
|
||||
params: {
|
||||
to: "thread:qa-room/thread-1",
|
||||
threadId: "thread-2",
|
||||
message: "conflicting thread",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("qa-channel target conflicts with the explicit threadId");
|
||||
|
||||
const outbound = await state.waitFor({
|
||||
kind: "message-text",
|
||||
direction: "outbound",
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
defineChannelMessageAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { DEFAULT_ACCOUNT_ID } from "./accounts.js";
|
||||
import { buildQaTarget, normalizeQaTarget, parseQaTarget } from "./bus-client.js";
|
||||
import {
|
||||
buildQaTarget,
|
||||
normalizeQaTarget,
|
||||
parseQaTarget,
|
||||
resolveQaTargetThread,
|
||||
} from "./bus-client.js";
|
||||
import { qaChannelMessageActions } from "./channel-actions.js";
|
||||
import { createQaChannelPluginBase, QA_CHANNEL_ID, qaChannelRuntimeMeta } from "./channel-base.js";
|
||||
import { startQaGatewayAccount } from "./gateway.js";
|
||||
@@ -73,7 +78,8 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createCh
|
||||
threadId,
|
||||
currentSessionKey,
|
||||
}) => {
|
||||
const parsed = parseQaTarget(target);
|
||||
const resolved = resolveQaTargetThread({ target, threadId });
|
||||
const parsed = resolved.target;
|
||||
const baseRoute = buildChannelOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId,
|
||||
@@ -93,10 +99,15 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createCh
|
||||
from: `${QA_CHANNEL_ID}:${accountId ?? DEFAULT_ACCOUNT_ID}`,
|
||||
to: buildQaTarget(parsed),
|
||||
});
|
||||
// An explicit thread target already owns the complete session identity;
|
||||
// applying reply or current-thread metadata would append a second thread.
|
||||
if (parsed.threadId !== undefined) {
|
||||
return baseRoute;
|
||||
}
|
||||
return buildThreadAwareOutboundSessionRoute({
|
||||
route: baseRoute,
|
||||
replyToId,
|
||||
threadId: threadId ?? (target.trim().startsWith("thread:") ? undefined : parsed.threadId),
|
||||
threadId: resolved.threadId,
|
||||
currentSessionKey,
|
||||
canRecoverCurrentThread: ({ route }) =>
|
||||
route.chatType !== "direct" || (cfg.session?.dmScope ?? "main") !== "main",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Qa Channel plugin module implements outbound behavior.
|
||||
import { resolveQaChannelAccount } from "./accounts.js";
|
||||
import { buildQaTarget, parseQaTarget, sendQaBusMessage } from "./bus-client.js";
|
||||
import { buildQaTarget, resolveQaTargetThread, sendQaBusMessage } from "./bus-client.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
export async function sendQaChannelText(params: {
|
||||
@@ -12,20 +12,20 @@ export async function sendQaChannelText(params: {
|
||||
replyToId?: string | number | null;
|
||||
}) {
|
||||
const account = resolveQaChannelAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const parsed = parseQaTarget(params.to);
|
||||
const resolvedThreadId = params.threadId == null ? parsed.threadId : String(params.threadId);
|
||||
const resolved = resolveQaTargetThread({ target: params.to, threadId: params.threadId });
|
||||
const parsed = resolved.target;
|
||||
const { message } = await sendQaBusMessage({
|
||||
baseUrl: account.baseUrl,
|
||||
accountId: account.accountId,
|
||||
to: buildQaTarget({
|
||||
chatType: parsed.chatType,
|
||||
conversationId: parsed.conversationId,
|
||||
threadId: resolvedThreadId,
|
||||
threadId: resolved.threadId,
|
||||
}),
|
||||
text: params.text,
|
||||
senderId: account.botUserId,
|
||||
senderName: account.botDisplayName,
|
||||
threadId: resolvedThreadId,
|
||||
threadId: resolved.threadId,
|
||||
replyToId: params.replyToId == null ? undefined : String(params.replyToId),
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
type QaBusReactToMessageInput,
|
||||
type QaBusReadMessageInput,
|
||||
type QaBusSearchMessagesInput,
|
||||
type QaBusSnapshotConversation,
|
||||
type QaBusStateSnapshot,
|
||||
type QaBusThread,
|
||||
type QaBusWaitForInput,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Qa Lab plugin module implements bus queries behavior.
|
||||
import { parseQaTarget } from "openclaw/plugin-sdk/qa-channel-protocol";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type {
|
||||
QaBusAttachment,
|
||||
@@ -9,6 +10,7 @@ import type {
|
||||
QaBusPollResult,
|
||||
QaBusReadMessageInput,
|
||||
QaBusSearchMessagesInput,
|
||||
QaBusSnapshotConversation,
|
||||
QaBusStateSnapshot,
|
||||
QaBusThread,
|
||||
QaBusToolCall,
|
||||
@@ -25,34 +27,10 @@ export function normalizeConversationFromTarget(target: string): {
|
||||
conversation: QaBusConversation;
|
||||
threadId?: string;
|
||||
} {
|
||||
const trimmed = target.trim();
|
||||
if (trimmed.startsWith("thread:")) {
|
||||
const rest = trimmed.slice("thread:".length);
|
||||
const slash = rest.indexOf("/");
|
||||
if (slash > 0) {
|
||||
return {
|
||||
conversation: { id: rest.slice(0, slash), kind: "channel" },
|
||||
threadId: rest.slice(slash + 1),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (trimmed.startsWith("channel:")) {
|
||||
return {
|
||||
conversation: { id: trimmed.slice("channel:".length), kind: "channel" },
|
||||
};
|
||||
}
|
||||
if (trimmed.startsWith("group:")) {
|
||||
return {
|
||||
conversation: { id: trimmed.slice("group:".length), kind: "group" },
|
||||
};
|
||||
}
|
||||
if (trimmed.startsWith("dm:")) {
|
||||
return {
|
||||
conversation: { id: trimmed.slice("dm:".length), kind: "direct" },
|
||||
};
|
||||
}
|
||||
const parsed = parseQaTarget(target);
|
||||
return {
|
||||
conversation: { id: trimmed, kind: "direct" },
|
||||
conversation: { id: parsed.conversationId, kind: parsed.chatType },
|
||||
...(parsed.threadId !== undefined ? { threadId: parsed.threadId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,7 +72,7 @@ export function cloneEvent(event: QaBusEvent): QaBusEvent {
|
||||
|
||||
export function buildQaBusSnapshot(params: {
|
||||
cursor: number;
|
||||
conversations: Map<string, QaBusConversation>;
|
||||
conversations: Map<string, QaBusSnapshotConversation>;
|
||||
threads: Map<string, QaBusThread>;
|
||||
messages: Map<string, QaBusMessage>;
|
||||
events: QaBusEvent[];
|
||||
@@ -110,14 +88,22 @@ export function buildQaBusSnapshot(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function requireQaBusMessageForAccount(params: {
|
||||
messages: Map<string, QaBusMessage>;
|
||||
input: Pick<QaBusReadMessageInput, "accountId" | "messageId">;
|
||||
}): QaBusMessage {
|
||||
const message = params.messages.get(params.input.messageId);
|
||||
if (!message || message.accountId !== normalizeAccountId(params.input.accountId)) {
|
||||
throw new Error(`qa-bus message not found: ${params.input.messageId}`);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export function readQaBusMessage(params: {
|
||||
messages: Map<string, QaBusMessage>;
|
||||
input: QaBusReadMessageInput;
|
||||
}) {
|
||||
const message = params.messages.get(params.input.messageId);
|
||||
if (!message) {
|
||||
throw new Error(`qa-bus message not found: ${params.input.messageId}`);
|
||||
}
|
||||
const message = requireQaBusMessageForAccount(params);
|
||||
return cloneMessage(message);
|
||||
}
|
||||
|
||||
@@ -131,10 +117,19 @@ export function searchQaBusMessages(params: {
|
||||
return Array.from(params.messages.values())
|
||||
.filter((message) => message.accountId === accountId)
|
||||
.filter((message) =>
|
||||
params.input.conversationId ? message.conversation.id === params.input.conversationId : true,
|
||||
params.input.conversationId !== undefined
|
||||
? message.conversation.id === params.input.conversationId
|
||||
: true,
|
||||
)
|
||||
.filter((message) =>
|
||||
params.input.threadId ? message.threadId === params.input.threadId : true,
|
||||
params.input.conversationKind
|
||||
? message.conversation.kind === params.input.conversationKind
|
||||
: true,
|
||||
)
|
||||
.filter((message) =>
|
||||
params.input.threadId !== undefined
|
||||
? (message.threadId ?? null) === params.input.threadId
|
||||
: true,
|
||||
)
|
||||
.filter((message) => {
|
||||
if (!query) {
|
||||
|
||||
@@ -4,6 +4,26 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { createQaBusState } from "./bus-state.js";
|
||||
|
||||
describe("qa-bus state", () => {
|
||||
it("roundtrips canonical target kinds and rejects non-canonical prefix casing", () => {
|
||||
const state = createQaBusState();
|
||||
const direct = state.addOutboundMessage({ to: "dm:CaseSensitive", text: "direct" });
|
||||
const channel = state.addOutboundMessage({ to: "channel:CaseSensitive", text: "channel" });
|
||||
const group = state.addOutboundMessage({ to: "group:CaseSensitive", text: "group" });
|
||||
const thread = state.addOutboundMessage({
|
||||
to: "thread:CaseSensitive/ThreadCase",
|
||||
text: "thread",
|
||||
});
|
||||
|
||||
expect(direct.conversation).toEqual({ id: "CaseSensitive", kind: "direct" });
|
||||
expect(channel.conversation).toEqual({ id: "CaseSensitive", kind: "channel" });
|
||||
expect(group.conversation).toEqual({ id: "CaseSensitive", kind: "group" });
|
||||
expect(thread.conversation).toEqual({ id: "CaseSensitive", kind: "channel" });
|
||||
expect(thread.threadId).toBe("ThreadCase");
|
||||
expect(() =>
|
||||
state.addOutboundMessage({ to: "CHANNEL:CaseSensitive", text: "invalid" }),
|
||||
).toThrow("qa-channel target prefixes must be lowercase");
|
||||
});
|
||||
|
||||
it("records inbound and outbound traffic in cursor order", () => {
|
||||
const state = createQaBusState();
|
||||
|
||||
@@ -66,6 +86,121 @@ describe("qa-bus state", () => {
|
||||
expect(typeof snapshot.messages[0]?.reactions[0]?.timestamp).toBe("number");
|
||||
});
|
||||
|
||||
it("rejects cross-account message reads and mutations", () => {
|
||||
const state = createQaBusState();
|
||||
const message = state.addOutboundMessage({
|
||||
accountId: "account-a",
|
||||
to: "channel:qa-room",
|
||||
text: "account-owned",
|
||||
});
|
||||
|
||||
expect(() => state.readMessage({ accountId: "account-b", messageId: message.id })).toThrow(
|
||||
"qa-bus message not found",
|
||||
);
|
||||
expect(() =>
|
||||
state.reactToMessage({
|
||||
accountId: "account-b",
|
||||
messageId: message.id,
|
||||
emoji: "eyes",
|
||||
}),
|
||||
).toThrow("qa-bus message not found");
|
||||
expect(() =>
|
||||
state.editMessage({
|
||||
accountId: "account-b",
|
||||
messageId: message.id,
|
||||
text: "foreign edit",
|
||||
}),
|
||||
).toThrow("qa-bus message not found");
|
||||
expect(() => state.deleteMessage({ accountId: "account-b", messageId: message.id })).toThrow(
|
||||
"qa-bus message not found",
|
||||
);
|
||||
|
||||
const unchanged = state.readMessage({ accountId: "account-a", messageId: message.id });
|
||||
expect(unchanged.text).toBe("account-owned");
|
||||
expect(unchanged.deleted).not.toBe(true);
|
||||
expect(unchanged.reactions).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps message conversation identity isolated by account and kind", () => {
|
||||
const state = createQaBusState();
|
||||
const directA = state.addInboundMessage({
|
||||
accountId: "account-a",
|
||||
conversation: { id: "shared", kind: "direct", title: "Direct A" },
|
||||
senderId: "alice",
|
||||
text: "direct a",
|
||||
});
|
||||
const channelA = state.addOutboundMessage({
|
||||
accountId: "account-a",
|
||||
to: "channel:shared",
|
||||
text: "channel a",
|
||||
});
|
||||
const directB = state.addInboundMessage({
|
||||
accountId: "account-b",
|
||||
conversation: { id: "shared", kind: "direct", title: "Direct B" },
|
||||
senderId: "bob",
|
||||
text: "direct b",
|
||||
});
|
||||
|
||||
expect(
|
||||
state.readMessage({ accountId: "account-a", messageId: directA.id }).conversation,
|
||||
).toEqual({ id: "shared", kind: "direct", title: "Direct A" });
|
||||
expect(
|
||||
state.readMessage({ accountId: "account-a", messageId: channelA.id }).conversation,
|
||||
).toEqual({ id: "shared", kind: "channel" });
|
||||
expect(
|
||||
state.readMessage({ accountId: "account-b", messageId: directB.id }).conversation,
|
||||
).toEqual({ id: "shared", kind: "direct", title: "Direct B" });
|
||||
expect(state.getSnapshot().conversations).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ accountId: "account-a", id: "shared", kind: "direct", title: "Direct A" },
|
||||
{ accountId: "account-a", id: "shared", kind: "channel" },
|
||||
{ accountId: "account-b", id: "shared", kind: "direct", title: "Direct B" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies kind and root-thread search scope before limiting results", () => {
|
||||
const state = createQaBusState();
|
||||
const root = state.addOutboundMessage({
|
||||
to: "channel:shared",
|
||||
text: "needle root",
|
||||
});
|
||||
const direct = state.addOutboundMessage({
|
||||
to: "dm:shared",
|
||||
text: "needle direct",
|
||||
});
|
||||
for (let index = 0; index < 25; index += 1) {
|
||||
state.addOutboundMessage({
|
||||
to: `thread:shared/thread-${String(index)}`,
|
||||
text: `needle thread ${String(index)}`,
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
state
|
||||
.searchMessages({
|
||||
query: "needle",
|
||||
conversationId: "shared",
|
||||
conversationKind: "channel",
|
||||
threadId: null,
|
||||
limit: 2,
|
||||
})
|
||||
.map((message) => message.id),
|
||||
).toEqual([root.id]);
|
||||
expect(
|
||||
state
|
||||
.searchMessages({
|
||||
query: "needle",
|
||||
conversationId: "shared",
|
||||
conversationKind: "direct",
|
||||
threadId: null,
|
||||
limit: 2,
|
||||
})
|
||||
.map((message) => message.id),
|
||||
).toEqual([direct.id]);
|
||||
expect(state.searchMessages({ conversationId: "", limit: 2 })).toEqual([]);
|
||||
});
|
||||
|
||||
it("waits for a text match and rejects on timeout", async () => {
|
||||
const state = createQaBusState();
|
||||
const pending = state.waitFor({
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
normalizeConversationFromTarget,
|
||||
pollQaBusEvents,
|
||||
readQaBusMessage,
|
||||
requireQaBusMessageForAccount,
|
||||
searchQaBusMessages,
|
||||
} from "./bus-queries.js";
|
||||
import { createQaBusWaiterStore } from "./bus-waiters.js";
|
||||
@@ -25,6 +26,7 @@ import type {
|
||||
QaBusReadMessageInput,
|
||||
QaBusReactToMessageInput,
|
||||
QaBusSearchMessagesInput,
|
||||
QaBusSnapshotConversation,
|
||||
QaBusStateSnapshot,
|
||||
QaBusThread,
|
||||
QaBusToolCall,
|
||||
@@ -69,7 +71,7 @@ type QaBusEventSeed =
|
||||
};
|
||||
|
||||
export function createQaBusState() {
|
||||
const conversations = new Map<string, QaBusConversation>();
|
||||
const conversations = new Map<string, QaBusSnapshotConversation>();
|
||||
const threads = new Map<string, QaBusThread>();
|
||||
const messages = new Map<string, QaBusMessage>();
|
||||
const events: QaBusEvent[] = [];
|
||||
@@ -93,16 +95,20 @@ export function createQaBusState() {
|
||||
return finalized;
|
||||
};
|
||||
|
||||
const ensureConversation = (conversation: QaBusConversation): QaBusConversation => {
|
||||
const existing = conversations.get(conversation.id);
|
||||
const ensureConversation = (
|
||||
accountId: string,
|
||||
conversation: QaBusConversation,
|
||||
): QaBusSnapshotConversation => {
|
||||
const key = JSON.stringify([accountId, conversation.kind, conversation.id]);
|
||||
const existing = conversations.get(key);
|
||||
if (existing) {
|
||||
if (!existing.title && conversation.title) {
|
||||
existing.title = conversation.title;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
const created = { ...conversation };
|
||||
conversations.set(created.id, created);
|
||||
const created = { ...conversation, accountId };
|
||||
conversations.set(key, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
@@ -121,13 +127,17 @@ export function createQaBusState() {
|
||||
nativeCommand?: QaBusInboundMessageInput["nativeCommand"];
|
||||
toolCalls?: QaBusToolCall[];
|
||||
}): QaBusMessage => {
|
||||
const conversation = ensureConversation(params.conversation);
|
||||
const storedConversation = ensureConversation(params.accountId, params.conversation);
|
||||
const toolCalls = sanitizeQaBusToolCalls(params.toolCalls);
|
||||
const message: QaBusMessage = {
|
||||
id: randomUUID(),
|
||||
accountId: params.accountId,
|
||||
direction: params.direction,
|
||||
conversation,
|
||||
conversation: {
|
||||
id: storedConversation.id,
|
||||
kind: storedConversation.kind,
|
||||
...(storedConversation.title ? { title: storedConversation.title } : {}),
|
||||
},
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
text: params.text,
|
||||
@@ -221,7 +231,7 @@ export function createQaBusState() {
|
||||
createdBy: input.createdBy?.trim() || DEFAULT_BOT_ID,
|
||||
};
|
||||
threads.set(thread.id, thread);
|
||||
ensureConversation({
|
||||
ensureConversation(accountId, {
|
||||
id: input.conversationId,
|
||||
kind: "channel",
|
||||
});
|
||||
@@ -234,10 +244,7 @@ export function createQaBusState() {
|
||||
},
|
||||
reactToMessage(input: QaBusReactToMessageInput) {
|
||||
const accountId = normalizeAccountId(input.accountId);
|
||||
const message = messages.get(input.messageId);
|
||||
if (!message) {
|
||||
throw new Error(`qa-bus message not found: ${input.messageId}`);
|
||||
}
|
||||
const message = requireQaBusMessageForAccount({ messages, input });
|
||||
const reaction = {
|
||||
emoji: input.emoji,
|
||||
senderId: input.senderId?.trim() || DEFAULT_BOT_ID,
|
||||
@@ -255,10 +262,7 @@ export function createQaBusState() {
|
||||
},
|
||||
editMessage(input: QaBusEditMessageInput) {
|
||||
const accountId = normalizeAccountId(input.accountId);
|
||||
const message = messages.get(input.messageId);
|
||||
if (!message) {
|
||||
throw new Error(`qa-bus message not found: ${input.messageId}`);
|
||||
}
|
||||
const message = requireQaBusMessageForAccount({ messages, input });
|
||||
message.text = input.text;
|
||||
message.editedAt = input.timestamp ?? Date.now();
|
||||
pushEvent({
|
||||
@@ -270,10 +274,7 @@ export function createQaBusState() {
|
||||
},
|
||||
deleteMessage(input: QaBusDeleteMessageInput) {
|
||||
const accountId = normalizeAccountId(input.accountId);
|
||||
const message = messages.get(input.messageId);
|
||||
if (!message) {
|
||||
throw new Error(`qa-bus message not found: ${input.messageId}`);
|
||||
}
|
||||
const message = requireQaBusMessageForAccount({ messages, input });
|
||||
message.deleted = true;
|
||||
pushEvent({
|
||||
kind: "message-deleted",
|
||||
|
||||
@@ -37,6 +37,7 @@ export type {
|
||||
QaBusReactToMessageInput,
|
||||
QaBusReadMessageInput,
|
||||
QaBusSearchMessagesInput,
|
||||
QaBusSnapshotConversation,
|
||||
QaBusStateSnapshot,
|
||||
QaBusThread,
|
||||
QaBusToolCall,
|
||||
|
||||
@@ -21,6 +21,7 @@ async function runLoadedScenarioFlow(
|
||||
params: {
|
||||
flow?: QaScenarioFlow;
|
||||
api?: Record<string, unknown>;
|
||||
state?: ReturnType<typeof createQaBusState>;
|
||||
omitOutboundSequence?: boolean;
|
||||
onWaitForOutboundMessage?: (params: {
|
||||
waitCount: number;
|
||||
@@ -34,7 +35,7 @@ async function runLoadedScenarioFlow(
|
||||
throw new Error(`scenario has no flow: ${scenarioId}`);
|
||||
}
|
||||
|
||||
const state = createQaBusState();
|
||||
const state = params.state ?? createQaBusState();
|
||||
let waitCount = 0;
|
||||
const transport = {
|
||||
state,
|
||||
@@ -207,6 +208,39 @@ async function runWebchatTranscriptWait(
|
||||
}
|
||||
|
||||
describe("scenario-flow-runner", () => {
|
||||
it("runs the canonical reaction lifecycle with target-bound actions", async () => {
|
||||
const state = createQaBusState();
|
||||
const actionTargets: unknown[] = [];
|
||||
const result = await runLoadedScenarioFlow("reaction-edit-delete", {
|
||||
state,
|
||||
api: {
|
||||
handleQaAction: async (params: {
|
||||
action: "delete" | "edit" | "react";
|
||||
args: Record<string, unknown>;
|
||||
}) => {
|
||||
actionTargets.push(params.args.to);
|
||||
const messageId = String(params.args.messageId);
|
||||
if (params.action === "react") {
|
||||
return state.reactToMessage({
|
||||
messageId,
|
||||
emoji: String(params.args.emoji),
|
||||
});
|
||||
}
|
||||
if (params.action === "edit") {
|
||||
return state.editMessage({
|
||||
messageId,
|
||||
text: String(params.args.text),
|
||||
});
|
||||
}
|
||||
return state.deleteMessage({ messageId });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
expect(actionTargets).toEqual(["channel:qa-room", "channel:qa-room", "channel:qa-room"]);
|
||||
});
|
||||
|
||||
it("fails when a flow calls a transport method the adapter does not implement", async () => {
|
||||
await expect(
|
||||
runLoadedScenarioFlow("channel-message-flows", {
|
||||
|
||||
@@ -6,6 +6,7 @@ export function createQaSelfCheckScenario(options?: {
|
||||
waitTimeoutMs?: number;
|
||||
}): QaScenarioDefinition {
|
||||
const waitTimeoutMs = options?.waitTimeoutMs ?? 5_000;
|
||||
let lifecycleTarget: string | undefined;
|
||||
return {
|
||||
name: "Synthetic Slack-class roundtrip",
|
||||
steps: [
|
||||
@@ -38,11 +39,12 @@ export function createQaSelfCheckScenario(options?: {
|
||||
});
|
||||
const threadPayload = extractQaToolPayload(
|
||||
threadResult as Parameters<typeof extractQaToolPayload>[0],
|
||||
) as { thread?: { id?: string } } | undefined;
|
||||
) as { target?: string; thread?: { id?: string } } | undefined;
|
||||
const threadId = threadPayload?.thread?.id;
|
||||
if (!threadId) {
|
||||
throw new Error("thread-create did not return thread id");
|
||||
if (!threadId || !threadPayload?.target) {
|
||||
throw new Error("thread-create did not return thread id and target");
|
||||
}
|
||||
lifecycleTarget = threadPayload.target;
|
||||
|
||||
await state.addInboundMessage({
|
||||
conversation: { id: "qa-room", kind: "channel", title: "QA Room" },
|
||||
@@ -76,8 +78,12 @@ export function createQaSelfCheckScenario(options?: {
|
||||
if (!outboundMessage) {
|
||||
throw new Error("threaded outbound message not found");
|
||||
}
|
||||
if (!lifecycleTarget) {
|
||||
throw new Error("thread target not found");
|
||||
}
|
||||
|
||||
await performAction("react", {
|
||||
to: lifecycleTarget,
|
||||
messageId: outboundMessage.id,
|
||||
emoji: "white_check_mark",
|
||||
});
|
||||
@@ -90,6 +96,7 @@ export function createQaSelfCheckScenario(options?: {
|
||||
}
|
||||
|
||||
await performAction("edit", {
|
||||
to: lifecycleTarget,
|
||||
messageId: outboundMessage.id,
|
||||
text: "qa-echo: inside thread (edited)",
|
||||
});
|
||||
@@ -102,6 +109,7 @@ export function createQaSelfCheckScenario(options?: {
|
||||
}
|
||||
|
||||
await performAction("delete", {
|
||||
to: lifecycleTarget,
|
||||
messageId: outboundMessage.id,
|
||||
});
|
||||
const deleted = await state.readMessage({ messageId: outboundMessage.id });
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Qa Lab tests cover self check plugin behavior.
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createQaBusState } from "./bus-state.js";
|
||||
import { createQaSelfCheckScenario } from "./self-check-scenario.js";
|
||||
import type { QaSelfCheckResult } from "./self-check.js";
|
||||
import { isQaSelfCheckSuccessful, resolveQaSelfCheckOutputPath } from "./self-check.js";
|
||||
|
||||
@@ -63,3 +65,66 @@ describe("resolveQaSelfCheckOutputPath", () => {
|
||||
expect(secondPath).not.toBe(firstPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createQaSelfCheckScenario", () => {
|
||||
it("binds lifecycle actions to the seeded message thread", async () => {
|
||||
const state = createQaBusState();
|
||||
const scenario = createQaSelfCheckScenario();
|
||||
const threadStep = scenario.steps[1];
|
||||
const lifecycleStep = scenario.steps[2];
|
||||
if (!threadStep || !lifecycleStep) {
|
||||
throw new Error("self-check thread lifecycle steps are missing");
|
||||
}
|
||||
const targets: unknown[] = [];
|
||||
const testState = {
|
||||
...state,
|
||||
addInboundMessage: (input: Parameters<typeof state.addInboundMessage>[0]) => {
|
||||
const inbound = state.addInboundMessage(input);
|
||||
if (input.text === "inside thread") {
|
||||
state.addOutboundMessage({
|
||||
to: `thread:${input.conversation.id}/${String(input.threadId)}`,
|
||||
text: "qa-echo: inside thread",
|
||||
});
|
||||
}
|
||||
return inbound;
|
||||
},
|
||||
};
|
||||
const performAction = async (action: string, args: Record<string, unknown>) => {
|
||||
if (action === "thread-create") {
|
||||
return {
|
||||
details: {
|
||||
target: "thread:qa-room/thread-1",
|
||||
thread: { id: "thread-1" },
|
||||
},
|
||||
};
|
||||
}
|
||||
targets.push(args.to);
|
||||
if (action === "react") {
|
||||
return state.reactToMessage({
|
||||
messageId: String(args.messageId),
|
||||
emoji: String(args.emoji),
|
||||
});
|
||||
}
|
||||
if (action === "edit") {
|
||||
return state.editMessage({
|
||||
messageId: String(args.messageId),
|
||||
text: String(args.text),
|
||||
});
|
||||
}
|
||||
if (action === "delete") {
|
||||
return state.deleteMessage({ messageId: String(args.messageId) });
|
||||
}
|
||||
throw new Error(`unexpected action: ${action}`);
|
||||
};
|
||||
|
||||
await threadStep.run({ state: testState, performAction });
|
||||
await lifecycleStep.run({ state: testState, performAction });
|
||||
|
||||
expect(targets).toEqual([
|
||||
"thread:qa-room/thread-1",
|
||||
"thread:qa-room/thread-1",
|
||||
"thread:qa-room/thread-1",
|
||||
]);
|
||||
expect(state.searchMessages({ query: "inside thread" }).at(-1)?.deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defaultQaModelForMode, isQaFastModeEnabled } from "../../model-selectio
|
||||
import { normalizeCaptureSavedView, normalizeCaptureSavedViews } from "./capture-saved-view.js";
|
||||
import { formatErrorMessage } from "./errors.js";
|
||||
import { getJson, getJsonNoStore, postJson } from "./http.js";
|
||||
import { conversationSelectionKey, findConversationBySelectionKey } from "./ui-conversation-key.js";
|
||||
import {
|
||||
type Bootstrap,
|
||||
type EvidenceEnvelope,
|
||||
@@ -200,7 +201,7 @@ export async function createQaLabApp(root: HTMLDivElement) {
|
||||
selectedCaptureSessionIds: [],
|
||||
selectedCaptureEventKey: null,
|
||||
selectedEvidenceEntryId: null,
|
||||
selectedConversationId: null,
|
||||
selectedConversationKey: null,
|
||||
selectedThreadId: null,
|
||||
selectedScenarioId: null,
|
||||
activeTab: initialUrl.pathname === "/evidence" || initialEvidencePath ? "evidence" : "chat",
|
||||
@@ -346,8 +347,11 @@ export async function createQaLabApp(root: HTMLDivElement) {
|
||||
};
|
||||
state.runnerDraftDirty = false;
|
||||
}
|
||||
if (!state.selectedConversationId) {
|
||||
state.selectedConversationId = snapshot.conversations[0]?.id ?? null;
|
||||
if (!state.selectedConversationKey) {
|
||||
const firstConversation = snapshot.conversations[0];
|
||||
state.selectedConversationKey = firstConversation
|
||||
? conversationSelectionKey(firstConversation)
|
||||
: null;
|
||||
}
|
||||
if (!state.selectedScenarioId) {
|
||||
state.selectedScenarioId = bootstrap.scenarios[0]?.id ?? null;
|
||||
@@ -559,7 +563,13 @@ export async function createQaLabApp(root: HTMLDivElement) {
|
||||
state.error = null;
|
||||
render();
|
||||
try {
|
||||
const selectedConversation = findConversationBySelectionKey(
|
||||
state.snapshot?.conversations ?? [],
|
||||
state.selectedConversationKey,
|
||||
);
|
||||
const accountId = selectedConversation?.accountId ?? "default";
|
||||
await postJson("/api/inbound/message", {
|
||||
accountId,
|
||||
conversation: {
|
||||
id: conversationId,
|
||||
kind: state.composer.conversationKind,
|
||||
@@ -570,7 +580,11 @@ export async function createQaLabApp(root: HTMLDivElement) {
|
||||
text,
|
||||
...(state.selectedThreadId ? { threadId: state.selectedThreadId } : {}),
|
||||
});
|
||||
state.selectedConversationId = conversationId;
|
||||
state.selectedConversationKey = conversationSelectionKey({
|
||||
accountId,
|
||||
id: conversationId,
|
||||
kind: state.composer.conversationKind,
|
||||
});
|
||||
state.composer.text = "";
|
||||
chatScrollLocked = true;
|
||||
await refresh();
|
||||
@@ -789,9 +803,9 @@ export async function createQaLabApp(root: HTMLDivElement) {
|
||||
});
|
||||
|
||||
/* Conversation chips */
|
||||
root.querySelectorAll<HTMLElement>("[data-conversation-id]").forEach((node) => {
|
||||
root.querySelectorAll<HTMLElement>("[data-conversation-key]").forEach((node) => {
|
||||
node.addEventListener("click", () => {
|
||||
state.selectedConversationId = node.dataset.conversationId ?? null;
|
||||
state.selectedConversationKey = node.dataset.conversationKey ?? null;
|
||||
state.selectedThreadId = null;
|
||||
if (state.activeTab !== "chat") {
|
||||
state.activeTab = "chat";
|
||||
@@ -808,9 +822,9 @@ export async function createQaLabApp(root: HTMLDivElement) {
|
||||
state.selectedThreadId = null;
|
||||
} else {
|
||||
state.selectedThreadId = val ?? null;
|
||||
const conv = node.dataset.threadConv;
|
||||
if (conv) {
|
||||
state.selectedConversationId = conv;
|
||||
const conversationKey = node.dataset.threadConversationKey;
|
||||
if (conversationKey) {
|
||||
state.selectedConversationKey = conversationKey;
|
||||
}
|
||||
}
|
||||
render();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { conversationSelectionKey, findConversationBySelectionKey } from "./ui-conversation-key.js";
|
||||
|
||||
describe("QA Lab conversation selection", () => {
|
||||
it("resolves the selected account without borrowing a same-id conversation", () => {
|
||||
const conversations = [
|
||||
{ accountId: "account-a", id: "shared", kind: "channel" as const },
|
||||
{ accountId: "account-b", id: "shared", kind: "channel" as const },
|
||||
{ accountId: "account-a", id: "shared", kind: "direct" as const },
|
||||
];
|
||||
|
||||
expect(
|
||||
findConversationBySelectionKey(
|
||||
conversations,
|
||||
conversationSelectionKey({ accountId: "account-b", id: "shared", kind: "channel" }),
|
||||
),
|
||||
).toEqual({ accountId: "account-b", id: "shared", kind: "channel" });
|
||||
expect(findConversationBySelectionKey(conversations, null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Conversation, Message, Thread } from "./ui-types.js";
|
||||
|
||||
type ConversationIdentity = Pick<Conversation, "accountId" | "id" | "kind">;
|
||||
|
||||
// Raw ids can collide across accounts and conversation kinds. Keep one key
|
||||
// shape for sidebar selection, transcript filtering, and thread navigation.
|
||||
export function conversationSelectionKey(identity: ConversationIdentity): string {
|
||||
return JSON.stringify([identity.accountId, identity.kind, identity.id]);
|
||||
}
|
||||
|
||||
export function findConversationBySelectionKey(
|
||||
conversations: Conversation[],
|
||||
selectionKey: string | null,
|
||||
): Conversation | undefined {
|
||||
if (!selectionKey) {
|
||||
return undefined;
|
||||
}
|
||||
return conversations.find(
|
||||
(conversation) => conversationSelectionKey(conversation) === selectionKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function messageConversationSelectionKey(message: Message): string {
|
||||
return conversationSelectionKey({
|
||||
accountId: message.accountId,
|
||||
id: message.conversation.id,
|
||||
kind: message.conversation.kind,
|
||||
});
|
||||
}
|
||||
|
||||
export function threadConversationSelectionKey(thread: Thread): string {
|
||||
// QA bus thread records come only from channel-scoped createThread; direct
|
||||
// message thread ids do not create sidebar thread records.
|
||||
return conversationSelectionKey({
|
||||
accountId: thread.accountId,
|
||||
id: thread.conversationId,
|
||||
kind: "channel",
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import {
|
||||
conversationSelectionKey,
|
||||
findConversationBySelectionKey,
|
||||
messageConversationSelectionKey,
|
||||
threadConversationSelectionKey,
|
||||
} from "./ui-conversation-key.js";
|
||||
import { findScenarioOutcome } from "./ui-render-scenario.js";
|
||||
import { badgeHtml, esc, formatIso, formatTime } from "./ui-render-utils.js";
|
||||
import type { Attachment, Message, SeedScenario, UiState } from "./ui-types.js";
|
||||
import type { Attachment, Conversation, Message, SeedScenario, UiState } from "./ui-types.js";
|
||||
|
||||
function attachmentSourceUrl(attachment: Attachment): string | null {
|
||||
if (attachment.url?.trim()) {
|
||||
@@ -53,7 +59,8 @@ function renderMessageAttachments(message: Message): string {
|
||||
}
|
||||
|
||||
function deriveSelectedConversation(state: UiState): string | null {
|
||||
return state.selectedConversationId ?? state.snapshot?.conversations[0]?.id ?? null;
|
||||
const first = state.snapshot?.conversations[0];
|
||||
return state.selectedConversationKey ?? (first ? conversationSelectionKey(first) : null);
|
||||
}
|
||||
|
||||
function deriveSelectedThread(state: UiState): string | null {
|
||||
@@ -63,7 +70,10 @@ function deriveSelectedThread(state: UiState): string | null {
|
||||
function filteredMessages(state: UiState) {
|
||||
const messages = state.snapshot?.messages ?? [];
|
||||
return messages.filter((message) => {
|
||||
if (state.selectedConversationId && message.conversation.id !== state.selectedConversationId) {
|
||||
if (
|
||||
state.selectedConversationKey &&
|
||||
messageConversationSelectionKey(message) !== state.selectedConversationKey
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (state.selectedThreadId && message.threadId !== state.selectedThreadId) {
|
||||
@@ -73,19 +83,35 @@ function filteredMessages(state: UiState) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatConversationLabel(
|
||||
conversation: Conversation,
|
||||
conversations: Conversation[],
|
||||
): string {
|
||||
const label = conversation.title || conversation.id;
|
||||
const hasAccountCollision = conversations.some(
|
||||
(candidate) =>
|
||||
candidate.accountId !== conversation.accountId &&
|
||||
candidate.kind === conversation.kind &&
|
||||
candidate.id === conversation.id,
|
||||
);
|
||||
return hasAccountCollision ? `${label} (${conversation.accountId})` : label;
|
||||
}
|
||||
|
||||
export function renderChatView(state: UiState): string {
|
||||
const conversations = state.snapshot?.conversations ?? [];
|
||||
const channels = conversations.filter((c) => c.kind === "channel");
|
||||
const dms = conversations.filter((c) => c.kind === "direct");
|
||||
const threads = (state.snapshot?.threads ?? []).filter(
|
||||
(t) => !state.selectedConversationId || t.conversationId === state.selectedConversationId,
|
||||
(thread) =>
|
||||
!state.selectedConversationKey ||
|
||||
threadConversationSelectionKey(thread) === state.selectedConversationKey,
|
||||
);
|
||||
const selectedConv = deriveSelectedConversation(state);
|
||||
const selectedThread = deriveSelectedThread(state);
|
||||
const activeConversation = conversations.find((c) => c.id === selectedConv);
|
||||
const activeConversation = findConversationBySelectionKey(conversations, selectedConv);
|
||||
const messages = filteredMessages({
|
||||
...state,
|
||||
selectedConversationId: selectedConv,
|
||||
selectedConversationKey: selectedConv,
|
||||
selectedThreadId: selectedThread,
|
||||
});
|
||||
|
||||
@@ -103,9 +129,9 @@ export function renderChatView(state: UiState): string {
|
||||
: channels
|
||||
.map(
|
||||
(c) => `
|
||||
<button class="chat-sidebar-item${c.id === selectedConv ? " active" : ""}" data-conversation-id="${esc(c.id)}">
|
||||
<button class="chat-sidebar-item${conversationSelectionKey(c) === selectedConv ? " active" : ""}" data-conversation-key="${esc(conversationSelectionKey(c))}">
|
||||
<span class="chat-sidebar-icon">#</span>
|
||||
<span class="chat-sidebar-label">${esc(c.title || c.id)}</span>
|
||||
<span class="chat-sidebar-label">${esc(formatConversationLabel(c, conversations))}</span>
|
||||
</button>`,
|
||||
)
|
||||
.join("")
|
||||
@@ -121,9 +147,9 @@ export function renderChatView(state: UiState): string {
|
||||
: dms
|
||||
.map(
|
||||
(c) => `
|
||||
<button class="chat-sidebar-item${c.id === selectedConv ? " active" : ""}" data-conversation-id="${esc(c.id)}">
|
||||
<button class="chat-sidebar-item${conversationSelectionKey(c) === selectedConv ? " active" : ""}" data-conversation-key="${esc(conversationSelectionKey(c))}">
|
||||
<span class="chat-sidebar-icon">\u25CF</span>
|
||||
<span class="chat-sidebar-label">${esc(c.title || c.id)}</span>
|
||||
<span class="chat-sidebar-label">${esc(formatConversationLabel(c, conversations))}</span>
|
||||
</button>`,
|
||||
)
|
||||
.join("")
|
||||
@@ -142,7 +168,7 @@ export function renderChatView(state: UiState): string {
|
||||
${threads
|
||||
.map(
|
||||
(t) => `
|
||||
<button class="chat-sidebar-item${t.id === selectedThread ? " active" : ""}" data-thread-select="${esc(t.id)}" data-thread-conv="${esc(t.conversationId)}">
|
||||
<button class="chat-sidebar-item${t.id === selectedThread ? " active" : ""}" data-thread-select="${esc(t.id)}" data-thread-conversation-key="${esc(threadConversationSelectionKey(t))}">
|
||||
<span class="chat-sidebar-icon">\u21B3</span>
|
||||
<span class="chat-sidebar-label">${esc(t.title)}</span>
|
||||
</button>`,
|
||||
@@ -159,7 +185,7 @@ export function renderChatView(state: UiState): string {
|
||||
<div class="chat-main">
|
||||
<!-- Channel header -->
|
||||
<div class="chat-channel-header">
|
||||
<span class="chat-channel-name">${esc(activeConversation?.title || selectedConv || "No conversation")}</span>
|
||||
<span class="chat-channel-name">${esc(activeConversation?.title || activeConversation?.id || "No conversation")}</span>
|
||||
${activeConversation ? `<span class="chat-channel-kind">${activeConversation.kind}</span>` : ""}
|
||||
${state.bootstrap?.runner.status === "running" ? '<span class="live-indicator"><span class="live-dot"></span>LIVE</span>' : ""}
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ function evidenceState(overrides: Partial<UiState> = {}): UiState {
|
||||
scenarioRun: null,
|
||||
selectedCaptureEventKey: null,
|
||||
selectedCaptureSessionIds: [],
|
||||
selectedConversationId: null,
|
||||
selectedConversationKey: null,
|
||||
selectedEvidenceEntryId: null,
|
||||
selectedScenarioId: null,
|
||||
selectedThreadId: null,
|
||||
@@ -84,6 +84,81 @@ function evidenceState(overrides: Partial<UiState> = {}): UiState {
|
||||
}
|
||||
|
||||
describe("QA Lab UI evidence render", () => {
|
||||
it("keeps same-id conversations isolated by account and kind", () => {
|
||||
const selectedConversationKey = JSON.stringify(["account-a", "channel", "shared"]);
|
||||
const html = renderQaLabUi(
|
||||
evidenceState({
|
||||
activeTab: "chat",
|
||||
selectedConversationKey,
|
||||
snapshot: {
|
||||
conversations: [
|
||||
{ accountId: "account-a", id: "shared", kind: "channel" },
|
||||
{ accountId: "account-b", id: "shared", kind: "channel" },
|
||||
{ accountId: "account-a", id: "shared", kind: "direct" },
|
||||
],
|
||||
events: [],
|
||||
messages: [
|
||||
{
|
||||
accountId: "account-a",
|
||||
conversation: { id: "shared", kind: "channel" },
|
||||
direction: "outbound",
|
||||
id: "selected-message",
|
||||
reactions: [],
|
||||
senderId: "openclaw",
|
||||
text: "selected account message",
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
accountId: "account-b",
|
||||
conversation: { id: "shared", kind: "channel" },
|
||||
direction: "outbound",
|
||||
id: "foreign-account-message",
|
||||
reactions: [],
|
||||
senderId: "openclaw",
|
||||
text: "foreign account message",
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
accountId: "account-a",
|
||||
conversation: { id: "shared", kind: "direct" },
|
||||
direction: "outbound",
|
||||
id: "foreign-kind-message",
|
||||
reactions: [],
|
||||
senderId: "openclaw",
|
||||
text: "foreign kind message",
|
||||
timestamp: 3,
|
||||
},
|
||||
],
|
||||
threads: [
|
||||
{
|
||||
accountId: "account-a",
|
||||
conversationId: "shared",
|
||||
id: "selected-thread",
|
||||
title: "Selected thread",
|
||||
},
|
||||
{
|
||||
accountId: "account-b",
|
||||
conversationId: "shared",
|
||||
id: "foreign-thread",
|
||||
title: "Foreign thread",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("selected account message");
|
||||
expect(html).toContain("Selected thread");
|
||||
expect(html).not.toContain("foreign account message");
|
||||
expect(html).not.toContain("foreign kind message");
|
||||
expect(html).not.toContain("Foreign thread");
|
||||
expect(html).toContain("shared (account-a)");
|
||||
expect(html).toContain("shared (account-b)");
|
||||
expect(html).toContain(
|
||||
`data-conversation-key="${selectedConversationKey.replaceAll('"', """)}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders capture startup commands without personal home paths", () => {
|
||||
const html = renderQaLabUi(evidenceState({ activeTab: "capture" }));
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
/* ===== Shared types (unchanged from the bus protocol) ===== */
|
||||
|
||||
export type Conversation = {
|
||||
accountId: string;
|
||||
id: string;
|
||||
kind: "direct" | "channel";
|
||||
title?: string;
|
||||
@@ -31,15 +32,17 @@ export type Attachment = {
|
||||
};
|
||||
|
||||
export type Thread = {
|
||||
accountId: string;
|
||||
id: string;
|
||||
conversationId: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
accountId: string;
|
||||
id: string;
|
||||
direction: "inbound" | "outbound";
|
||||
conversation: Conversation;
|
||||
conversation: Omit<Conversation, "accountId">;
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
text: string;
|
||||
@@ -361,7 +364,7 @@ export type UiState = {
|
||||
selectedCaptureSessionIds: string[];
|
||||
selectedCaptureEventKey: string | null;
|
||||
selectedEvidenceEntryId: string | null;
|
||||
selectedConversationId: string | null;
|
||||
selectedConversationKey: string | null;
|
||||
selectedThreadId: string | null;
|
||||
selectedScenarioId: string | null;
|
||||
activeTab: TabId;
|
||||
|
||||
@@ -46,6 +46,8 @@ flow:
|
||||
ref: env
|
||||
action: react
|
||||
args:
|
||||
to:
|
||||
expr: config.target
|
||||
messageId:
|
||||
expr: seed.id
|
||||
emoji:
|
||||
@@ -56,6 +58,8 @@ flow:
|
||||
ref: env
|
||||
action: edit
|
||||
args:
|
||||
to:
|
||||
expr: config.target
|
||||
messageId:
|
||||
expr: seed.id
|
||||
text:
|
||||
@@ -66,6 +70,8 @@ flow:
|
||||
ref: env
|
||||
action: delete
|
||||
args:
|
||||
to:
|
||||
expr: config.target
|
||||
messageId:
|
||||
expr: seed.id
|
||||
- call: state.readMessage
|
||||
|
||||
@@ -356,13 +356,17 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
},
|
||||
actions: {
|
||||
describeMessageTool: () => ({
|
||||
actions: ["pin", "list-pins", "member-info", "channel-info"],
|
||||
actions: ["pin", "list-pins", "member-info", "channel-info", "edit"],
|
||||
}),
|
||||
messageActionTargetAliases: {
|
||||
edit: { aliases: ["messageId"], deliveryTargetAliases: [] },
|
||||
},
|
||||
supportsAction: ({ action }) =>
|
||||
action === "pin" ||
|
||||
action === "list-pins" ||
|
||||
action === "member-info" ||
|
||||
action === "channel-info",
|
||||
action === "channel-info" ||
|
||||
action === "edit",
|
||||
handleAction,
|
||||
},
|
||||
};
|
||||
@@ -448,6 +452,53 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
expect(resolveAgentRuntimeIdentityToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("infers the trusted current target for resource-referenced edits", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "actionhub",
|
||||
source: "test",
|
||||
origin: "bundled",
|
||||
plugin: actionHubPlugin,
|
||||
},
|
||||
]),
|
||||
);
|
||||
await runMessageAction({
|
||||
cfg: {
|
||||
channels: {
|
||||
actionhub: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
action: "edit",
|
||||
params: {
|
||||
channel: "actionhub",
|
||||
messageId: "om_123",
|
||||
text: "updated",
|
||||
},
|
||||
toolContext: {
|
||||
currentChannelProvider: "actionhub",
|
||||
currentChannelId: "actionhub:current",
|
||||
},
|
||||
defaultAccountId: "default",
|
||||
requesterAccountId: "default",
|
||||
conversationReadOrigin: "delegated",
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
expectRecordFields(
|
||||
readRecordField(readLastPluginCall(handleAction), "params", "edit call params"),
|
||||
{
|
||||
messageId: "om_123",
|
||||
target: "actionhub:current",
|
||||
text: "updated",
|
||||
to: "actionhub:current",
|
||||
},
|
||||
"edit call params",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes execution context ids into plugin handleAction", async () => {
|
||||
const stateDir = path.join("/tmp", "openclaw-plugin-dispatch-media-roots");
|
||||
const expectedWorkspaceRoot = path.resolve(stateDir, "workspace-alpha");
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
// QA channel protocol tests cover synthetic channel payload validation and parsing.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeQaBusToolCalls } from "./qa-channel-protocol.js";
|
||||
import { parseQaTarget, sanitizeQaBusToolCalls } from "./qa-channel-protocol.js";
|
||||
|
||||
describe("qa-channel protocol", () => {
|
||||
it("parses canonical targets without folding ids or prefix casing", () => {
|
||||
expect(parseQaTarget("channel:CaseSensitive")).toEqual({
|
||||
chatType: "channel",
|
||||
conversationId: "CaseSensitive",
|
||||
});
|
||||
expect(parseQaTarget("thread:Room/Topic")).toEqual({
|
||||
chatType: "channel",
|
||||
conversationId: "Room",
|
||||
threadId: "Topic",
|
||||
});
|
||||
expect(parseQaTarget("bare-id", { defaultChatType: "group" })).toEqual({
|
||||
chatType: "group",
|
||||
conversationId: "bare-id",
|
||||
});
|
||||
expect(() => parseQaTarget("CHANNEL:CaseSensitive")).toThrow(
|
||||
"qa-channel target prefixes must be lowercase",
|
||||
);
|
||||
expect(() => parseQaTarget("thread:Room/")).toThrow("invalid qa-channel thread target");
|
||||
});
|
||||
|
||||
it("sanitizes QA bus tool-call arguments before persistence", () => {
|
||||
const toolCalls = sanitizeQaBusToolCalls([
|
||||
null,
|
||||
|
||||
@@ -4,6 +4,62 @@ import { isRecord } from "../../packages/normalization-core/src/record-coerce.js
|
||||
/** Conversation shape supported by the synthetic QA channel bus. */
|
||||
export type QaBusConversationKind = "direct" | "channel" | "group";
|
||||
|
||||
/** Parsed QA channel target with case-preserving conversation identifiers. */
|
||||
export type QaTargetParts = {
|
||||
chatType: QaBusConversationKind;
|
||||
conversationId: string;
|
||||
threadId?: string;
|
||||
};
|
||||
|
||||
/** Parse the lowercase, prefix-scoped target grammar shared by QA Channel and QA Lab. */
|
||||
export function parseQaTarget(
|
||||
raw: string,
|
||||
options?: { defaultChatType?: QaBusConversationKind },
|
||||
): QaTargetParts {
|
||||
const normalized = raw.trim();
|
||||
if (!normalized) {
|
||||
throw new Error("qa-channel target is required");
|
||||
}
|
||||
const prefixed = /^(thread|channel|group|dm):(.*)$/u.exec(normalized);
|
||||
if (!prefixed && /^(thread|channel|group|dm):/iu.test(normalized)) {
|
||||
throw new Error(`qa-channel target prefixes must be lowercase: ${normalized}`);
|
||||
}
|
||||
const prefix = prefixed?.[1];
|
||||
const rest = prefixed?.[2]?.trim();
|
||||
if (prefix === "thread") {
|
||||
if (!rest) {
|
||||
throw new Error(`invalid qa-channel thread target: ${normalized}`);
|
||||
}
|
||||
const slashIndex = rest.indexOf("/");
|
||||
if (slashIndex <= 0 || slashIndex === rest.length - 1) {
|
||||
throw new Error(`invalid qa-channel thread target: ${normalized}`);
|
||||
}
|
||||
const conversationId = rest.slice(0, slashIndex).trim();
|
||||
const threadId = rest.slice(slashIndex + 1).trim();
|
||||
if (!conversationId || !threadId) {
|
||||
throw new Error(`invalid qa-channel thread target: ${normalized}`);
|
||||
}
|
||||
return {
|
||||
chatType: "channel",
|
||||
conversationId,
|
||||
threadId,
|
||||
};
|
||||
}
|
||||
if (prefix) {
|
||||
if (!rest) {
|
||||
throw new Error(`invalid qa-channel ${prefix} target: ${normalized}`);
|
||||
}
|
||||
return {
|
||||
chatType: prefix === "dm" ? "direct" : prefix === "group" ? "group" : "channel",
|
||||
conversationId: rest,
|
||||
};
|
||||
}
|
||||
return {
|
||||
chatType: options?.defaultChatType ?? "direct",
|
||||
conversationId: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
/** Addressable conversation used by QA bus messages and thread state. */
|
||||
export type QaBusConversation = {
|
||||
id: string;
|
||||
@@ -11,6 +67,11 @@ export type QaBusConversation = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/** Account-qualified conversation record returned in QA bus snapshots. */
|
||||
export type QaBusSnapshotConversation = QaBusConversation & {
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
/** Media/file attachment fixture accepted by QA bus message APIs. */
|
||||
export type QaBusAttachment = {
|
||||
id: string;
|
||||
@@ -63,7 +124,7 @@ export type QaBusMessage = {
|
||||
}>;
|
||||
};
|
||||
|
||||
/** Synthetic thread record created inside a QA bus conversation. */
|
||||
/** Synthetic thread record created inside a QA bus channel conversation. */
|
||||
export type QaBusThread = {
|
||||
id: string;
|
||||
accountId: string;
|
||||
@@ -157,7 +218,9 @@ export type QaBusSearchMessagesInput = {
|
||||
accountId?: string;
|
||||
query?: string;
|
||||
conversationId?: string;
|
||||
threadId?: string;
|
||||
conversationKind?: QaBusConversationKind;
|
||||
/** Omit for any thread scope; use null for root-only results. */
|
||||
threadId?: string | null;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
@@ -184,7 +247,7 @@ export type QaBusPollResult = {
|
||||
/** Complete QA bus state snapshot exposed to tests and diagnostics. */
|
||||
export type QaBusStateSnapshot = {
|
||||
cursor: number;
|
||||
conversations: QaBusConversation[];
|
||||
conversations: QaBusSnapshotConversation[];
|
||||
threads: QaBusThread[];
|
||||
messages: QaBusMessage[];
|
||||
events: QaBusEvent[];
|
||||
|
||||
Reference in New Issue
Block a user