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:
Josh Avant
2026-07-15 14:36:20 -07:00
committed by GitHub
parent 766742c674
commit 2cc04c205f
24 changed files with 1028 additions and 154 deletions
+47 -1
View File
@@ -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":[',
+14 -40
View File
@@ -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 } : {}),
};
}
+81 -17
View File
@@ -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 });
+222
View File
@@ -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",
+14 -3
View File
@@ -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",
+5 -5
View File
@@ -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 {
+1
View File
@@ -29,6 +29,7 @@ export {
type QaBusReactToMessageInput,
type QaBusReadMessageInput,
type QaBusSearchMessagesInput,
type QaBusSnapshotConversation,
type QaBusStateSnapshot,
type QaBusThread,
type QaBusWaitForInput,
+29 -34
View File
@@ -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) {
+135
View File
@@ -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({
+21 -20
View File
@@ -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",
+1
View File
@@ -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", {
+11 -3
View File
@@ -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 });
+65
View File
@@ -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);
});
});
+23 -9
View File
@@ -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",
});
}
+38 -12
View File
@@ -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>
+76 -1
View File
@@ -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('"', "&quot;")}"`,
);
});
it("renders capture startup commands without personal home paths", () => {
const html = renderQaLabUi(evidenceState({ activeTab: "capture" }));
+5 -2
View File
@@ -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");
+21 -1
View File
@@ -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,
+66 -3
View File
@@ -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[];