mirror of https://github.com/openclaw/openclaw.git
Add /btw side questions (#45444)
* feat(agent): add /btw side questions * fix(agent): gate and log /btw reviews * feat(btw): isolate side-question delivery * test(reply): update route reply runtime mocks * fix(btw): complete side-result delivery across clients * fix(gateway): handle streamed btw side results * fix(telegram): unblock btw side questions * fix(reply): make external btw replies explicit * fix(chat): keep btw side results ephemeral in internal history * fix(btw): address remaining review feedback * fix(chat): preserve btw history on mobile refresh * fix(acp): keep btw replies out of prompt history * refactor(btw): narrow side questions to live channels * fix(btw): preserve channel typing indicators * fix(btw): keep side questions isolated in chat * fix(outbound): restore typed channel send deps * fix(btw): avoid blocking replies on transcript persistence * fix(btw): keep side questions fast * docs(commands): document btw slash command * docs(changelog): add btw side questions entry * test(outbound): align session transcript mocks
This commit is contained in:
parent
b5ba2101c7
commit
9aac55d306
|
|
@ -6,7 +6,7 @@ Docs: https://docs.openclaw.ai
|
|||
|
||||
### Changes
|
||||
|
||||
- Placeholder: replace with the first 2026.3.14 user-facing change.
|
||||
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
|
||||
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
|
||||
|
||||
### Fixes
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ Text + native (when enabled):
|
|||
- `/allowlist` (list/add/remove allowlist entries)
|
||||
- `/approve <id> allow-once|allow-always|deny` (resolve exec approval prompts)
|
||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/btw <question>` (ask a quick side question about the current session without changing future session context)
|
||||
- `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
- `/session idle <duration|off>` (manage inactivity auto-unfocus for focused thread bindings)
|
||||
|
|
|
|||
|
|
@ -60,6 +60,20 @@ describe("getTelegramSequentialKey", () => {
|
|||
"telegram:123:control",
|
||||
],
|
||||
[{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/status" }) }, "telegram:123"],
|
||||
[
|
||||
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/btw what is the time?" }) },
|
||||
"telegram:123:btw:1",
|
||||
],
|
||||
[
|
||||
{
|
||||
me: { username: "openclaw_bot" } as never,
|
||||
message: mockMessage({
|
||||
chat: mockChat({ id: 123 }),
|
||||
text: "/btw@openclaw_bot what is the time?",
|
||||
}),
|
||||
},
|
||||
"telegram:123:btw:1",
|
||||
],
|
||||
[
|
||||
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop" }) },
|
||||
"telegram:123:control",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { type Message, type UserFromGetMe } from "@grammyjs/types";
|
||||
import { isAbortRequestText } from "../../../src/auto-reply/reply/abort.js";
|
||||
import { isBtwRequestText } from "../../../src/auto-reply/reply/btw-command.js";
|
||||
import { resolveTelegramForumThreadId } from "./bot/helpers.js";
|
||||
|
||||
export type TelegramSequentialKeyContext = {
|
||||
|
|
@ -41,6 +42,16 @@ export function getTelegramSequentialKey(ctx: TelegramSequentialKeyContext): str
|
|||
}
|
||||
return "telegram:control";
|
||||
}
|
||||
if (isBtwRequestText(rawText, botUsername ? { botUsername } : undefined)) {
|
||||
const messageId = msg?.message_id;
|
||||
if (typeof chatId === "number" && typeof messageId === "number") {
|
||||
return `telegram:${chatId}:btw:${messageId}`;
|
||||
}
|
||||
if (typeof chatId === "number") {
|
||||
return `telegram:${chatId}:btw`;
|
||||
}
|
||||
return "telegram:btw";
|
||||
}
|
||||
const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup";
|
||||
const messageThreadId = msg?.message_thread_id;
|
||||
const isForum = msg?.chat?.is_forum;
|
||||
|
|
|
|||
|
|
@ -308,6 +308,21 @@ describe("web processMessage inbound contract", () => {
|
|||
expect(replyOptions?.disableBlockStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("passes sendComposing through as the reply typing callback", async () => {
|
||||
const sendComposing = vi.fn(async () => undefined);
|
||||
const args = createWhatsAppDirectStreamingArgs();
|
||||
args.msg = {
|
||||
...args.msg,
|
||||
sendComposing,
|
||||
};
|
||||
|
||||
await processMessage(args);
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const dispatcherOptions = (capturedDispatchParams as any)?.dispatcherOptions;
|
||||
expect(dispatcherOptions?.onReplyStart).toBe(sendComposing);
|
||||
});
|
||||
|
||||
it("updates main last route for DM when session key matches main session key", async () => {
|
||||
const updateLastRouteMock = vi.mocked(updateLastRouteInBackground);
|
||||
updateLastRouteMock.mockClear();
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
|
|||
return { channel: "whatsapp", messageId: "" };
|
||||
}
|
||||
const send =
|
||||
resolveOutboundSendDep<typeof sendMessageWhatsApp>(deps, "whatsapp") ??
|
||||
resolveOutboundSendDep<typeof import("./send.js").sendMessageWhatsApp>(deps, "whatsapp") ??
|
||||
(await import("./send.js")).sendMessageWhatsApp;
|
||||
const result = await send(to, normalizedText, {
|
||||
verbose: false,
|
||||
|
|
@ -55,7 +55,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
|
|||
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => {
|
||||
const normalizedText = trimLeadingWhitespace(text);
|
||||
const send =
|
||||
resolveOutboundSendDep<typeof sendMessageWhatsApp>(deps, "whatsapp") ??
|
||||
resolveOutboundSendDep<typeof import("./send.js").sendMessageWhatsApp>(deps, "whatsapp") ??
|
||||
(await import("./send.js")).sendMessageWhatsApp;
|
||||
const result = await send(to, normalizedText, {
|
||||
verbose: false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,829 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
|
||||
const streamSimpleMock = vi.fn();
|
||||
const appendCustomEntryMock = vi.fn();
|
||||
const buildSessionContextMock = vi.fn();
|
||||
const getLeafEntryMock = vi.fn();
|
||||
const branchMock = vi.fn();
|
||||
const resetLeafMock = vi.fn();
|
||||
const ensureOpenClawModelsJsonMock = vi.fn();
|
||||
const discoverAuthStorageMock = vi.fn();
|
||||
const discoverModelsMock = vi.fn();
|
||||
const resolveModelWithRegistryMock = vi.fn();
|
||||
const getApiKeyForModelMock = vi.fn();
|
||||
const requireApiKeyMock = vi.fn();
|
||||
const acquireSessionWriteLockMock = vi.fn();
|
||||
const resolveSessionAuthProfileOverrideMock = vi.fn();
|
||||
const getActiveEmbeddedRunSnapshotMock = vi.fn();
|
||||
const waitForEmbeddedPiRunEndMock = vi.fn();
|
||||
const diagWarnMock = vi.fn();
|
||||
const diagDebugMock = vi.fn();
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
streamSimple: (...args: unknown[]) => streamSimpleMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
||||
SessionManager: {
|
||||
open: () => ({
|
||||
getLeafEntry: getLeafEntryMock,
|
||||
branch: branchMock,
|
||||
resetLeaf: resetLeafMock,
|
||||
buildSessionContext: buildSessionContextMock,
|
||||
appendCustomEntry: appendCustomEntryMock,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./models-config.js", () => ({
|
||||
ensureOpenClawModelsJson: (...args: unknown[]) => ensureOpenClawModelsJsonMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./pi-model-discovery.js", () => ({
|
||||
discoverAuthStorage: (...args: unknown[]) => discoverAuthStorageMock(...args),
|
||||
discoverModels: (...args: unknown[]) => discoverModelsMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./pi-embedded-runner/model.js", () => ({
|
||||
resolveModelWithRegistry: (...args: unknown[]) => resolveModelWithRegistryMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./model-auth.js", () => ({
|
||||
getApiKeyForModel: (...args: unknown[]) => getApiKeyForModelMock(...args),
|
||||
requireApiKey: (...args: unknown[]) => requireApiKeyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./session-write-lock.js", () => ({
|
||||
acquireSessionWriteLock: (...args: unknown[]) => acquireSessionWriteLockMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./pi-embedded-runner/runs.js", () => ({
|
||||
getActiveEmbeddedRunSnapshot: (...args: unknown[]) => getActiveEmbeddedRunSnapshotMock(...args),
|
||||
waitForEmbeddedPiRunEnd: (...args: unknown[]) => waitForEmbeddedPiRunEndMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./auth-profiles/session-override.js", () => ({
|
||||
resolveSessionAuthProfileOverride: (...args: unknown[]) =>
|
||||
resolveSessionAuthProfileOverrideMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../logging/diagnostic.js", () => ({
|
||||
diagnosticLogger: {
|
||||
warn: (...args: unknown[]) => diagWarnMock(...args),
|
||||
debug: (...args: unknown[]) => diagDebugMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const { BTW_CUSTOM_TYPE, runBtwSideQuestion } = await import("./btw.js");
|
||||
|
||||
function makeAsyncEvents(events: unknown[]) {
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionEntry(overrides: Partial<SessionEntry> = {}): SessionEntry {
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
sessionFile: "session-1.jsonl",
|
||||
updatedAt: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runBtwSideQuestion", () => {
|
||||
beforeEach(() => {
|
||||
streamSimpleMock.mockReset();
|
||||
appendCustomEntryMock.mockReset();
|
||||
buildSessionContextMock.mockReset();
|
||||
getLeafEntryMock.mockReset();
|
||||
branchMock.mockReset();
|
||||
resetLeafMock.mockReset();
|
||||
ensureOpenClawModelsJsonMock.mockReset();
|
||||
discoverAuthStorageMock.mockReset();
|
||||
discoverModelsMock.mockReset();
|
||||
resolveModelWithRegistryMock.mockReset();
|
||||
getApiKeyForModelMock.mockReset();
|
||||
requireApiKeyMock.mockReset();
|
||||
acquireSessionWriteLockMock.mockReset();
|
||||
resolveSessionAuthProfileOverrideMock.mockReset();
|
||||
getActiveEmbeddedRunSnapshotMock.mockReset();
|
||||
waitForEmbeddedPiRunEndMock.mockReset();
|
||||
diagWarnMock.mockReset();
|
||||
diagDebugMock.mockReset();
|
||||
|
||||
buildSessionContextMock.mockReturnValue({
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 1 }],
|
||||
});
|
||||
getLeafEntryMock.mockReturnValue(null);
|
||||
resolveModelWithRegistryMock.mockReturnValue({
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-5",
|
||||
api: "anthropic-messages",
|
||||
});
|
||||
getApiKeyForModelMock.mockResolvedValue({ apiKey: "secret", mode: "api-key", source: "test" });
|
||||
requireApiKeyMock.mockReturnValue("secret");
|
||||
acquireSessionWriteLockMock.mockResolvedValue({
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
resolveSessionAuthProfileOverrideMock.mockResolvedValue("profile-1");
|
||||
getActiveEmbeddedRunSnapshotMock.mockReturnValue(undefined);
|
||||
waitForEmbeddedPiRunEndMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("streams blocks and persists a non-context custom entry", async () => {
|
||||
const onBlockReply = vi.fn().mockResolvedValue(undefined);
|
||||
streamSimpleMock.mockReturnValue(
|
||||
makeAsyncEvents([
|
||||
{
|
||||
type: "text_delta",
|
||||
delta: "Side answer.",
|
||||
partial: {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text_end",
|
||||
content: "Side answer.",
|
||||
contentIndex: 0,
|
||||
partial: {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Side answer." }],
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-5",
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runBtwSideQuestion({
|
||||
cfg: {} as never,
|
||||
agentDir: "/tmp/agent",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
question: "What changed?",
|
||||
sessionEntry: createSessionEntry(),
|
||||
sessionStore: {},
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: "/tmp/sessions.json",
|
||||
resolvedThinkLevel: "low",
|
||||
resolvedReasoningLevel: "off",
|
||||
blockReplyChunking: {
|
||||
minChars: 1,
|
||||
maxChars: 200,
|
||||
breakPreference: "paragraph",
|
||||
},
|
||||
resolvedBlockStreamingBreak: "text_end",
|
||||
opts: { onBlockReply },
|
||||
isNewSession: false,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(onBlockReply).toHaveBeenCalledWith({
|
||||
text: "Side answer.",
|
||||
btw: { question: "What changed?" },
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(appendCustomEntryMock).toHaveBeenCalledWith(
|
||||
BTW_CUSTOM_TYPE,
|
||||
expect.objectContaining({
|
||||
question: "What changed?",
|
||||
answer: "Side answer.",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a final payload when block streaming is unavailable", async () => {
|
||||
streamSimpleMock.mockReturnValue(
|
||||
makeAsyncEvents([
|
||||
{
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Final answer." }],
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-5",
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runBtwSideQuestion({
|
||||
cfg: {} as never,
|
||||
agentDir: "/tmp/agent",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
question: "What changed?",
|
||||
sessionEntry: createSessionEntry(),
|
||||
resolvedReasoningLevel: "off",
|
||||
opts: {},
|
||||
isNewSession: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ text: "Final answer." });
|
||||
});
|
||||
|
||||
it("fails when the current branch has no messages", async () => {
|
||||
buildSessionContextMock.mockReturnValue({ messages: [] });
|
||||
streamSimpleMock.mockReturnValue(makeAsyncEvents([]));
|
||||
|
||||
await expect(
|
||||
runBtwSideQuestion({
|
||||
cfg: {} as never,
|
||||
agentDir: "/tmp/agent",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
question: "What changed?",
|
||||
sessionEntry: createSessionEntry(),
|
||||
resolvedReasoningLevel: "off",
|
||||
opts: {},
|
||||
isNewSession: false,
|
||||
}),
|
||||
).rejects.toThrow("No active session context.");
|
||||
});
|
||||
|
||||
it("uses active-run snapshot messages for BTW context while the main run is in flight", async () => {
|
||||
buildSessionContextMock.mockReturnValue({ messages: [] });
|
||||
getActiveEmbeddedRunSnapshotMock.mockReturnValue({
|
||||
transcriptLeafId: "assistant-1",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "write some things then wait 30 seconds and write more" },
|
||||
],
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
streamSimpleMock.mockReturnValue(
|
||||
makeAsyncEvents([
|
||||
{
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "323" }],
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-5",
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runBtwSideQuestion({
|
||||
cfg: {} as never,
|
||||
agentDir: "/tmp/agent",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
question: "What is 17 * 19?",
|
||||
sessionEntry: createSessionEntry(),
|
||||
resolvedReasoningLevel: "off",
|
||||
opts: {},
|
||||
isNewSession: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ text: "323" });
|
||||
expect(streamSimpleMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
systemPrompt: expect.stringContaining("ephemeral /btw side question"),
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({ role: "user" }),
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringContaining(
|
||||
"<btw_side_question>\nWhat is 17 * 19?\n</btw_side_question>",
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the in-flight prompt as background only when there is no prior transcript context", async () => {
|
||||
buildSessionContextMock.mockReturnValue({ messages: [] });
|
||||
getActiveEmbeddedRunSnapshotMock.mockReturnValue({
|
||||
transcriptLeafId: null,
|
||||
messages: [],
|
||||
inFlightPrompt: "build me a tic-tac-toe game in brainfuck",
|
||||
});
|
||||
streamSimpleMock.mockReturnValue(
|
||||
makeAsyncEvents([
|
||||
{
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "You're building a tic-tac-toe game in Brainfuck." }],
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-5",
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runBtwSideQuestion({
|
||||
cfg: {} as never,
|
||||
agentDir: "/tmp/agent",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
question: "what are we doing?",
|
||||
sessionEntry: createSessionEntry(),
|
||||
resolvedReasoningLevel: "off",
|
||||
opts: {},
|
||||
isNewSession: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ text: "You're building a tic-tac-toe game in Brainfuck." });
|
||||
expect(streamSimpleMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
messages: [
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringContaining(
|
||||
"<in_flight_main_task>\nbuild me a tic-tac-toe game in brainfuck\n</in_flight_main_task>",
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps the side question so the model does not treat it as a main-task continuation", async () => {
|
||||
streamSimpleMock.mockReturnValue(
|
||||
makeAsyncEvents([
|
||||
{
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "About 93 million miles." }],
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-5",
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await runBtwSideQuestion({
|
||||
cfg: {} as never,
|
||||
agentDir: "/tmp/agent",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
question: "what is the distance to the sun?",
|
||||
sessionEntry: createSessionEntry(),
|
||||
resolvedReasoningLevel: "off",
|
||||
opts: {},
|
||||
isNewSession: false,
|
||||
});
|
||||
|
||||
const [, context] = streamSimpleMock.mock.calls[0] ?? [];
|
||||
expect(context).toMatchObject({
|
||||
systemPrompt: expect.stringContaining(
|
||||
"Do not continue, resume, or complete any unfinished task",
|
||||
),
|
||||
});
|
||||
expect(context).toMatchObject({
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringContaining(
|
||||
"Ignore any unfinished task in the conversation while answering it.",
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("branches away from an unresolved trailing user turn before building BTW context", async () => {
|
||||
getLeafEntryMock.mockReturnValue({
|
||||
type: "message",
|
||||
parentId: "assistant-1",
|
||||
message: { role: "user" },
|
||||
});
|
||||
streamSimpleMock.mockReturnValue(
|
||||
makeAsyncEvents([
|
||||
{
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "323" }],
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-5",
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runBtwSideQuestion({
|
||||
cfg: {} as never,
|
||||
agentDir: "/tmp/agent",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
question: "What is 17 * 19?",
|
||||
sessionEntry: createSessionEntry(),
|
||||
resolvedReasoningLevel: "off",
|
||||
opts: {},
|
||||
isNewSession: false,
|
||||
});
|
||||
|
||||
expect(branchMock).toHaveBeenCalledWith("assistant-1");
|
||||
expect(resetLeafMock).not.toHaveBeenCalled();
|
||||
expect(buildSessionContextMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ text: "323" });
|
||||
});
|
||||
|
||||
it("branches to the active run snapshot leaf when the session is busy", async () => {
|
||||
getActiveEmbeddedRunSnapshotMock.mockReturnValue({
|
||||
transcriptLeafId: "assistant-seed",
|
||||
});
|
||||
streamSimpleMock.mockReturnValue(
|
||||
makeAsyncEvents([
|
||||
{
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "323" }],
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-5",
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runBtwSideQuestion({
|
||||
cfg: {} as never,
|
||||
agentDir: "/tmp/agent",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
question: "What is 17 * 19?",
|
||||
sessionEntry: createSessionEntry(),
|
||||
resolvedReasoningLevel: "off",
|
||||
opts: {},
|
||||
isNewSession: false,
|
||||
});
|
||||
|
||||
expect(branchMock).toHaveBeenCalledWith("assistant-seed");
|
||||
expect(getLeafEntryMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ text: "323" });
|
||||
});
|
||||
|
||||
it("falls back when the active run snapshot leaf no longer exists", async () => {
|
||||
getActiveEmbeddedRunSnapshotMock.mockReturnValue({
|
||||
transcriptLeafId: "assistant-gone",
|
||||
});
|
||||
branchMock.mockImplementationOnce(() => {
|
||||
throw new Error("Entry 3235c7c4 not found");
|
||||
});
|
||||
streamSimpleMock.mockReturnValue(
|
||||
makeAsyncEvents([
|
||||
{
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "323" }],
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-5",
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runBtwSideQuestion({
|
||||
cfg: {} as never,
|
||||
agentDir: "/tmp/agent",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
question: "What is 17 * 19?",
|
||||
sessionEntry: createSessionEntry(),
|
||||
resolvedReasoningLevel: "off",
|
||||
opts: {},
|
||||
isNewSession: false,
|
||||
});
|
||||
|
||||
expect(branchMock).toHaveBeenCalledWith("assistant-gone");
|
||||
expect(resetLeafMock).toHaveBeenCalled();
|
||||
expect(result).toEqual({ text: "323" });
|
||||
expect(diagDebugMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("btw snapshot leaf unavailable: sessionId=session-1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the BTW answer and retries transcript persistence after a session lock", async () => {
|
||||
acquireSessionWriteLockMock
|
||||
.mockRejectedValueOnce(
|
||||
new Error("session file locked (timeout 250ms): pid=123 /tmp/session.lock"),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
streamSimpleMock.mockReturnValue(
|
||||
makeAsyncEvents([
|
||||
{
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "323" }],
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-5",
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runBtwSideQuestion({
|
||||
cfg: {} as never,
|
||||
agentDir: "/tmp/agent",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
question: "What is 17 * 19?",
|
||||
sessionEntry: createSessionEntry(),
|
||||
resolvedReasoningLevel: "off",
|
||||
opts: {},
|
||||
isNewSession: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ text: "323" });
|
||||
await vi.waitFor(() => {
|
||||
expect(waitForEmbeddedPiRunEndMock).toHaveBeenCalledWith("session-1", 30000);
|
||||
expect(appendCustomEntryMock).toHaveBeenCalledWith(
|
||||
BTW_CUSTOM_TYPE,
|
||||
expect.objectContaining({
|
||||
question: "What is 17 * 19?",
|
||||
answer: "323",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("logs deferred persistence failures through the diagnostic logger", async () => {
|
||||
acquireSessionWriteLockMock
|
||||
.mockRejectedValueOnce(
|
||||
new Error("session file locked (timeout 250ms): pid=123 /tmp/session.lock"),
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
new Error("session file locked (timeout 10000ms): pid=123 /tmp/session.lock"),
|
||||
);
|
||||
streamSimpleMock.mockReturnValue(
|
||||
makeAsyncEvents([
|
||||
{
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "323" }],
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-5",
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runBtwSideQuestion({
|
||||
cfg: {} as never,
|
||||
agentDir: "/tmp/agent",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
question: "What is 17 * 19?",
|
||||
sessionEntry: createSessionEntry(),
|
||||
resolvedReasoningLevel: "off",
|
||||
opts: {},
|
||||
isNewSession: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ text: "323" });
|
||||
await vi.waitFor(() => {
|
||||
expect(diagWarnMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("btw transcript persistence skipped: sessionId=session-1"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes tool results from BTW context to avoid replaying raw tool output", async () => {
|
||||
getActiveEmbeddedRunSnapshotMock.mockReturnValue({
|
||||
transcriptLeafId: "assistant-1",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "seed" }],
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
content: [{ type: "text", text: "sensitive tool output" }],
|
||||
details: { raw: "secret" },
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
timestamp: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
streamSimpleMock.mockReturnValue(
|
||||
makeAsyncEvents([
|
||||
{
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "323" }],
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-5",
|
||||
stopReason: "stop",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await runBtwSideQuestion({
|
||||
cfg: {} as never,
|
||||
agentDir: "/tmp/agent",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
question: "What is 17 * 19?",
|
||||
sessionEntry: createSessionEntry(),
|
||||
resolvedReasoningLevel: "off",
|
||||
opts: {},
|
||||
isNewSession: false,
|
||||
});
|
||||
|
||||
const [, context] = streamSimpleMock.mock.calls[0] ?? [];
|
||||
expect(context).toMatchObject({
|
||||
messages: [
|
||||
expect.objectContaining({ role: "user" }),
|
||||
expect.objectContaining({ role: "assistant" }),
|
||||
expect.objectContaining({ role: "user" }),
|
||||
],
|
||||
});
|
||||
expect((context as { messages?: Array<{ role?: string }> }).messages).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ role: "toolResult" })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,513 @@
|
|||
import {
|
||||
streamSimple,
|
||||
type Api,
|
||||
type AssistantMessageEvent,
|
||||
type ThinkingLevel as SimpleThinkingLevel,
|
||||
type Message,
|
||||
type Model,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
type SessionEntry,
|
||||
} from "../config/sessions.js";
|
||||
import { diagnosticLogger as diag } from "../logging/diagnostic.js";
|
||||
import { resolveSessionAuthProfileOverride } from "./auth-profiles/session-override.js";
|
||||
import { getApiKeyForModel, requireApiKey } from "./model-auth.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
import { EmbeddedBlockChunker, type BlockReplyChunking } from "./pi-embedded-block-chunker.js";
|
||||
import { resolveModelWithRegistry } from "./pi-embedded-runner/model.js";
|
||||
import {
|
||||
getActiveEmbeddedRunSnapshot,
|
||||
waitForEmbeddedPiRunEnd,
|
||||
} from "./pi-embedded-runner/runs.js";
|
||||
import { mapThinkingLevel } from "./pi-embedded-runner/utils.js";
|
||||
import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js";
|
||||
import { stripToolResultDetails } from "./session-transcript-repair.js";
|
||||
import { acquireSessionWriteLock } from "./session-write-lock.js";
|
||||
|
||||
const BTW_CUSTOM_TYPE = "openclaw:btw";
|
||||
const BTW_PERSIST_TIMEOUT_MS = 250;
|
||||
const BTW_PERSIST_RETRY_WAIT_MS = 30_000;
|
||||
const BTW_PERSIST_RETRY_LOCK_MS = 10_000;
|
||||
|
||||
type SessionManagerLike = {
|
||||
getLeafEntry?: () => {
|
||||
id?: string;
|
||||
type?: string;
|
||||
parentId?: string | null;
|
||||
message?: { role?: string };
|
||||
} | null;
|
||||
branch?: (parentId: string) => void;
|
||||
resetLeaf?: () => void;
|
||||
buildSessionContext: () => { messages?: unknown[] };
|
||||
};
|
||||
|
||||
type BtwCustomEntryData = {
|
||||
timestamp: number;
|
||||
question: string;
|
||||
answer: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingLevel: ThinkLevel | "off";
|
||||
reasoningLevel: ReasoningLevel;
|
||||
sessionKey?: string;
|
||||
authProfileId?: string;
|
||||
authProfileIdSource?: "auto" | "user";
|
||||
usage?: unknown;
|
||||
};
|
||||
|
||||
async function appendBtwCustomEntry(params: {
|
||||
sessionFile: string;
|
||||
timeoutMs: number;
|
||||
entry: BtwCustomEntryData;
|
||||
}) {
|
||||
const lock = await acquireSessionWriteLock({
|
||||
sessionFile: params.sessionFile,
|
||||
timeoutMs: params.timeoutMs,
|
||||
allowReentrant: false,
|
||||
});
|
||||
try {
|
||||
const persisted = SessionManager.open(params.sessionFile);
|
||||
persisted.appendCustomEntry(BTW_CUSTOM_TYPE, params.entry);
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
function isSessionLockError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes("session file locked");
|
||||
}
|
||||
|
||||
function deferBtwCustomEntryPersist(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
entry: BtwCustomEntryData;
|
||||
}) {
|
||||
void (async () => {
|
||||
try {
|
||||
await waitForEmbeddedPiRunEnd(params.sessionId, BTW_PERSIST_RETRY_WAIT_MS);
|
||||
await appendBtwCustomEntry({
|
||||
sessionFile: params.sessionFile,
|
||||
timeoutMs: BTW_PERSIST_RETRY_LOCK_MS,
|
||||
entry: params.entry,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
diag.warn(`btw transcript persistence skipped: sessionId=${params.sessionId} err=${message}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function persistBtwCustomEntry(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
entry: BtwCustomEntryData;
|
||||
}) {
|
||||
try {
|
||||
await appendBtwCustomEntry({
|
||||
sessionFile: params.sessionFile,
|
||||
timeoutMs: BTW_PERSIST_TIMEOUT_MS,
|
||||
entry: params.entry,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isSessionLockError(error)) {
|
||||
throw error;
|
||||
}
|
||||
deferBtwCustomEntryPersist({
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
entry: params.entry,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function persistBtwCustomEntryInBackground(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
entry: BtwCustomEntryData;
|
||||
}) {
|
||||
void persistBtwCustomEntry(params).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
diag.warn(`btw transcript persistence skipped: sessionId=${params.sessionId} err=${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
function collectTextContent(content: Array<{ type?: string; text?: string }>): string {
|
||||
return content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function collectThinkingContent(content: Array<{ type?: string; thinking?: string }>): string {
|
||||
return content
|
||||
.filter((part): part is { type: "thinking"; thinking: string } => part.type === "thinking")
|
||||
.map((part) => part.thinking)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function buildBtwSystemPrompt(): string {
|
||||
return [
|
||||
"You are answering an ephemeral /btw side question about the current conversation.",
|
||||
"Use the conversation only as background context.",
|
||||
"Answer only the side question in the last user message.",
|
||||
"Do not continue, resume, or complete any unfinished task from the conversation.",
|
||||
"Do not emit tool calls, pseudo-tool calls, shell commands, file writes, patches, or code unless the side question explicitly asks for them.",
|
||||
"Do not say you will continue the main task after answering.",
|
||||
"If the question can be answered briefly, answer briefly.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildBtwQuestionPrompt(question: string, inFlightPrompt?: string): string {
|
||||
const lines = [
|
||||
"Answer this side question only.",
|
||||
"Ignore any unfinished task in the conversation while answering it.",
|
||||
];
|
||||
const trimmedPrompt = inFlightPrompt?.trim();
|
||||
if (trimmedPrompt) {
|
||||
lines.push(
|
||||
"",
|
||||
"Current in-flight main task request for background context only:",
|
||||
"<in_flight_main_task>",
|
||||
trimmedPrompt,
|
||||
"</in_flight_main_task>",
|
||||
"Do not continue or complete that task while answering the side question.",
|
||||
);
|
||||
}
|
||||
lines.push("", "<btw_side_question>", question.trim(), "</btw_side_question>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function toSimpleContextMessages(messages: unknown[]): Message[] {
|
||||
const contextMessages = messages.filter((message): message is Message => {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false;
|
||||
}
|
||||
const role = (message as { role?: unknown }).role;
|
||||
return role === "user" || role === "assistant";
|
||||
});
|
||||
return stripToolResultDetails(
|
||||
contextMessages as Parameters<typeof stripToolResultDetails>[0],
|
||||
) as Message[];
|
||||
}
|
||||
|
||||
function resolveSimpleThinkingLevel(level?: ThinkLevel): SimpleThinkingLevel | undefined {
|
||||
if (!level || level === "off") {
|
||||
return undefined;
|
||||
}
|
||||
return mapThinkingLevel(level) as SimpleThinkingLevel;
|
||||
}
|
||||
|
||||
function resolveSessionTranscriptPath(params: {
|
||||
sessionId: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
}): string | undefined {
|
||||
try {
|
||||
const agentId = params.sessionKey?.split(":")[1];
|
||||
const pathOpts = resolveSessionFilePathOptions({
|
||||
agentId,
|
||||
storePath: params.storePath,
|
||||
});
|
||||
return resolveSessionFilePath(params.sessionId, params.sessionEntry, pathOpts);
|
||||
} catch (error) {
|
||||
diag.debug(
|
||||
`resolveSessionTranscriptPath failed: sessionId=${params.sessionId} err=${String(error)}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRuntimeModel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
provider: string;
|
||||
model: string;
|
||||
agentDir: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
isNewSession: boolean;
|
||||
}): Promise<{
|
||||
model: Model<Api>;
|
||||
authProfileId?: string;
|
||||
authProfileIdSource?: "auto" | "user";
|
||||
}> {
|
||||
await ensureOpenClawModelsJson(params.cfg, params.agentDir);
|
||||
const authStorage = discoverAuthStorage(params.agentDir);
|
||||
const modelRegistry = discoverModels(authStorage, params.agentDir);
|
||||
const model = resolveModelWithRegistry({
|
||||
provider: params.provider,
|
||||
modelId: params.model,
|
||||
modelRegistry,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
if (!model) {
|
||||
throw new Error(`Unknown model: ${params.provider}/${params.model}`);
|
||||
}
|
||||
|
||||
const authProfileId = await resolveSessionAuthProfileOverride({
|
||||
cfg: params.cfg,
|
||||
provider: params.provider,
|
||||
agentDir: params.agentDir,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionStore: params.sessionStore,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
isNewSession: params.isNewSession,
|
||||
});
|
||||
return {
|
||||
model,
|
||||
authProfileId,
|
||||
authProfileIdSource: params.sessionEntry?.authProfileOverrideSource,
|
||||
};
|
||||
}
|
||||
|
||||
type RunBtwSideQuestionParams = {
|
||||
cfg: OpenClawConfig;
|
||||
agentDir: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
question: string;
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
resolvedThinkLevel?: ThinkLevel;
|
||||
resolvedReasoningLevel: ReasoningLevel;
|
||||
blockReplyChunking?: BlockReplyChunking;
|
||||
resolvedBlockStreamingBreak?: "text_end" | "message_end";
|
||||
opts?: GetReplyOptions;
|
||||
isNewSession: boolean;
|
||||
};
|
||||
|
||||
export async function runBtwSideQuestion(
|
||||
params: RunBtwSideQuestionParams,
|
||||
): Promise<ReplyPayload | undefined> {
|
||||
const sessionId = params.sessionEntry.sessionId?.trim();
|
||||
if (!sessionId) {
|
||||
throw new Error("No active session context.");
|
||||
}
|
||||
|
||||
const sessionFile = resolveSessionTranscriptPath({
|
||||
sessionId,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
});
|
||||
if (!sessionFile) {
|
||||
throw new Error("No active session transcript.");
|
||||
}
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile) as SessionManagerLike;
|
||||
const activeRunSnapshot = getActiveEmbeddedRunSnapshot(sessionId);
|
||||
let messages: Message[] = [];
|
||||
let inFlightPrompt: string | undefined;
|
||||
if (Array.isArray(activeRunSnapshot?.messages) && activeRunSnapshot.messages.length > 0) {
|
||||
messages = toSimpleContextMessages(activeRunSnapshot.messages);
|
||||
inFlightPrompt = activeRunSnapshot.inFlightPrompt;
|
||||
} else if (activeRunSnapshot) {
|
||||
inFlightPrompt = activeRunSnapshot.inFlightPrompt;
|
||||
if (activeRunSnapshot.transcriptLeafId && sessionManager.branch) {
|
||||
try {
|
||||
sessionManager.branch(activeRunSnapshot.transcriptLeafId);
|
||||
} catch (error) {
|
||||
diag.debug(
|
||||
`btw snapshot leaf unavailable: sessionId=${sessionId} leaf=${activeRunSnapshot.transcriptLeafId} err=${String(error)}`,
|
||||
);
|
||||
sessionManager.resetLeaf?.();
|
||||
}
|
||||
} else {
|
||||
sessionManager.resetLeaf?.();
|
||||
}
|
||||
} else {
|
||||
const leafEntry = sessionManager.getLeafEntry?.();
|
||||
if (leafEntry?.type === "message" && leafEntry.message?.role === "user") {
|
||||
if (leafEntry.parentId && sessionManager.branch) {
|
||||
sessionManager.branch(leafEntry.parentId);
|
||||
} else {
|
||||
sessionManager.resetLeaf?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
const sessionContext = sessionManager.buildSessionContext();
|
||||
messages = toSimpleContextMessages(
|
||||
Array.isArray(sessionContext.messages) ? sessionContext.messages : [],
|
||||
);
|
||||
}
|
||||
if (messages.length === 0 && !inFlightPrompt?.trim()) {
|
||||
throw new Error("No active session context.");
|
||||
}
|
||||
|
||||
const { model, authProfileId, authProfileIdSource } = await resolveRuntimeModel({
|
||||
cfg: params.cfg,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
agentDir: params.agentDir,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionStore: params.sessionStore,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
isNewSession: params.isNewSession,
|
||||
});
|
||||
const apiKeyInfo = await getApiKeyForModel({
|
||||
model,
|
||||
cfg: params.cfg,
|
||||
profileId: authProfileId,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
const apiKey = requireApiKey(apiKeyInfo, model.provider);
|
||||
|
||||
const chunker =
|
||||
params.opts?.onBlockReply && params.blockReplyChunking
|
||||
? new EmbeddedBlockChunker(params.blockReplyChunking)
|
||||
: undefined;
|
||||
let emittedBlocks = 0;
|
||||
let blockEmitChain: Promise<void> = Promise.resolve();
|
||||
let answerText = "";
|
||||
let reasoningText = "";
|
||||
let assistantStarted = false;
|
||||
let sawTextEvent = false;
|
||||
|
||||
const emitBlockChunk = async (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || !params.opts?.onBlockReply) {
|
||||
return;
|
||||
}
|
||||
emittedBlocks += 1;
|
||||
blockEmitChain = blockEmitChain.then(async () => {
|
||||
await params.opts?.onBlockReply?.({
|
||||
text,
|
||||
btw: { question: params.question },
|
||||
});
|
||||
});
|
||||
await blockEmitChain;
|
||||
};
|
||||
|
||||
const stream = streamSimple(
|
||||
model,
|
||||
{
|
||||
systemPrompt: buildBtwSystemPrompt(),
|
||||
messages: [
|
||||
...messages,
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: buildBtwQuestionPrompt(params.question, inFlightPrompt),
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiKey,
|
||||
reasoning: resolveSimpleThinkingLevel(params.resolvedThinkLevel),
|
||||
signal: params.opts?.abortSignal,
|
||||
},
|
||||
);
|
||||
|
||||
let finalEvent:
|
||||
| Extract<AssistantMessageEvent, { type: "done" }>
|
||||
| Extract<AssistantMessageEvent, { type: "error" }>
|
||||
| undefined;
|
||||
|
||||
for await (const event of stream) {
|
||||
finalEvent = event.type === "done" || event.type === "error" ? event : finalEvent;
|
||||
|
||||
if (!assistantStarted && (event.type === "text_start" || event.type === "start")) {
|
||||
assistantStarted = true;
|
||||
await params.opts?.onAssistantMessageStart?.();
|
||||
}
|
||||
|
||||
if (event.type === "text_delta") {
|
||||
sawTextEvent = true;
|
||||
answerText += event.delta;
|
||||
chunker?.append(event.delta);
|
||||
if (chunker && params.resolvedBlockStreamingBreak === "text_end") {
|
||||
chunker.drain({ force: false, emit: (chunk) => void emitBlockChunk(chunk) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === "text_end" && chunker && params.resolvedBlockStreamingBreak === "text_end") {
|
||||
chunker.drain({ force: true, emit: (chunk) => void emitBlockChunk(chunk) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === "thinking_delta") {
|
||||
reasoningText += event.delta;
|
||||
if (params.resolvedReasoningLevel !== "off") {
|
||||
await params.opts?.onReasoningStream?.({ text: reasoningText, isReasoning: true });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === "thinking_end" && params.resolvedReasoningLevel !== "off") {
|
||||
await params.opts?.onReasoningEnd?.();
|
||||
}
|
||||
}
|
||||
|
||||
if (chunker && params.resolvedBlockStreamingBreak !== "text_end" && chunker.hasBuffered()) {
|
||||
chunker.drain({ force: true, emit: (chunk) => void emitBlockChunk(chunk) });
|
||||
}
|
||||
await blockEmitChain;
|
||||
|
||||
if (finalEvent?.type === "error") {
|
||||
const message = collectTextContent(finalEvent.error.content);
|
||||
throw new Error(message || finalEvent.error.errorMessage || "BTW failed.");
|
||||
}
|
||||
|
||||
const finalMessage = finalEvent?.type === "done" ? finalEvent.message : undefined;
|
||||
if (finalMessage) {
|
||||
if (!sawTextEvent) {
|
||||
answerText = collectTextContent(finalMessage.content);
|
||||
}
|
||||
if (!reasoningText) {
|
||||
reasoningText = collectThinkingContent(finalMessage.content);
|
||||
}
|
||||
}
|
||||
|
||||
const answer = answerText.trim();
|
||||
if (!answer) {
|
||||
throw new Error("No BTW response generated.");
|
||||
}
|
||||
|
||||
const customEntry = {
|
||||
timestamp: Date.now(),
|
||||
question: params.question,
|
||||
answer,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
thinkingLevel: params.resolvedThinkLevel ?? "off",
|
||||
reasoningLevel: params.resolvedReasoningLevel,
|
||||
sessionKey: params.sessionKey,
|
||||
authProfileId,
|
||||
authProfileIdSource,
|
||||
usage: finalMessage?.usage,
|
||||
} satisfies BtwCustomEntryData;
|
||||
|
||||
persistBtwCustomEntryInBackground({
|
||||
sessionId,
|
||||
sessionFile,
|
||||
entry: customEntry,
|
||||
});
|
||||
|
||||
if (emittedBlocks > 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { text: answer };
|
||||
}
|
||||
|
||||
export { BTW_CUSTOM_TYPE };
|
||||
|
|
@ -111,6 +111,7 @@ import {
|
|||
clearActiveEmbeddedRun,
|
||||
type EmbeddedPiQueueHandle,
|
||||
setActiveEmbeddedRun,
|
||||
updateActiveEmbeddedRunSnapshot,
|
||||
} from "../runs.js";
|
||||
import { buildEmbeddedSandboxInfo } from "../sandbox-info.js";
|
||||
import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manager-cache.js";
|
||||
|
|
@ -830,6 +831,7 @@ function extractBalancedJsonPrefix(raw: string): string | null {
|
|||
const MAX_TOOLCALL_REPAIR_BUFFER_CHARS = 64_000;
|
||||
const MAX_TOOLCALL_REPAIR_TRAILING_CHARS = 3;
|
||||
const TOOLCALL_REPAIR_ALLOWED_TRAILING_RE = /^[^\s{}[\]":,\\]{1,3}$/;
|
||||
const MAX_BTW_SNAPSHOT_MESSAGES = 100;
|
||||
|
||||
function shouldAttemptMalformedToolCallRepair(partialJson: string, delta: string): boolean {
|
||||
if (/[}\]]/.test(delta)) {
|
||||
|
|
@ -2376,6 +2378,8 @@ export async function runEmbeddedAttempt(
|
|||
`runId=${params.runId} sessionId=${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
const transcriptLeafId =
|
||||
(sessionManager.getLeafEntry() as { id?: string } | null | undefined)?.id ?? null;
|
||||
|
||||
try {
|
||||
// Idempotent cleanup for legacy sessions with persisted image payloads.
|
||||
|
|
@ -2454,6 +2458,13 @@ export async function runEmbeddedAttempt(
|
|||
});
|
||||
}
|
||||
|
||||
const btwSnapshotMessages = activeSession.messages.slice(-MAX_BTW_SNAPSHOT_MESSAGES);
|
||||
updateActiveEmbeddedRunSnapshot(params.sessionId, {
|
||||
transcriptLeafId,
|
||||
messages: btwSnapshotMessages,
|
||||
inFlightPrompt: effectivePrompt,
|
||||
});
|
||||
|
||||
// Only pass images option if there are actually images to pass
|
||||
// This avoids potential issues with models that don't expect the images parameter
|
||||
if (imageResult.images.length > 0) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import {
|
|||
__testing,
|
||||
abortEmbeddedPiRun,
|
||||
clearActiveEmbeddedRun,
|
||||
getActiveEmbeddedRunSnapshot,
|
||||
setActiveEmbeddedRun,
|
||||
updateActiveEmbeddedRunSnapshot,
|
||||
waitForActiveEmbeddedRuns,
|
||||
} from "./runs.js";
|
||||
|
||||
|
|
@ -137,4 +139,28 @@ describe("pi-embedded runner run registry", () => {
|
|||
runsB.__testing.resetActiveEmbeddedRuns();
|
||||
}
|
||||
});
|
||||
|
||||
it("tracks and clears per-session transcript snapshots for active runs", () => {
|
||||
const handle = {
|
||||
queueMessage: async () => {},
|
||||
isStreaming: () => true,
|
||||
isCompacting: () => false,
|
||||
abort: vi.fn(),
|
||||
};
|
||||
|
||||
setActiveEmbeddedRun("session-snapshot", handle);
|
||||
updateActiveEmbeddedRunSnapshot("session-snapshot", {
|
||||
transcriptLeafId: "assistant-1",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: 1 }],
|
||||
inFlightPrompt: "keep going",
|
||||
});
|
||||
expect(getActiveEmbeddedRunSnapshot("session-snapshot")).toEqual({
|
||||
transcriptLeafId: "assistant-1",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: 1 }],
|
||||
inFlightPrompt: "keep going",
|
||||
});
|
||||
|
||||
clearActiveEmbeddedRun("session-snapshot", handle);
|
||||
expect(getActiveEmbeddedRunSnapshot("session-snapshot")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ type EmbeddedPiQueueHandle = {
|
|||
abort: () => void;
|
||||
};
|
||||
|
||||
export type ActiveEmbeddedRunSnapshot = {
|
||||
transcriptLeafId: string | null;
|
||||
messages?: unknown[];
|
||||
inFlightPrompt?: string;
|
||||
};
|
||||
|
||||
type EmbeddedRunWaiter = {
|
||||
resolve: (ended: boolean) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
|
|
@ -25,9 +31,11 @@ const EMBEDDED_RUN_STATE_KEY = Symbol.for("openclaw.embeddedRunState");
|
|||
|
||||
const embeddedRunState = resolveGlobalSingleton(EMBEDDED_RUN_STATE_KEY, () => ({
|
||||
activeRuns: new Map<string, EmbeddedPiQueueHandle>(),
|
||||
snapshots: new Map<string, ActiveEmbeddedRunSnapshot>(),
|
||||
waiters: new Map<string, Set<EmbeddedRunWaiter>>(),
|
||||
}));
|
||||
const ACTIVE_EMBEDDED_RUNS = embeddedRunState.activeRuns;
|
||||
const ACTIVE_EMBEDDED_RUN_SNAPSHOTS = embeddedRunState.snapshots;
|
||||
const EMBEDDED_RUN_WAITERS = embeddedRunState.waiters;
|
||||
|
||||
export function queueEmbeddedPiMessage(sessionId: string, text: string): boolean {
|
||||
|
|
@ -135,6 +143,12 @@ export function getActiveEmbeddedRunCount(): number {
|
|||
return ACTIVE_EMBEDDED_RUNS.size;
|
||||
}
|
||||
|
||||
export function getActiveEmbeddedRunSnapshot(
|
||||
sessionId: string,
|
||||
): ActiveEmbeddedRunSnapshot | undefined {
|
||||
return ACTIVE_EMBEDDED_RUN_SNAPSHOTS.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for active embedded runs to drain.
|
||||
*
|
||||
|
|
@ -230,6 +244,16 @@ export function setActiveEmbeddedRun(
|
|||
}
|
||||
}
|
||||
|
||||
export function updateActiveEmbeddedRunSnapshot(
|
||||
sessionId: string,
|
||||
snapshot: ActiveEmbeddedRunSnapshot,
|
||||
) {
|
||||
if (!ACTIVE_EMBEDDED_RUNS.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
ACTIVE_EMBEDDED_RUN_SNAPSHOTS.set(sessionId, snapshot);
|
||||
}
|
||||
|
||||
export function clearActiveEmbeddedRun(
|
||||
sessionId: string,
|
||||
handle: EmbeddedPiQueueHandle,
|
||||
|
|
@ -237,6 +261,7 @@ export function clearActiveEmbeddedRun(
|
|||
) {
|
||||
if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) {
|
||||
ACTIVE_EMBEDDED_RUNS.delete(sessionId);
|
||||
ACTIVE_EMBEDDED_RUN_SNAPSHOTS.delete(sessionId);
|
||||
logSessionStateChange({ sessionId, sessionKey, state: "idle", reason: "run_completed" });
|
||||
if (!sessionId.startsWith("probe-")) {
|
||||
diag.debug(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
|
||||
|
|
@ -257,6 +282,7 @@ export const __testing = {
|
|||
}
|
||||
EMBEDDED_RUN_WAITERS.clear();
|
||||
ACTIVE_EMBEDDED_RUNS.clear();
|
||||
ACTIVE_EMBEDDED_RUN_SNAPSHOTS.clear();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -196,6 +196,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||
acceptsArgs: true,
|
||||
category: "status",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "btw",
|
||||
nativeName: "btw",
|
||||
description: "Ask a side question without changing future session context.",
|
||||
textAlias: "/btw",
|
||||
acceptsArgs: true,
|
||||
category: "tools",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "export-session",
|
||||
nativeName: "export-session",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { normalizeCommandBody, type CommandNormalizeOptions } from "../commands-registry.js";
|
||||
|
||||
const BTW_COMMAND_RE = /^\/btw(?::|\s|$)/i;
|
||||
|
||||
export function isBtwRequestText(text?: string, options?: CommandNormalizeOptions): boolean {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeCommandBody(text, options).trim();
|
||||
return BTW_COMMAND_RE.test(normalized);
|
||||
}
|
||||
|
||||
export function extractBtwQuestion(
|
||||
text?: string,
|
||||
options?: CommandNormalizeOptions,
|
||||
): string | null {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeCommandBody(text, options).trim();
|
||||
const match = normalized.match(/^\/btw(?:\s+(.*))?$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return match[1]?.trim() ?? "";
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { buildCommandTestParams } from "./commands.test-harness.js";
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
|
||||
const runBtwSideQuestionMock = vi.fn();
|
||||
|
||||
vi.mock("../../agents/btw.js", () => ({
|
||||
runBtwSideQuestion: (...args: unknown[]) => runBtwSideQuestionMock(...args),
|
||||
}));
|
||||
|
||||
const { handleBtwCommand } = await import("./commands-btw.js");
|
||||
|
||||
function buildParams(commandBody: string) {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
return buildCommandTestParams(commandBody, cfg, undefined, { workspaceDir: "/tmp/workspace" });
|
||||
}
|
||||
|
||||
describe("handleBtwCommand", () => {
|
||||
beforeEach(() => {
|
||||
runBtwSideQuestionMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns usage when the side question is missing", async () => {
|
||||
const result = await handleBtwCommand(buildParams("/btw"), true);
|
||||
|
||||
expect(result).toEqual({
|
||||
shouldContinue: false,
|
||||
reply: { text: "Usage: /btw <side question>" },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores /btw when text commands are disabled", async () => {
|
||||
const result = await handleBtwCommand(buildParams("/btw what changed?"), false);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(runBtwSideQuestionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores /btw from unauthorized senders", async () => {
|
||||
const params = buildParams("/btw what changed?");
|
||||
params.command.isAuthorizedSender = false;
|
||||
|
||||
const result = await handleBtwCommand(params, true);
|
||||
|
||||
expect(result).toEqual({ shouldContinue: false });
|
||||
expect(runBtwSideQuestionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires an active session context", async () => {
|
||||
const params = buildParams("/btw what changed?");
|
||||
params.sessionEntry = undefined;
|
||||
|
||||
const result = await handleBtwCommand(params, true);
|
||||
|
||||
expect(result).toEqual({
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ /btw requires an active session with existing context." },
|
||||
});
|
||||
});
|
||||
|
||||
it("still delegates while the session is actively running", async () => {
|
||||
const params = buildParams("/btw what changed?");
|
||||
params.agentDir = "/tmp/agent";
|
||||
params.sessionEntry = {
|
||||
sessionId: "session-1",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
runBtwSideQuestionMock.mockResolvedValue({ text: "snapshot answer" });
|
||||
|
||||
const result = await handleBtwCommand(params, true);
|
||||
|
||||
expect(runBtwSideQuestionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
question: "what changed?",
|
||||
sessionEntry: params.sessionEntry,
|
||||
resolvedThinkLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
shouldContinue: false,
|
||||
reply: { text: "snapshot answer", btw: { question: "what changed?" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts the typing keepalive while the side question runs", async () => {
|
||||
const params = buildParams("/btw what changed?");
|
||||
const typing = createMockTypingController();
|
||||
params.typing = typing;
|
||||
params.agentDir = "/tmp/agent";
|
||||
params.sessionEntry = {
|
||||
sessionId: "session-1",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
runBtwSideQuestionMock.mockResolvedValue({ text: "snapshot answer" });
|
||||
|
||||
await handleBtwCommand(params, true);
|
||||
|
||||
expect(typing.startTypingLoop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("delegates to the side-question runner", async () => {
|
||||
const params = buildParams("/btw what changed?");
|
||||
params.agentDir = "/tmp/agent";
|
||||
params.sessionEntry = {
|
||||
sessionId: "session-1",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
runBtwSideQuestionMock.mockResolvedValue({ text: "nothing important" });
|
||||
|
||||
const result = await handleBtwCommand(params, true);
|
||||
|
||||
expect(runBtwSideQuestionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
question: "what changed?",
|
||||
agentDir: "/tmp/agent",
|
||||
sessionEntry: params.sessionEntry,
|
||||
resolvedThinkLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
shouldContinue: false,
|
||||
reply: { text: "nothing important", btw: { question: "what changed?" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { runBtwSideQuestion } from "../../agents/btw.js";
|
||||
import { extractBtwQuestion } from "./btw-command.js";
|
||||
import { rejectUnauthorizedCommand } from "./command-gates.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
const BTW_USAGE = "Usage: /btw <side question>";
|
||||
|
||||
export const handleBtwCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const question = extractBtwQuestion(params.command.commandBodyNormalized);
|
||||
if (question === null) {
|
||||
return null;
|
||||
}
|
||||
const unauthorized = rejectUnauthorizedCommand(params, "/btw");
|
||||
if (unauthorized) {
|
||||
return unauthorized;
|
||||
}
|
||||
|
||||
if (!question) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: BTW_USAGE },
|
||||
};
|
||||
}
|
||||
|
||||
if (!params.sessionEntry?.sessionId) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ /btw requires an active session with existing context." },
|
||||
};
|
||||
}
|
||||
|
||||
if (!params.agentDir) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ /btw is unavailable because the active agent directory could not be resolved.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await params.typing?.startTypingLoop();
|
||||
const reply = await runBtwSideQuestion({
|
||||
cfg: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
question,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionStore: params.sessionStore,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
// BTW is intentionally a quick side question, so do not inherit slower
|
||||
// session-level think/reasoning settings from the main run.
|
||||
resolvedThinkLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
blockReplyChunking: params.blockReplyChunking,
|
||||
resolvedBlockStreamingBreak: params.resolvedBlockStreamingBreak,
|
||||
opts: params.opts,
|
||||
isNewSession: false,
|
||||
});
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: reply ? { ...reply, btw: { question } } : reply,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.trim() : "";
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚠️ /btw failed${message ? `: ${message}` : "."}`,
|
||||
btw: { question },
|
||||
isError: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -11,6 +11,7 @@ import { resolveBoundAcpThreadSessionKey } from "./commands-acp/targets.js";
|
|||
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
||||
import { handleApproveCommand } from "./commands-approve.js";
|
||||
import { handleBashCommand } from "./commands-bash.js";
|
||||
import { handleBtwCommand } from "./commands-btw.js";
|
||||
import { handleCompactCommand } from "./commands-compact.js";
|
||||
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
|
||||
import {
|
||||
|
|
@ -174,6 +175,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
|||
HANDLERS = [
|
||||
// Plugin commands are processed first, before built-in commands
|
||||
handlePluginCommand,
|
||||
handleBtwCommand,
|
||||
handleBashCommand,
|
||||
handleActivationCommand,
|
||||
handleSendPolicyCommand,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js";
|
||||
import type { SkillCommandSpec } from "../../agents/skills.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
export type CommandContext = {
|
||||
surface: string;
|
||||
|
|
@ -44,17 +46,21 @@ export type HandleCommandsParams = {
|
|||
storePath?: string;
|
||||
sessionScope?: SessionScope;
|
||||
workspaceDir: string;
|
||||
opts?: GetReplyOptions;
|
||||
defaultGroupActivation: () => "always" | "mention";
|
||||
resolvedThinkLevel?: ThinkLevel;
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
resolvedReasoningLevel: ReasoningLevel;
|
||||
resolvedElevatedLevel?: ElevatedLevel;
|
||||
blockReplyChunking?: BlockReplyChunking;
|
||||
resolvedBlockStreamingBreak?: "text_end" | "message_end";
|
||||
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
|
||||
provider: string;
|
||||
model: string;
|
||||
contextTokens: number;
|
||||
isGroup: boolean;
|
||||
skillCommands?: SkillCommandSpec[];
|
||||
typing?: TypingController;
|
||||
};
|
||||
|
||||
export type CommandHandlerResult = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { collectTextContentBlocks } from "../../agents/content-blocks.js";
|
||||
import { createOpenClawTools } from "../../agents/openclaw-tools.js";
|
||||
import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js";
|
||||
import type { SkillCommandSpec } from "../../agents/skills.js";
|
||||
import { applyOwnerOnlyToolPolicy } from "../../agents/tool-policy.js";
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
|
|
@ -37,6 +38,7 @@ function getBuiltinSlashCommands(): Set<string> {
|
|||
return builtinSlashCommands;
|
||||
}
|
||||
builtinSlashCommands = listReservedChatSlashCommandNames([
|
||||
"btw",
|
||||
"think",
|
||||
"verbose",
|
||||
"reasoning",
|
||||
|
|
@ -113,6 +115,8 @@ export async function handleInlineActions(params: {
|
|||
resolvedVerboseLevel: VerboseLevel | undefined;
|
||||
resolvedReasoningLevel: ReasoningLevel;
|
||||
resolvedElevatedLevel: ElevatedLevel;
|
||||
blockReplyChunking?: BlockReplyChunking;
|
||||
resolvedBlockStreamingBreak?: "text_end" | "message_end";
|
||||
resolveDefaultThinkingLevel: Awaited<
|
||||
ReturnType<typeof createModelSelectionState>
|
||||
>["resolveDefaultThinkingLevel"];
|
||||
|
|
@ -152,6 +156,8 @@ export async function handleInlineActions(params: {
|
|||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
blockReplyChunking,
|
||||
resolvedBlockStreamingBreak,
|
||||
resolveDefaultThinkingLevel,
|
||||
provider,
|
||||
model,
|
||||
|
|
@ -357,17 +363,21 @@ export async function handleInlineActions(params: {
|
|||
storePath,
|
||||
sessionScope,
|
||||
workspaceDir,
|
||||
opts,
|
||||
defaultGroupActivation: defaultActivation,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
blockReplyChunking,
|
||||
resolvedBlockStreamingBreak,
|
||||
resolveDefaultThinkingLevel,
|
||||
provider,
|
||||
model,
|
||||
contextTokens,
|
||||
isGroup,
|
||||
skillCommands,
|
||||
typing,
|
||||
});
|
||||
|
||||
if (inlineCommand) {
|
||||
|
|
|
|||
|
|
@ -332,6 +332,8 @@ export async function getReplyFromConfig(
|
|||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
blockReplyChunking,
|
||||
resolvedBlockStreamingBreak,
|
||||
resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel,
|
||||
provider,
|
||||
model,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,19 @@ import type { ReplyPayload } from "../types.js";
|
|||
import { extractReplyToTag } from "./reply-tags.js";
|
||||
import { createReplyToModeFilterForChannel } from "./reply-threading.js";
|
||||
|
||||
export function formatBtwTextForExternalDelivery(payload: ReplyPayload): string | undefined {
|
||||
const text = payload.text?.trim();
|
||||
if (!text) {
|
||||
return payload.text;
|
||||
}
|
||||
const question = payload.btw?.question?.trim();
|
||||
if (!question) {
|
||||
return payload.text;
|
||||
}
|
||||
const formatted = `BTW\nQuestion: ${question}\n\n${text}`;
|
||||
return text === formatted || text.startsWith("BTW\nQuestion:") ? text : formatted;
|
||||
}
|
||||
|
||||
function resolveReplyThreadingForPayload(params: {
|
||||
payload: ReplyPayload;
|
||||
implicitReplyToId?: string;
|
||||
|
|
|
|||
|
|
@ -44,25 +44,33 @@ vi.mock("../../../extensions/slack/src/send.js", () => ({
|
|||
vi.mock("../../../extensions/telegram/src/send.js", () => ({
|
||||
sendMessageTelegram: mocks.sendMessageTelegram,
|
||||
}));
|
||||
vi.mock("../../../extensions/telegram/src/send.js", () => ({
|
||||
sendMessageTelegram: mocks.sendMessageTelegram,
|
||||
}));
|
||||
vi.mock("../../../extensions/whatsapp/src/send.js", () => ({
|
||||
sendMessageWhatsApp: mocks.sendMessageWhatsApp,
|
||||
sendPollWhatsApp: mocks.sendMessageWhatsApp,
|
||||
}));
|
||||
vi.mock("../../../extensions/discord/src/send.js", () => ({
|
||||
sendMessageDiscord: mocks.sendMessageDiscord,
|
||||
sendPollDiscord: mocks.sendMessageDiscord,
|
||||
sendWebhookMessageDiscord: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../extensions/mattermost/src/mattermost/send.js", () => ({
|
||||
sendMessageMattermost: mocks.sendMessageMattermost,
|
||||
}));
|
||||
vi.mock("../../infra/outbound/deliver.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../infra/outbound/deliver.js")>(
|
||||
"../../infra/outbound/deliver.js",
|
||||
vi.mock("../../infra/outbound/deliver-runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../infra/outbound/deliver-runtime.js")>(
|
||||
"../../infra/outbound/deliver-runtime.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
|
||||
};
|
||||
});
|
||||
const actualDeliver = await vi.importActual<typeof import("../../infra/outbound/deliver.js")>(
|
||||
"../../infra/outbound/deliver.js",
|
||||
);
|
||||
const actualDeliver = await vi.importActual<
|
||||
typeof import("../../infra/outbound/deliver-runtime.js")
|
||||
>("../../infra/outbound/deliver-runtime.js");
|
||||
|
||||
const { routeReply } = await import("./route-reply.js");
|
||||
|
||||
|
|
@ -294,6 +302,36 @@ describe("routeReply", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("formats BTW replies prominently on routed sends", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
await routeReply({
|
||||
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"BTW\nQuestion: what is 17 * 19?\n\n323",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("formats BTW replies prominently on routed discord sends", async () => {
|
||||
mocks.sendMessageDiscord.mockClear();
|
||||
await routeReply({
|
||||
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
|
||||
channel: "discord",
|
||||
to: "channel:123456",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageDiscord).toHaveBeenCalledWith(
|
||||
"channel:123456",
|
||||
"BTW\nQuestion: what is 17 * 19?\n\n323",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes replyToId to Telegram sends", async () => {
|
||||
mocks.sendMessageTelegram.mockClear();
|
||||
await routeReply({
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/m
|
|||
import type { OriginatingChannelType } from "../templating.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||
import { shouldSuppressReasoningPayload } from "./reply-payloads.js";
|
||||
import {
|
||||
formatBtwTextForExternalDelivery,
|
||||
shouldSuppressReasoningPayload,
|
||||
} from "./reply-payloads.js";
|
||||
|
||||
let deliverRuntimePromise: Promise<
|
||||
typeof import("../../infra/outbound/deliver-runtime.js")
|
||||
|
|
@ -102,24 +105,28 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
|||
if (!normalized) {
|
||||
return { ok: true };
|
||||
}
|
||||
const externalPayload: ReplyPayload = {
|
||||
...normalized,
|
||||
text: formatBtwTextForExternalDelivery(normalized),
|
||||
};
|
||||
|
||||
let text = normalized.text ?? "";
|
||||
let mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
|
||||
? (normalized.mediaUrls?.filter(Boolean) as string[])
|
||||
: normalized.mediaUrl
|
||||
? [normalized.mediaUrl]
|
||||
let text = externalPayload.text ?? "";
|
||||
let mediaUrls = (externalPayload.mediaUrls?.filter(Boolean) ?? []).length
|
||||
? (externalPayload.mediaUrls?.filter(Boolean) as string[])
|
||||
: externalPayload.mediaUrl
|
||||
? [externalPayload.mediaUrl]
|
||||
: [];
|
||||
const replyToId = normalized.replyToId;
|
||||
const replyToId = externalPayload.replyToId;
|
||||
let hasSlackBlocks = false;
|
||||
if (
|
||||
channel === "slack" &&
|
||||
normalized.channelData?.slack &&
|
||||
typeof normalized.channelData.slack === "object" &&
|
||||
!Array.isArray(normalized.channelData.slack)
|
||||
externalPayload.channelData?.slack &&
|
||||
typeof externalPayload.channelData.slack === "object" &&
|
||||
!Array.isArray(externalPayload.channelData.slack)
|
||||
) {
|
||||
try {
|
||||
hasSlackBlocks = Boolean(
|
||||
parseSlackBlocksInput((normalized.channelData.slack as { blocks?: unknown }).blocks)
|
||||
parseSlackBlocksInput((externalPayload.channelData.slack as { blocks?: unknown }).blocks)
|
||||
?.length,
|
||||
);
|
||||
} catch {
|
||||
|
|
@ -168,7 +175,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
|||
channel: channelId,
|
||||
to,
|
||||
accountId: accountId ?? undefined,
|
||||
payloads: [normalized],
|
||||
payloads: [externalPayload],
|
||||
replyToId: resolvedReplyToId ?? null,
|
||||
threadId: resolvedThreadId,
|
||||
session: outboundSession,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ export type ReplyPayload = {
|
|||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
btw?: {
|
||||
question: string;
|
||||
};
|
||||
replyToId?: string;
|
||||
replyToTag?: boolean;
|
||||
/** True when [[reply_to_current]] was present but not yet mapped to a message id. */
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
|||
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
import { resolveSessionFilePath } from "../../config/sessions.js";
|
||||
import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js";
|
||||
|
|
@ -130,6 +131,16 @@ type ChatSendOriginatingRoute = {
|
|||
explicitDeliverRoute: boolean;
|
||||
};
|
||||
|
||||
type SideResultPayload = {
|
||||
kind: "btw";
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
question: string;
|
||||
text: string;
|
||||
isError?: boolean;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
function resolveChatSendOriginatingRoute(params: {
|
||||
client?: { mode?: string | null; id?: string | null } | null;
|
||||
deliver?: boolean;
|
||||
|
|
@ -900,6 +911,33 @@ function broadcastChatFinal(params: {
|
|||
params.context.agentRunSeq.delete(params.runId);
|
||||
}
|
||||
|
||||
function isBtwReplyPayload(payload: ReplyPayload | undefined): payload is ReplyPayload & {
|
||||
btw: { question: string };
|
||||
text: string;
|
||||
} {
|
||||
return (
|
||||
typeof payload?.btw?.question === "string" &&
|
||||
payload.btw.question.trim().length > 0 &&
|
||||
typeof payload.text === "string" &&
|
||||
payload.text.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function broadcastSideResult(params: {
|
||||
context: Pick<GatewayRequestContext, "broadcast" | "nodeSendToSession" | "agentRunSeq">;
|
||||
payload: SideResultPayload;
|
||||
}) {
|
||||
const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.payload.runId);
|
||||
params.context.broadcast("chat.side_result", {
|
||||
...params.payload,
|
||||
seq,
|
||||
});
|
||||
params.context.nodeSendToSession(params.payload.sessionKey, "chat.side_result", {
|
||||
...params.payload,
|
||||
seq,
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastChatError(params: {
|
||||
context: Pick<GatewayRequestContext, "broadcast" | "nodeSendToSession" | "agentRunSeq">;
|
||||
runId: string;
|
||||
|
|
@ -1284,21 +1322,17 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||
agentId,
|
||||
channel: INTERNAL_MESSAGE_CHANNEL,
|
||||
});
|
||||
const finalReplyParts: string[] = [];
|
||||
const deliveredReplies: Array<{ payload: ReplyPayload; kind: "block" | "final" }> = [];
|
||||
const dispatcher = createReplyDispatcher({
|
||||
...prefixOptions,
|
||||
onError: (err) => {
|
||||
context.logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`);
|
||||
},
|
||||
deliver: async (payload, info) => {
|
||||
if (info.kind !== "final") {
|
||||
if (info.kind !== "block" && info.kind !== "final") {
|
||||
return;
|
||||
}
|
||||
const text = payload.text?.trim() ?? "";
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
finalReplyParts.push(text);
|
||||
deliveredReplies.push({ payload, kind: info.kind });
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -1335,48 +1369,78 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||
})
|
||||
.then(() => {
|
||||
if (!agentRunStarted) {
|
||||
const combinedReply = finalReplyParts
|
||||
.map((part) => part.trim())
|
||||
const btwReplies = deliveredReplies
|
||||
.map((entry) => entry.payload)
|
||||
.filter(isBtwReplyPayload);
|
||||
const btwText = btwReplies
|
||||
.map((payload) => payload.text.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
let message: Record<string, unknown> | undefined;
|
||||
if (combinedReply) {
|
||||
const { storePath: latestStorePath, entry: latestEntry } =
|
||||
loadSessionEntry(sessionKey);
|
||||
const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId;
|
||||
const appended = appendAssistantTranscriptMessage({
|
||||
message: combinedReply,
|
||||
sessionId,
|
||||
storePath: latestStorePath,
|
||||
sessionFile: latestEntry?.sessionFile,
|
||||
agentId,
|
||||
createIfMissing: true,
|
||||
if (btwReplies.length > 0 && btwText) {
|
||||
broadcastSideResult({
|
||||
context,
|
||||
payload: {
|
||||
kind: "btw",
|
||||
runId: clientRunId,
|
||||
sessionKey: rawSessionKey,
|
||||
question: btwReplies[0].btw.question.trim(),
|
||||
text: btwText,
|
||||
isError: btwReplies.some((payload) => payload.isError),
|
||||
ts: Date.now(),
|
||||
},
|
||||
});
|
||||
if (appended.ok) {
|
||||
message = appended.message;
|
||||
} else {
|
||||
context.logGateway.warn(
|
||||
`webchat transcript append failed: ${appended.error ?? "unknown error"}`,
|
||||
);
|
||||
const now = Date.now();
|
||||
message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: combinedReply }],
|
||||
timestamp: now,
|
||||
// Keep this compatible with Pi stopReason enums even though this message isn't
|
||||
// persisted to the transcript due to the append failure.
|
||||
stopReason: "stop",
|
||||
usage: { input: 0, output: 0, totalTokens: 0 },
|
||||
};
|
||||
broadcastChatFinal({
|
||||
context,
|
||||
runId: clientRunId,
|
||||
sessionKey: rawSessionKey,
|
||||
});
|
||||
} else {
|
||||
const combinedReply = deliveredReplies
|
||||
.filter((entry) => entry.kind === "final")
|
||||
.map((entry) => entry.payload)
|
||||
.map((part) => part.text?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
let message: Record<string, unknown> | undefined;
|
||||
if (combinedReply) {
|
||||
const { storePath: latestStorePath, entry: latestEntry } =
|
||||
loadSessionEntry(sessionKey);
|
||||
const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId;
|
||||
const appended = appendAssistantTranscriptMessage({
|
||||
message: combinedReply,
|
||||
sessionId,
|
||||
storePath: latestStorePath,
|
||||
sessionFile: latestEntry?.sessionFile,
|
||||
agentId,
|
||||
createIfMissing: true,
|
||||
});
|
||||
if (appended.ok) {
|
||||
message = appended.message;
|
||||
} else {
|
||||
context.logGateway.warn(
|
||||
`webchat transcript append failed: ${appended.error ?? "unknown error"}`,
|
||||
);
|
||||
const now = Date.now();
|
||||
message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: combinedReply }],
|
||||
timestamp: now,
|
||||
// Keep this compatible with Pi stopReason enums even though this message isn't
|
||||
// persisted to the transcript due to the append failure.
|
||||
stopReason: "stop",
|
||||
usage: { input: 0, output: 0, totalTokens: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
broadcastChatFinal({
|
||||
context,
|
||||
runId: clientRunId,
|
||||
sessionKey: rawSessionKey,
|
||||
message,
|
||||
});
|
||||
}
|
||||
broadcastChatFinal({
|
||||
context,
|
||||
runId: clientRunId,
|
||||
sessionKey: rawSessionKey,
|
||||
message,
|
||||
});
|
||||
}
|
||||
setGatewayDedupeEntry({
|
||||
dedupe: context.dedupe,
|
||||
|
|
|
|||
|
|
@ -497,6 +497,103 @@ describe("gateway server chat", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("routes /btw replies through side-result events without transcript injection", async () => {
|
||||
await withMainSessionStore(async () => {
|
||||
const replyMock = vi.mocked(getReplyFromConfig);
|
||||
replyMock.mockResolvedValueOnce({
|
||||
text: "323",
|
||||
btw: { question: "what is 17 * 19?" },
|
||||
});
|
||||
const sideResultPromise = onceMessage(
|
||||
ws,
|
||||
(o) =>
|
||||
o.type === "event" &&
|
||||
o.event === "chat.side_result" &&
|
||||
o.payload?.kind === "btw" &&
|
||||
o.payload?.runId === "idem-btw-1",
|
||||
8000,
|
||||
);
|
||||
const finalPromise = onceMessage(
|
||||
ws,
|
||||
(o) =>
|
||||
o.type === "event" &&
|
||||
o.event === "chat" &&
|
||||
o.payload?.state === "final" &&
|
||||
o.payload?.runId === "idem-btw-1",
|
||||
8000,
|
||||
);
|
||||
|
||||
const res = await rpcReq(ws, "chat.send", {
|
||||
sessionKey: "main",
|
||||
message: "/btw what is 17 * 19?",
|
||||
idempotencyKey: "idem-btw-1",
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
const sideResult = await sideResultPromise;
|
||||
const finalEvent = await finalPromise;
|
||||
expect(sideResult.payload).toMatchObject({
|
||||
kind: "btw",
|
||||
runId: "idem-btw-1",
|
||||
sessionKey: "main",
|
||||
question: "what is 17 * 19?",
|
||||
text: "323",
|
||||
});
|
||||
expect(finalEvent.payload).toMatchObject({
|
||||
runId: "idem-btw-1",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
});
|
||||
|
||||
const historyRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
|
||||
sessionKey: "main",
|
||||
});
|
||||
expect(historyRes.ok).toBe(true);
|
||||
expect(historyRes.payload?.messages ?? []).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("routes block-streamed /btw replies through side-result events", async () => {
|
||||
await withMainSessionStore(async () => {
|
||||
const replyMock = vi.mocked(getReplyFromConfig);
|
||||
replyMock.mockImplementationOnce(async (_ctx, opts) => {
|
||||
await opts?.onBlockReply?.({
|
||||
text: "first chunk",
|
||||
btw: { question: "what changed?" },
|
||||
});
|
||||
await opts?.onBlockReply?.({
|
||||
text: "second chunk",
|
||||
btw: { question: "what changed?" },
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
const sideResultPromise = onceMessage(
|
||||
ws,
|
||||
(o) =>
|
||||
o.type === "event" &&
|
||||
o.event === "chat.side_result" &&
|
||||
o.payload?.kind === "btw" &&
|
||||
o.payload?.runId === "idem-btw-block-1",
|
||||
8000,
|
||||
);
|
||||
|
||||
const res = await rpcReq(ws, "chat.send", {
|
||||
sessionKey: "main",
|
||||
message: "/btw what changed?",
|
||||
idempotencyKey: "idem-btw-block-1",
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
const sideResult = await sideResultPromise;
|
||||
expect(sideResult.payload).toMatchObject({
|
||||
kind: "btw",
|
||||
runId: "idem-btw-block-1",
|
||||
question: "what changed?",
|
||||
text: "first chunk\n\nsecond chunk",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.history hides assistant NO_REPLY-only entries and keeps mixed-content assistant entries", async () => {
|
||||
const historyMessages = await loadChatHistoryWithMessages(buildNoReplyHistoryFixture(true));
|
||||
const roleAndText = historyMessages
|
||||
|
|
|
|||
|
|
@ -1,22 +1,82 @@
|
|||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { markdownToSignalTextChunks } from "../../../extensions/signal/src/format.js";
|
||||
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.adapters.js";
|
||||
import { signalOutbound } from "../../channels/plugins/outbound/signal.js";
|
||||
import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js";
|
||||
import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { STATE_DIR } from "../../config/paths.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { withEnvAsync } from "../../test-utils/env.js";
|
||||
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
|
||||
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js";
|
||||
import {
|
||||
clearDeliverTestRegistry,
|
||||
hookMocks,
|
||||
resetDeliverTestState,
|
||||
resetDeliverTestMocks,
|
||||
runChunkedWhatsAppDelivery as runChunkedWhatsAppDeliveryHelper,
|
||||
whatsappChunkConfig,
|
||||
} from "./deliver.test-helpers.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
|
||||
}));
|
||||
const hookMocks = vi.hoisted(() => ({
|
||||
runner: {
|
||||
hasHooks: vi.fn(() => false),
|
||||
runMessageSent: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
const internalHookMocks = vi.hoisted(() => ({
|
||||
createInternalHookEvent: vi.fn(),
|
||||
triggerInternalHook: vi.fn(async () => {}),
|
||||
}));
|
||||
const queueMocks = vi.hoisted(() => ({
|
||||
enqueueDelivery: vi.fn(async () => "mock-queue-id"),
|
||||
ackDelivery: vi.fn(async () => {}),
|
||||
failDelivery: vi.fn(async () => {}),
|
||||
}));
|
||||
const logMocks = vi.hoisted(() => ({
|
||||
warn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
||||
"../../config/sessions.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
|
||||
};
|
||||
});
|
||||
vi.mock("../../config/sessions/transcript.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../config/sessions/transcript.js")>(
|
||||
"../../config/sessions/transcript.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
|
||||
};
|
||||
});
|
||||
vi.mock("../../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: () => hookMocks.runner,
|
||||
}));
|
||||
vi.mock("../../hooks/internal-hooks.js", () => ({
|
||||
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
|
||||
triggerInternalHook: internalHookMocks.triggerInternalHook,
|
||||
}));
|
||||
vi.mock("./delivery-queue.js", () => ({
|
||||
enqueueDelivery: queueMocks.enqueueDelivery,
|
||||
ackDelivery: queueMocks.ackDelivery,
|
||||
failDelivery: queueMocks.failDelivery,
|
||||
}));
|
||||
vi.mock("../../logging/subsystem.js", () => ({
|
||||
createSubsystemLogger: () => {
|
||||
const makeLogger = () => ({
|
||||
warn: logMocks.warn,
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
child: vi.fn(() => makeLogger()),
|
||||
});
|
||||
return makeLogger();
|
||||
},
|
||||
}));
|
||||
|
||||
const { deliverOutboundPayloads, normalizeOutboundPayloads } = await import("./deliver.js");
|
||||
|
||||
|
|
@ -24,39 +84,19 @@ const telegramChunkConfig: OpenClawConfig = {
|
|||
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
|
||||
};
|
||||
|
||||
const whatsappChunkConfig: OpenClawConfig = {
|
||||
channels: { whatsapp: { textChunkLimit: 4000 } },
|
||||
};
|
||||
|
||||
type DeliverOutboundArgs = Parameters<typeof deliverOutboundPayloads>[0];
|
||||
type DeliverOutboundPayload = DeliverOutboundArgs["payloads"][number];
|
||||
type DeliverSession = DeliverOutboundArgs["session"];
|
||||
|
||||
function setMatrixTextOnlyPlugin(sendText: NonNullable<ChannelOutboundAdapter["sendText"]>) {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "matrix",
|
||||
outbound: { deliveryMode: "direct", sendText },
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function deliverMatrixPayloads(payloads: DeliverOutboundPayload[]) {
|
||||
return deliverOutboundPayloads({
|
||||
cfg: {},
|
||||
channel: "matrix",
|
||||
to: "!room:1",
|
||||
payloads,
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverWhatsAppPayload(params: {
|
||||
sendWhatsApp: NonNullable<
|
||||
NonNullable<Parameters<typeof deliverOutboundPayloads>[0]["deps"]>["sendWhatsApp"]
|
||||
>;
|
||||
payload: { text: string; mediaUrl?: string };
|
||||
payload: DeliverOutboundPayload;
|
||||
cfg?: OpenClawConfig;
|
||||
}) {
|
||||
return deliverOutboundPayloads({
|
||||
|
|
@ -86,14 +126,97 @@ async function deliverTelegramPayload(params: {
|
|||
});
|
||||
}
|
||||
|
||||
async function runChunkedWhatsAppDelivery(params?: {
|
||||
mirror?: Parameters<typeof deliverOutboundPayloads>[0]["mirror"];
|
||||
}) {
|
||||
const sendWhatsApp = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "w1", toJid: "jid" })
|
||||
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { textChunkLimit: 2 } },
|
||||
};
|
||||
const results = await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "abcd" }],
|
||||
deps: { sendWhatsApp },
|
||||
...(params?.mirror ? { mirror: params.mirror } : {}),
|
||||
});
|
||||
return { sendWhatsApp, results };
|
||||
}
|
||||
|
||||
async function deliverSingleWhatsAppForHookTest(params?: { sessionKey?: string }) {
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
|
||||
await deliverOutboundPayloads({
|
||||
cfg: whatsappChunkConfig,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "hello" }],
|
||||
deps: { sendWhatsApp },
|
||||
...(params?.sessionKey ? { session: { key: params.sessionKey } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function runBestEffortPartialFailureDelivery() {
|
||||
const sendWhatsApp = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("fail"))
|
||||
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
|
||||
const onError = vi.fn();
|
||||
const cfg: OpenClawConfig = {};
|
||||
const results = await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "a" }, { text: "b" }],
|
||||
deps: { sendWhatsApp },
|
||||
bestEffort: true,
|
||||
onError,
|
||||
});
|
||||
return { sendWhatsApp, onError, results };
|
||||
}
|
||||
|
||||
function expectSuccessfulWhatsAppInternalHookPayload(
|
||||
expected: Partial<{
|
||||
content: string;
|
||||
messageId: string;
|
||||
isGroup: boolean;
|
||||
groupId: string;
|
||||
}>,
|
||||
) {
|
||||
return expect.objectContaining({
|
||||
to: "+1555",
|
||||
success: true,
|
||||
channelId: "whatsapp",
|
||||
conversationId: "+1555",
|
||||
...expected,
|
||||
});
|
||||
}
|
||||
|
||||
describe("deliverOutboundPayloads", () => {
|
||||
beforeEach(() => {
|
||||
resetDeliverTestState();
|
||||
resetDeliverTestMocks();
|
||||
setActivePluginRegistry(defaultRegistry);
|
||||
mocks.appendAssistantMessageToSessionTranscript.mockClear();
|
||||
hookMocks.runner.hasHooks.mockClear();
|
||||
hookMocks.runner.hasHooks.mockReturnValue(false);
|
||||
hookMocks.runner.runMessageSent.mockClear();
|
||||
hookMocks.runner.runMessageSent.mockResolvedValue(undefined);
|
||||
internalHookMocks.createInternalHookEvent.mockClear();
|
||||
internalHookMocks.createInternalHookEvent.mockImplementation(createInternalHookEventPayload);
|
||||
internalHookMocks.triggerInternalHook.mockClear();
|
||||
queueMocks.enqueueDelivery.mockClear();
|
||||
queueMocks.enqueueDelivery.mockResolvedValue("mock-queue-id");
|
||||
queueMocks.ackDelivery.mockClear();
|
||||
queueMocks.ackDelivery.mockResolvedValue(undefined);
|
||||
queueMocks.failDelivery.mockClear();
|
||||
queueMocks.failDelivery.mockResolvedValue(undefined);
|
||||
logMocks.warn.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearDeliverTestRegistry();
|
||||
setActivePluginRegistry(emptyRegistry);
|
||||
});
|
||||
it("chunks telegram markdown and passes through accountId", async () => {
|
||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||
|
|
@ -175,6 +298,24 @@ describe("deliverOutboundPayloads", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("formats BTW replies prominently for telegram delivery", async () => {
|
||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||
|
||||
await deliverTelegramPayload({
|
||||
sendTelegram,
|
||||
cfg: {
|
||||
channels: { telegram: { botToken: "tok-1", textChunkLimit: 100 } },
|
||||
},
|
||||
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"BTW\nQuestion: what is 17 * 19?\n\n323",
|
||||
expect.objectContaining({ verbose: false, textMode: "html" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves HTML text for telegram sendPayload channelData path", async () => {
|
||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||
|
||||
|
|
@ -416,9 +557,7 @@ describe("deliverOutboundPayloads", () => {
|
|||
});
|
||||
|
||||
it("chunks WhatsApp text and returns all results", async () => {
|
||||
const { sendWhatsApp, results } = await runChunkedWhatsAppDeliveryHelper({
|
||||
deliverOutboundPayloads,
|
||||
});
|
||||
const { sendWhatsApp, results } = await runChunkedWhatsAppDelivery();
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
|
||||
expect(results.map((r) => r.messageId)).toEqual(["w1", "w2"]);
|
||||
|
|
@ -614,6 +753,226 @@ describe("deliverOutboundPayloads", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("formats BTW replies prominently for whatsapp delivery", async () => {
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
|
||||
|
||||
await deliverWhatsAppPayload({
|
||||
sendWhatsApp,
|
||||
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledWith(
|
||||
"+1555",
|
||||
"BTW\nQuestion: what is 17 * 19?\n\n323",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("continues on errors when bestEffort is enabled", async () => {
|
||||
const { sendWhatsApp, onError, results } = await runBestEffortPartialFailureDelivery();
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(results).toEqual([{ channel: "whatsapp", messageId: "w2", toJid: "jid" }]);
|
||||
});
|
||||
|
||||
it("emits internal message:sent hook with success=true for chunked payload delivery", async () => {
|
||||
const { sendWhatsApp } = await runChunkedWhatsAppDelivery({
|
||||
mirror: {
|
||||
sessionKey: "agent:main:main",
|
||||
isGroup: true,
|
||||
groupId: "whatsapp:group:123",
|
||||
},
|
||||
});
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledTimes(1);
|
||||
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
|
||||
"message",
|
||||
"sent",
|
||||
"agent:main:main",
|
||||
expectSuccessfulWhatsAppInternalHookPayload({
|
||||
content: "abcd",
|
||||
messageId: "w2",
|
||||
isGroup: true,
|
||||
groupId: "whatsapp:group:123",
|
||||
}),
|
||||
);
|
||||
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not emit internal message:sent hook when neither mirror nor sessionKey is provided", async () => {
|
||||
await deliverSingleWhatsAppForHookTest();
|
||||
|
||||
expect(internalHookMocks.createInternalHookEvent).not.toHaveBeenCalled();
|
||||
expect(internalHookMocks.triggerInternalHook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits internal message:sent hook when sessionKey is provided without mirror", async () => {
|
||||
await deliverSingleWhatsAppForHookTest({ sessionKey: "agent:main:main" });
|
||||
|
||||
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledTimes(1);
|
||||
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
|
||||
"message",
|
||||
"sent",
|
||||
"agent:main:main",
|
||||
expectSuccessfulWhatsAppInternalHookPayload({ content: "hello", messageId: "w1" }),
|
||||
);
|
||||
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("warns when session.agentId is set without a session key", async () => {
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
|
||||
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg: whatsappChunkConfig,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "hello" }],
|
||||
deps: { sendWhatsApp },
|
||||
session: { agentId: "agent-main" },
|
||||
});
|
||||
|
||||
expect(logMocks.warn).toHaveBeenCalledWith(
|
||||
"deliverOutboundPayloads: session.agentId present without session key; internal message:sent hook will be skipped",
|
||||
expect.objectContaining({ channel: "whatsapp", to: "+1555", agentId: "agent-main" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls failDelivery instead of ackDelivery on bestEffort partial failure", async () => {
|
||||
const { onError } = await runBestEffortPartialFailureDelivery();
|
||||
|
||||
// onError was called for the first payload's failure.
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Queue entry should NOT be acked — failDelivery should be called instead.
|
||||
expect(queueMocks.ackDelivery).not.toHaveBeenCalled();
|
||||
expect(queueMocks.failDelivery).toHaveBeenCalledWith(
|
||||
"mock-queue-id",
|
||||
"partial delivery failure (bestEffort)",
|
||||
);
|
||||
});
|
||||
|
||||
it("acks the queue entry when delivery is aborted", async () => {
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
await expect(
|
||||
deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "a" }],
|
||||
deps: { sendWhatsApp },
|
||||
abortSignal: abortController.signal,
|
||||
}),
|
||||
).rejects.toThrow("Operation aborted");
|
||||
|
||||
expect(queueMocks.ackDelivery).toHaveBeenCalledWith("mock-queue-id");
|
||||
expect(queueMocks.failDelivery).not.toHaveBeenCalled();
|
||||
expect(sendWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes normalized payload to onError", async () => {
|
||||
const sendWhatsApp = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const onError = vi.fn();
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "hi", mediaUrl: "https://x.test/a.jpg" }],
|
||||
deps: { sendWhatsApp },
|
||||
bestEffort: true,
|
||||
onError,
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ text: "hi", mediaUrls: ["https://x.test/a.jpg"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("mirrors delivered output when mirror options are provided", async () => {
|
||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||
mocks.appendAssistantMessageToSessionTranscript.mockClear();
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg: telegramChunkConfig,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
payloads: [{ text: "caption", mediaUrl: "https://example.com/files/report.pdf?sig=1" }],
|
||||
deps: { sendTelegram },
|
||||
mirror: {
|
||||
sessionKey: "agent:main:main",
|
||||
text: "caption",
|
||||
mediaUrls: ["https://example.com/files/report.pdf?sig=1"],
|
||||
idempotencyKey: "idem-deliver-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "report.pdf",
|
||||
idempotencyKey: "idem-deliver-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits message_sent success for text-only deliveries", async () => {
|
||||
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg: {},
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "hello" }],
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
|
||||
expect(hookMocks.runner.runMessageSent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "+1555", content: "hello", success: true }),
|
||||
expect.objectContaining({ channelId: "whatsapp" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits message_sent success for sendPayload deliveries", async () => {
|
||||
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||
const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" });
|
||||
const sendText = vi.fn();
|
||||
const sendMedia = vi.fn();
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "matrix",
|
||||
outbound: { deliveryMode: "direct", sendPayload, sendText, sendMedia },
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg: {},
|
||||
channel: "matrix",
|
||||
to: "!room:1",
|
||||
payloads: [{ text: "payload text", channelData: { mode: "custom" } }],
|
||||
});
|
||||
|
||||
expect(hookMocks.runner.runMessageSent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "!room:1", content: "payload text", success: true }),
|
||||
expect.objectContaining({ channelId: "matrix" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves channelData-only payloads with empty text for non-WhatsApp sendPayload channels", async () => {
|
||||
const sendPayload = vi.fn().mockResolvedValue({ channel: "line", messageId: "ln-1" });
|
||||
const sendText = vi.fn();
|
||||
|
|
@ -649,11 +1008,25 @@ describe("deliverOutboundPayloads", () => {
|
|||
|
||||
it("falls back to sendText when plugin outbound omits sendMedia", async () => {
|
||||
const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" });
|
||||
setMatrixTextOnlyPlugin(sendText);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "matrix",
|
||||
outbound: { deliveryMode: "direct", sendText },
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const results = await deliverMatrixPayloads([
|
||||
{ text: "caption", mediaUrl: "https://example.com/file.png" },
|
||||
]);
|
||||
const results = await deliverOutboundPayloads({
|
||||
cfg: {},
|
||||
channel: "matrix",
|
||||
to: "!room:1",
|
||||
payloads: [{ text: "caption", mediaUrl: "https://example.com/file.png" }],
|
||||
});
|
||||
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
expect(sendText).toHaveBeenCalledWith(
|
||||
|
|
@ -661,19 +1034,42 @@ describe("deliverOutboundPayloads", () => {
|
|||
text: "caption",
|
||||
}),
|
||||
);
|
||||
expect(logMocks.warn).toHaveBeenCalledWith(
|
||||
"Plugin outbound adapter does not implement sendMedia; media URLs will be dropped and text fallback will be used",
|
||||
expect.objectContaining({
|
||||
channel: "matrix",
|
||||
mediaCount: 1,
|
||||
}),
|
||||
);
|
||||
expect(results).toEqual([{ channel: "matrix", messageId: "mx-1" }]);
|
||||
});
|
||||
|
||||
it("falls back to one sendText call for multi-media payloads when sendMedia is omitted", async () => {
|
||||
const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-2" });
|
||||
setMatrixTextOnlyPlugin(sendText);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "matrix",
|
||||
outbound: { deliveryMode: "direct", sendText },
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const results = await deliverMatrixPayloads([
|
||||
{
|
||||
text: "caption",
|
||||
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
|
||||
},
|
||||
]);
|
||||
const results = await deliverOutboundPayloads({
|
||||
cfg: {},
|
||||
channel: "matrix",
|
||||
to: "!room:1",
|
||||
payloads: [
|
||||
{
|
||||
text: "caption",
|
||||
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
expect(sendText).toHaveBeenCalledWith(
|
||||
|
|
@ -681,20 +1077,109 @@ describe("deliverOutboundPayloads", () => {
|
|||
text: "caption",
|
||||
}),
|
||||
);
|
||||
expect(logMocks.warn).toHaveBeenCalledWith(
|
||||
"Plugin outbound adapter does not implement sendMedia; media URLs will be dropped and text fallback will be used",
|
||||
expect.objectContaining({
|
||||
channel: "matrix",
|
||||
mediaCount: 2,
|
||||
}),
|
||||
);
|
||||
expect(results).toEqual([{ channel: "matrix", messageId: "mx-2" }]);
|
||||
});
|
||||
|
||||
it("fails media-only payloads when plugin outbound omits sendMedia", async () => {
|
||||
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||
const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-3" });
|
||||
setMatrixTextOnlyPlugin(sendText);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "matrix",
|
||||
outbound: { deliveryMode: "direct", sendText },
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
deliverMatrixPayloads([{ text: " ", mediaUrl: "https://example.com/file.png" }]),
|
||||
deliverOutboundPayloads({
|
||||
cfg: {},
|
||||
channel: "matrix",
|
||||
to: "!room:1",
|
||||
payloads: [{ text: " ", mediaUrl: "https://example.com/file.png" }],
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Plugin outbound adapter does not implement sendMedia and no text fallback is available for media payload",
|
||||
);
|
||||
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(logMocks.warn).toHaveBeenCalledWith(
|
||||
"Plugin outbound adapter does not implement sendMedia; media URLs will be dropped and text fallback will be used",
|
||||
expect.objectContaining({
|
||||
channel: "matrix",
|
||||
mediaCount: 1,
|
||||
}),
|
||||
);
|
||||
expect(hookMocks.runner.runMessageSent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "!room:1",
|
||||
content: "",
|
||||
success: false,
|
||||
error:
|
||||
"Plugin outbound adapter does not implement sendMedia and no text fallback is available for media payload",
|
||||
}),
|
||||
expect.objectContaining({ channelId: "matrix" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits message_sent failure when delivery errors", async () => {
|
||||
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||
const sendWhatsApp = vi.fn().mockRejectedValue(new Error("downstream failed"));
|
||||
|
||||
await expect(
|
||||
deliverOutboundPayloads({
|
||||
cfg: {},
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "hi" }],
|
||||
deps: { sendWhatsApp },
|
||||
}),
|
||||
).rejects.toThrow("downstream failed");
|
||||
|
||||
expect(hookMocks.runner.runMessageSent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "+1555",
|
||||
content: "hi",
|
||||
success: false,
|
||||
error: "downstream failed",
|
||||
}),
|
||||
expect.objectContaining({ channelId: "whatsapp" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const emptyRegistry = createTestRegistry([]);
|
||||
const defaultRegistry = createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "signal",
|
||||
plugin: createOutboundTestPlugin({ id: "signal", outbound: signalOutbound }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
plugin: createOutboundTestPlugin({ id: "whatsapp", outbound: whatsappOutbound }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "imessage",
|
||||
plugin: createIMessageTestPlugin(),
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,6 @@
|
|||
import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js";
|
||||
import {
|
||||
formatBtwTextForExternalDelivery,
|
||||
isRenderablePayload,
|
||||
shouldSuppressReasoningPayload,
|
||||
} from "../../auto-reply/reply/reply-payloads.js";
|
||||
|
|
@ -59,7 +60,11 @@ export function normalizeReplyPayloadsForDelivery(
|
|||
const resolvedMediaUrl = hasMultipleMedia ? undefined : explicitMediaUrl;
|
||||
const next: ReplyPayload = {
|
||||
...payload,
|
||||
text: parsed.text ?? "",
|
||||
text:
|
||||
formatBtwTextForExternalDelivery({
|
||||
...payload,
|
||||
text: parsed.text ?? "",
|
||||
}) ?? "",
|
||||
mediaUrls: mergedMedia.length ? mergedMedia : undefined,
|
||||
mediaUrl: resolvedMediaUrl,
|
||||
replyToId: payload.replyToId ?? parsed.replyToId,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ const RESERVED_COMMANDS = new Set([
|
|||
"status",
|
||||
"whoami",
|
||||
"context",
|
||||
"btw",
|
||||
// Session management
|
||||
"stop",
|
||||
"restart",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { BtwInlineMessage } from "./btw-inline-message.js";
|
||||
|
||||
describe("btw inline message", () => {
|
||||
it("renders the BTW question, answer, and dismiss hint inline", () => {
|
||||
const message = new BtwInlineMessage({
|
||||
question: "what is 17 * 19?",
|
||||
text: "323",
|
||||
});
|
||||
|
||||
const rendered = message.render(80).join("\n");
|
||||
expect(rendered).toContain("BTW: what is 17 * 19?");
|
||||
expect(rendered).toContain("323");
|
||||
expect(rendered).toContain("Press Enter or Esc to dismiss");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { AssistantMessageComponent } from "./assistant-message.js";
|
||||
|
||||
type BtwInlineMessageParams = {
|
||||
question: string;
|
||||
text: string;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
export class BtwInlineMessage extends Container {
|
||||
constructor(params: BtwInlineMessageParams) {
|
||||
super();
|
||||
this.setResult(params);
|
||||
}
|
||||
|
||||
setResult(params: BtwInlineMessageParams) {
|
||||
this.clear();
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.header(`BTW: ${params.question}`), 1, 0));
|
||||
if (params.isError) {
|
||||
this.addChild(new Text(theme.error(params.text), 1, 0));
|
||||
} else {
|
||||
this.addChild(new AssistantMessageComponent(params.text));
|
||||
}
|
||||
this.addChild(new Text(theme.dim("Press Enter or Esc to dismiss"), 1, 0));
|
||||
}
|
||||
}
|
||||
|
|
@ -52,4 +52,25 @@ describe("ChatLog", () => {
|
|||
|
||||
expect(chatLog.children.length).toBe(20);
|
||||
});
|
||||
|
||||
it("renders BTW inline and removes it when dismissed", () => {
|
||||
const chatLog = new ChatLog(40);
|
||||
|
||||
chatLog.addSystem("session agent:main:main");
|
||||
chatLog.showBtw({
|
||||
question: "what is 17 * 19?",
|
||||
text: "323",
|
||||
});
|
||||
|
||||
let rendered = chatLog.render(120).join("\n");
|
||||
expect(rendered).toContain("BTW: what is 17 * 19?");
|
||||
expect(rendered).toContain("323");
|
||||
expect(chatLog.hasVisibleBtw()).toBe(true);
|
||||
|
||||
chatLog.dismissBtw();
|
||||
|
||||
rendered = chatLog.render(120).join("\n");
|
||||
expect(rendered).not.toContain("BTW: what is 17 * 19?");
|
||||
expect(chatLog.hasVisibleBtw()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Component } from "@mariozechner/pi-tui";
|
|||
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { AssistantMessageComponent } from "./assistant-message.js";
|
||||
import { BtwInlineMessage } from "./btw-inline-message.js";
|
||||
import { ToolExecutionComponent } from "./tool-execution.js";
|
||||
import { UserMessageComponent } from "./user-message.js";
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ export class ChatLog extends Container {
|
|||
private readonly maxComponents: number;
|
||||
private toolById = new Map<string, ToolExecutionComponent>();
|
||||
private streamingRuns = new Map<string, AssistantMessageComponent>();
|
||||
private btwMessage: BtwInlineMessage | null = null;
|
||||
private toolsExpanded = false;
|
||||
|
||||
constructor(maxComponents = 180) {
|
||||
|
|
@ -27,6 +29,9 @@ export class ChatLog extends Container {
|
|||
this.streamingRuns.delete(runId);
|
||||
}
|
||||
}
|
||||
if (this.btwMessage === component) {
|
||||
this.btwMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
private pruneOverflow() {
|
||||
|
|
@ -49,6 +54,7 @@ export class ChatLog extends Container {
|
|||
this.clear();
|
||||
this.toolById.clear();
|
||||
this.streamingRuns.clear();
|
||||
this.btwMessage = null;
|
||||
}
|
||||
|
||||
addSystem(text: string) {
|
||||
|
|
@ -108,6 +114,33 @@ export class ChatLog extends Container {
|
|||
this.streamingRuns.delete(effectiveRunId);
|
||||
}
|
||||
|
||||
showBtw(params: { question: string; text: string; isError?: boolean }) {
|
||||
if (this.btwMessage) {
|
||||
this.btwMessage.setResult(params);
|
||||
if (this.children[this.children.length - 1] !== this.btwMessage) {
|
||||
this.removeChild(this.btwMessage);
|
||||
this.append(this.btwMessage);
|
||||
}
|
||||
return this.btwMessage;
|
||||
}
|
||||
const component = new BtwInlineMessage(params);
|
||||
this.btwMessage = component;
|
||||
this.append(component);
|
||||
return component;
|
||||
}
|
||||
|
||||
dismissBtw() {
|
||||
if (!this.btwMessage) {
|
||||
return;
|
||||
}
|
||||
this.removeChild(this.btwMessage);
|
||||
this.btwMessage = null;
|
||||
}
|
||||
|
||||
hasVisibleBtw() {
|
||||
return this.btwMessage !== null;
|
||||
}
|
||||
|
||||
startTool(toolCallId: string, toolName: string, args: unknown) {
|
||||
const existing = this.toolById.get(toolCallId);
|
||||
if (existing) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ function createHarness(params?: {
|
|||
loadHistory?: LoadHistoryMock;
|
||||
setActivityStatus?: SetActivityStatusMock;
|
||||
isConnected?: boolean;
|
||||
activeChatRunId?: string | null;
|
||||
}) {
|
||||
const sendChat = params?.sendChat ?? vi.fn().mockResolvedValue({ runId: "r1" });
|
||||
const resetSession = params?.resetSession ?? vi.fn().mockResolvedValue({ ok: true });
|
||||
|
|
@ -19,21 +20,24 @@ function createHarness(params?: {
|
|||
const addUser = vi.fn();
|
||||
const addSystem = vi.fn();
|
||||
const requestRender = vi.fn();
|
||||
const noteLocalRunId = vi.fn();
|
||||
const noteLocalBtwRunId = vi.fn();
|
||||
const loadHistory =
|
||||
params?.loadHistory ?? (vi.fn().mockResolvedValue(undefined) as LoadHistoryMock);
|
||||
const setActivityStatus = params?.setActivityStatus ?? (vi.fn() as SetActivityStatusMock);
|
||||
const state = {
|
||||
currentSessionKey: "agent:main:main",
|
||||
activeChatRunId: params?.activeChatRunId ?? null,
|
||||
isConnected: params?.isConnected ?? true,
|
||||
sessionInfo: {},
|
||||
};
|
||||
|
||||
const { handleCommand } = createCommandHandlers({
|
||||
client: { sendChat, resetSession } as never,
|
||||
chatLog: { addUser, addSystem } as never,
|
||||
tui: { requestRender } as never,
|
||||
opts: {},
|
||||
state: {
|
||||
currentSessionKey: "agent:main:main",
|
||||
activeChatRunId: null,
|
||||
isConnected: params?.isConnected ?? true,
|
||||
sessionInfo: {},
|
||||
} as never,
|
||||
state: state as never,
|
||||
deliverDefault: false,
|
||||
openOverlay: vi.fn(),
|
||||
closeOverlay: vi.fn(),
|
||||
|
|
@ -45,8 +49,10 @@ function createHarness(params?: {
|
|||
setActivityStatus,
|
||||
formatSessionKey: vi.fn(),
|
||||
applySessionInfoFromPatch: vi.fn(),
|
||||
noteLocalRunId: vi.fn(),
|
||||
noteLocalRunId,
|
||||
noteLocalBtwRunId,
|
||||
forgetLocalRunId: vi.fn(),
|
||||
forgetLocalBtwRunId: vi.fn(),
|
||||
requestExit: vi.fn(),
|
||||
});
|
||||
|
||||
|
|
@ -60,6 +66,9 @@ function createHarness(params?: {
|
|||
requestRender,
|
||||
loadHistory,
|
||||
setActivityStatus,
|
||||
noteLocalRunId,
|
||||
noteLocalBtwRunId,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -108,6 +117,29 @@ describe("tui command handlers", () => {
|
|||
expect(requestRender).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends /btw without hijacking the active main run", async () => {
|
||||
const setActivityStatus = vi.fn();
|
||||
const { handleCommand, sendChat, addUser, noteLocalRunId, noteLocalBtwRunId, state } =
|
||||
createHarness({
|
||||
activeChatRunId: "run-main",
|
||||
setActivityStatus,
|
||||
});
|
||||
|
||||
await handleCommand("/btw what changed?");
|
||||
|
||||
expect(addUser).not.toHaveBeenCalled();
|
||||
expect(noteLocalRunId).not.toHaveBeenCalled();
|
||||
expect(noteLocalBtwRunId).toHaveBeenCalledTimes(1);
|
||||
expect(state.activeChatRunId).toBe("run-main");
|
||||
expect(setActivityStatus).not.toHaveBeenCalledWith("sending");
|
||||
expect(setActivityStatus).not.toHaveBeenCalledWith("waiting");
|
||||
expect(sendChat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "/btw what changed?",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates unique session for /new and resets shared session for /reset", async () => {
|
||||
const loadHistory = vi.fn().mockResolvedValue(undefined);
|
||||
const setSessionMock = vi.fn().mockResolvedValue(undefined) as SetSessionMock;
|
||||
|
|
|
|||
|
|
@ -43,10 +43,16 @@ type CommandHandlerContext = {
|
|||
formatSessionKey: (key: string) => string;
|
||||
applySessionInfoFromPatch: (result: SessionsPatchResult) => void;
|
||||
noteLocalRunId: (runId: string) => void;
|
||||
noteLocalBtwRunId?: (runId: string) => void;
|
||||
forgetLocalRunId?: (runId: string) => void;
|
||||
forgetLocalBtwRunId?: (runId: string) => void;
|
||||
requestExit: () => void;
|
||||
};
|
||||
|
||||
function isBtwCommand(text: string): boolean {
|
||||
return /^\/btw(?::|\s|$)/i.test(text.trim());
|
||||
}
|
||||
|
||||
export function createCommandHandlers(context: CommandHandlerContext) {
|
||||
const {
|
||||
client,
|
||||
|
|
@ -66,7 +72,9 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||
formatSessionKey,
|
||||
applySessionInfoFromPatch,
|
||||
noteLocalRunId,
|
||||
noteLocalBtwRunId,
|
||||
forgetLocalRunId,
|
||||
forgetLocalBtwRunId,
|
||||
requestExit,
|
||||
} = context;
|
||||
|
||||
|
|
@ -501,13 +509,17 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
const isBtw = isBtwCommand(text);
|
||||
const runId = randomUUID();
|
||||
try {
|
||||
chatLog.addUser(text);
|
||||
tui.requestRender();
|
||||
const runId = randomUUID();
|
||||
noteLocalRunId(runId);
|
||||
state.activeChatRunId = runId;
|
||||
setActivityStatus("sending");
|
||||
if (!isBtw) {
|
||||
chatLog.addUser(text);
|
||||
noteLocalRunId(runId);
|
||||
state.activeChatRunId = runId;
|
||||
setActivityStatus("sending");
|
||||
} else {
|
||||
noteLocalBtwRunId?.(runId);
|
||||
}
|
||||
tui.requestRender();
|
||||
await client.sendChat({
|
||||
sessionKey: state.currentSessionKey,
|
||||
|
|
@ -517,15 +529,24 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||
timeoutMs: opts.timeoutMs,
|
||||
runId,
|
||||
});
|
||||
setActivityStatus("waiting");
|
||||
tui.requestRender();
|
||||
if (!isBtw) {
|
||||
setActivityStatus("waiting");
|
||||
tui.requestRender();
|
||||
}
|
||||
} catch (err) {
|
||||
if (state.activeChatRunId) {
|
||||
if (isBtw) {
|
||||
forgetLocalBtwRunId?.(runId);
|
||||
}
|
||||
if (!isBtw && state.activeChatRunId) {
|
||||
forgetLocalRunId?.(state.activeChatRunId);
|
||||
}
|
||||
state.activeChatRunId = null;
|
||||
chatLog.addSystem(`send failed: ${String(err)}`);
|
||||
setActivityStatus("error");
|
||||
if (!isBtw) {
|
||||
state.activeChatRunId = null;
|
||||
}
|
||||
chatLog.addSystem(`${isBtw ? "btw failed" : "send failed"}: ${String(err)}`);
|
||||
if (!isBtw) {
|
||||
setActivityStatus("error");
|
||||
}
|
||||
tui.requestRender();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createEventHandlers } from "./tui-event-handlers.js";
|
||||
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
|
||||
import type { AgentEvent, BtwEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
|
||||
|
||||
type MockFn = ReturnType<typeof vi.fn>;
|
||||
type HandlerChatLog = {
|
||||
|
|
@ -11,6 +11,10 @@ type HandlerChatLog = {
|
|||
finalizeAssistant: (...args: unknown[]) => void;
|
||||
dropAssistant: (...args: unknown[]) => void;
|
||||
};
|
||||
type HandlerBtwPresenter = {
|
||||
showResult: (...args: unknown[]) => void;
|
||||
clear: (...args: unknown[]) => void;
|
||||
};
|
||||
type HandlerTui = { requestRender: (...args: unknown[]) => void };
|
||||
type MockChatLog = {
|
||||
startTool: MockFn;
|
||||
|
|
@ -20,6 +24,10 @@ type MockChatLog = {
|
|||
finalizeAssistant: MockFn;
|
||||
dropAssistant: MockFn;
|
||||
};
|
||||
type MockBtwPresenter = {
|
||||
showResult: MockFn;
|
||||
clear: MockFn;
|
||||
};
|
||||
type MockTui = { requestRender: MockFn };
|
||||
|
||||
function createMockChatLog(): MockChatLog & HandlerChatLog {
|
||||
|
|
@ -33,6 +41,13 @@ function createMockChatLog(): MockChatLog & HandlerChatLog {
|
|||
} as unknown as MockChatLog & HandlerChatLog;
|
||||
}
|
||||
|
||||
function createMockBtwPresenter(): MockBtwPresenter & HandlerBtwPresenter {
|
||||
return {
|
||||
showResult: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
} as unknown as MockBtwPresenter & HandlerBtwPresenter;
|
||||
}
|
||||
|
||||
describe("tui-event-handlers: handleAgentEvent", () => {
|
||||
const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({
|
||||
agentDefaultId: "main",
|
||||
|
|
@ -59,50 +74,69 @@ describe("tui-event-handlers: handleAgentEvent", () => {
|
|||
|
||||
const makeContext = (state: TuiStateAccess) => {
|
||||
const chatLog = createMockChatLog();
|
||||
const btw = createMockBtwPresenter();
|
||||
const tui = { requestRender: vi.fn() } as unknown as MockTui & HandlerTui;
|
||||
const setActivityStatus = vi.fn();
|
||||
const loadHistory = vi.fn();
|
||||
const localRunIds = new Set<string>();
|
||||
const localBtwRunIds = new Set<string>();
|
||||
const noteLocalRunId = (runId: string) => {
|
||||
localRunIds.add(runId);
|
||||
};
|
||||
const forgetLocalRunId = localRunIds.delete.bind(localRunIds);
|
||||
const isLocalRunId = localRunIds.has.bind(localRunIds);
|
||||
const clearLocalRunIds = localRunIds.clear.bind(localRunIds);
|
||||
const noteLocalBtwRunId = (runId: string) => {
|
||||
localBtwRunIds.add(runId);
|
||||
};
|
||||
const forgetLocalBtwRunId = localBtwRunIds.delete.bind(localBtwRunIds);
|
||||
const isLocalBtwRunId = localBtwRunIds.has.bind(localBtwRunIds);
|
||||
const clearLocalBtwRunIds = localBtwRunIds.clear.bind(localBtwRunIds);
|
||||
|
||||
return {
|
||||
chatLog,
|
||||
btw,
|
||||
tui,
|
||||
state,
|
||||
setActivityStatus,
|
||||
loadHistory,
|
||||
noteLocalRunId,
|
||||
noteLocalBtwRunId,
|
||||
forgetLocalRunId,
|
||||
isLocalRunId,
|
||||
clearLocalRunIds,
|
||||
forgetLocalBtwRunId,
|
||||
isLocalBtwRunId,
|
||||
clearLocalBtwRunIds,
|
||||
};
|
||||
};
|
||||
|
||||
const createHandlersHarness = (params?: {
|
||||
state?: Partial<TuiStateAccess>;
|
||||
chatLog?: HandlerChatLog;
|
||||
btw?: HandlerBtwPresenter;
|
||||
}) => {
|
||||
const state = makeState(params?.state);
|
||||
const context = makeContext(state);
|
||||
const chatLog = (params?.chatLog ?? context.chatLog) as MockChatLog & HandlerChatLog;
|
||||
const handlers = createEventHandlers({
|
||||
chatLog,
|
||||
btw: (params?.btw ?? context.btw) as MockBtwPresenter & HandlerBtwPresenter,
|
||||
tui: context.tui,
|
||||
state,
|
||||
setActivityStatus: context.setActivityStatus,
|
||||
loadHistory: context.loadHistory,
|
||||
isLocalRunId: context.isLocalRunId,
|
||||
forgetLocalRunId: context.forgetLocalRunId,
|
||||
isLocalBtwRunId: context.isLocalBtwRunId,
|
||||
forgetLocalBtwRunId: context.forgetLocalBtwRunId,
|
||||
clearLocalBtwRunIds: context.clearLocalBtwRunIds,
|
||||
});
|
||||
return {
|
||||
...context,
|
||||
state,
|
||||
chatLog,
|
||||
btw: (params?.btw ?? context.btw) as MockBtwPresenter & HandlerBtwPresenter,
|
||||
...handlers,
|
||||
};
|
||||
};
|
||||
|
|
@ -212,6 +246,62 @@ describe("tui-event-handlers: handleAgentEvent", () => {
|
|||
expect(chatLog.updateAssistant).toHaveBeenCalledWith("hello", "run-alias");
|
||||
});
|
||||
|
||||
it("renders BTW results separately without disturbing the active run", () => {
|
||||
const { state, btw, setActivityStatus, loadHistory, tui, handleBtwEvent } =
|
||||
createHandlersHarness({
|
||||
state: { activeChatRunId: "run-main" },
|
||||
});
|
||||
|
||||
const evt: BtwEvent = {
|
||||
kind: "btw",
|
||||
runId: "run-btw",
|
||||
sessionKey: state.currentSessionKey,
|
||||
question: "what changed?",
|
||||
text: "nothing important",
|
||||
};
|
||||
|
||||
handleBtwEvent(evt);
|
||||
|
||||
expect(state.activeChatRunId).toBe("run-main");
|
||||
expect(btw.showResult).toHaveBeenCalledWith({
|
||||
question: "what changed?",
|
||||
text: "nothing important",
|
||||
isError: undefined,
|
||||
});
|
||||
expect(setActivityStatus).not.toHaveBeenCalled();
|
||||
expect(loadHistory).not.toHaveBeenCalled();
|
||||
expect(tui.requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a local BTW result visible when its empty final chat event arrives", () => {
|
||||
const { state, btw, loadHistory, noteLocalBtwRunId, handleBtwEvent, handleChatEvent } =
|
||||
createHandlersHarness({
|
||||
state: { activeChatRunId: null },
|
||||
});
|
||||
|
||||
noteLocalBtwRunId("run-btw");
|
||||
handleBtwEvent({
|
||||
kind: "btw",
|
||||
runId: "run-btw",
|
||||
sessionKey: state.currentSessionKey,
|
||||
question: "what changed?",
|
||||
text: "nothing important",
|
||||
} satisfies BtwEvent);
|
||||
|
||||
handleChatEvent({
|
||||
runId: "run-btw",
|
||||
sessionKey: state.currentSessionKey,
|
||||
state: "final",
|
||||
} satisfies ChatEvent);
|
||||
|
||||
expect(loadHistory).not.toHaveBeenCalled();
|
||||
expect(btw.showResult).toHaveBeenCalledWith({
|
||||
question: "what changed?",
|
||||
text: "nothing important",
|
||||
isError: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not cross-match canonical session keys from different agents", () => {
|
||||
const { chatLog, handleChatEvent } = createHandlersHarness({
|
||||
state: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
|
||||
import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js";
|
||||
import { TuiStreamAssembler } from "./tui-stream-assembler.js";
|
||||
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
|
||||
import type { AgentEvent, BtwEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
|
||||
|
||||
type EventHandlerChatLog = {
|
||||
startTool: (toolCallId: string, toolName: string, args: unknown) => void;
|
||||
|
|
@ -20,8 +20,14 @@ type EventHandlerTui = {
|
|||
requestRender: () => void;
|
||||
};
|
||||
|
||||
type EventHandlerBtwPresenter = {
|
||||
showResult: (params: { question: string; text: string; isError?: boolean }) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
type EventHandlerContext = {
|
||||
chatLog: EventHandlerChatLog;
|
||||
btw: EventHandlerBtwPresenter;
|
||||
tui: EventHandlerTui;
|
||||
state: TuiStateAccess;
|
||||
setActivityStatus: (text: string) => void;
|
||||
|
|
@ -30,11 +36,15 @@ type EventHandlerContext = {
|
|||
isLocalRunId?: (runId: string) => boolean;
|
||||
forgetLocalRunId?: (runId: string) => void;
|
||||
clearLocalRunIds?: () => void;
|
||||
isLocalBtwRunId?: (runId: string) => boolean;
|
||||
forgetLocalBtwRunId?: (runId: string) => void;
|
||||
clearLocalBtwRunIds?: () => void;
|
||||
};
|
||||
|
||||
export function createEventHandlers(context: EventHandlerContext) {
|
||||
const {
|
||||
chatLog,
|
||||
btw,
|
||||
tui,
|
||||
state,
|
||||
setActivityStatus,
|
||||
|
|
@ -43,6 +53,9 @@ export function createEventHandlers(context: EventHandlerContext) {
|
|||
isLocalRunId,
|
||||
forgetLocalRunId,
|
||||
clearLocalRunIds,
|
||||
isLocalBtwRunId,
|
||||
forgetLocalBtwRunId,
|
||||
clearLocalBtwRunIds,
|
||||
} = context;
|
||||
const finalizedRuns = new Map<string, number>();
|
||||
const sessionRuns = new Map<string, number>();
|
||||
|
|
@ -81,6 +94,8 @@ export function createEventHandlers(context: EventHandlerContext) {
|
|||
sessionRuns.clear();
|
||||
streamAssembler = new TuiStreamAssembler();
|
||||
clearLocalRunIds?.();
|
||||
clearLocalBtwRunIds?.();
|
||||
btw.clear();
|
||||
};
|
||||
|
||||
const noteSessionRun = (runId: string) => {
|
||||
|
|
@ -194,7 +209,7 @@ export function createEventHandlers(context: EventHandlerContext) {
|
|||
}
|
||||
}
|
||||
noteSessionRun(evt.runId);
|
||||
if (!state.activeChatRunId) {
|
||||
if (!state.activeChatRunId && !isLocalBtwRunId?.(evt.runId)) {
|
||||
state.activeChatRunId = evt.runId;
|
||||
}
|
||||
if (evt.state === "delta") {
|
||||
|
|
@ -206,7 +221,14 @@ export function createEventHandlers(context: EventHandlerContext) {
|
|||
setActivityStatus("streaming");
|
||||
}
|
||||
if (evt.state === "final") {
|
||||
const isLocalBtwRun = isLocalBtwRunId?.(evt.runId) ?? false;
|
||||
const wasActiveRun = state.activeChatRunId === evt.runId;
|
||||
if (!evt.message && isLocalBtwRun) {
|
||||
forgetLocalBtwRunId?.(evt.runId);
|
||||
noteFinalizedRun(evt.runId);
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (!evt.message) {
|
||||
maybeRefreshHistoryForRun(evt.runId, {
|
||||
allowLocalWithoutDisplayableFinal: true,
|
||||
|
|
@ -254,12 +276,14 @@ export function createEventHandlers(context: EventHandlerContext) {
|
|||
});
|
||||
}
|
||||
if (evt.state === "aborted") {
|
||||
forgetLocalBtwRunId?.(evt.runId);
|
||||
const wasActiveRun = state.activeChatRunId === evt.runId;
|
||||
chatLog.addSystem("run aborted");
|
||||
terminateRun({ runId: evt.runId, wasActiveRun, status: "aborted" });
|
||||
maybeRefreshHistoryForRun(evt.runId);
|
||||
}
|
||||
if (evt.state === "error") {
|
||||
forgetLocalBtwRunId?.(evt.runId);
|
||||
const wasActiveRun = state.activeChatRunId === evt.runId;
|
||||
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
|
||||
terminateRun({ runId: evt.runId, wasActiveRun, status: "error" });
|
||||
|
|
@ -335,5 +359,30 @@ export function createEventHandlers(context: EventHandlerContext) {
|
|||
}
|
||||
};
|
||||
|
||||
return { handleChatEvent, handleAgentEvent };
|
||||
const handleBtwEvent = (payload: unknown) => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return;
|
||||
}
|
||||
const evt = payload as BtwEvent;
|
||||
syncSessionKey();
|
||||
if (!isSameSessionKey(evt.sessionKey, state.currentSessionKey)) {
|
||||
return;
|
||||
}
|
||||
if (evt.kind !== "btw") {
|
||||
return;
|
||||
}
|
||||
const question = evt.question.trim();
|
||||
const text = evt.text.trim();
|
||||
if (!question || !text) {
|
||||
return;
|
||||
}
|
||||
btw.showResult({
|
||||
question,
|
||||
text,
|
||||
isError: evt.isError,
|
||||
});
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
return { handleChatEvent, handleAgentEvent, handleBtwEvent };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import { createSessionActions } from "./tui-session-actions.js";
|
|||
import type { TuiStateAccess } from "./tui-types.js";
|
||||
|
||||
describe("tui session actions", () => {
|
||||
const createBtwPresenter = () => ({
|
||||
clear: vi.fn(),
|
||||
showResult: vi.fn(),
|
||||
});
|
||||
|
||||
it("queues session refreshes and applies the latest result", async () => {
|
||||
let resolveFirst: ((value: unknown) => void) | undefined;
|
||||
let resolveSecond: ((value: unknown) => void) | undefined;
|
||||
|
|
@ -52,6 +57,7 @@ describe("tui session actions", () => {
|
|||
const { refreshSessionInfo } = createSessionActions({
|
||||
client: { listSessions } as unknown as GatewayChatClient,
|
||||
chatLog: { addSystem: vi.fn() } as unknown as import("./components/chat-log.js").ChatLog,
|
||||
btw: createBtwPresenter(),
|
||||
tui: { requestRender } as unknown as import("@mariozechner/pi-tui").TUI,
|
||||
opts: {},
|
||||
state,
|
||||
|
|
@ -157,6 +163,7 @@ describe("tui session actions", () => {
|
|||
const { applySessionInfoFromPatch, refreshSessionInfo } = createSessionActions({
|
||||
client: { listSessions } as unknown as GatewayChatClient,
|
||||
chatLog: { addSystem: vi.fn() } as unknown as import("./components/chat-log.js").ChatLog,
|
||||
btw: createBtwPresenter(),
|
||||
tui: { requestRender: vi.fn() } as unknown as import("@mariozechner/pi-tui").TUI,
|
||||
opts: {},
|
||||
state,
|
||||
|
|
@ -211,6 +218,7 @@ describe("tui session actions", () => {
|
|||
sessionId: "session-2",
|
||||
messages: [],
|
||||
});
|
||||
const btw = createBtwPresenter();
|
||||
|
||||
const state: TuiStateAccess = {
|
||||
agentDefaultId: "main",
|
||||
|
|
@ -247,6 +255,7 @@ describe("tui session actions", () => {
|
|||
addSystem: vi.fn(),
|
||||
clearAll: vi.fn(),
|
||||
} as unknown as import("./components/chat-log.js").ChatLog,
|
||||
btw,
|
||||
tui: { requestRender: vi.fn() } as unknown as import("@mariozechner/pi-tui").TUI,
|
||||
opts: {},
|
||||
state,
|
||||
|
|
@ -270,5 +279,6 @@ describe("tui session actions", () => {
|
|||
expect(state.sessionInfo.model).toBe("session-model");
|
||||
expect(state.sessionInfo.modelProvider).toBe("openai");
|
||||
expect(state.sessionInfo.updatedAt).toBe(50);
|
||||
expect(btw.clear).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,9 +10,14 @@ import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js";
|
|||
import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js";
|
||||
import type { SessionInfo, TuiOptions, TuiStateAccess } from "./tui-types.js";
|
||||
|
||||
type SessionActionBtwPresenter = {
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
type SessionActionContext = {
|
||||
client: GatewayChatClient;
|
||||
chatLog: ChatLog;
|
||||
btw: SessionActionBtwPresenter;
|
||||
tui: TUI;
|
||||
opts: TuiOptions;
|
||||
state: TuiStateAccess;
|
||||
|
|
@ -42,6 +47,7 @@ export function createSessionActions(context: SessionActionContext) {
|
|||
const {
|
||||
client,
|
||||
chatLog,
|
||||
btw,
|
||||
tui,
|
||||
opts,
|
||||
state,
|
||||
|
|
@ -298,6 +304,7 @@ export function createSessionActions(context: SessionActionContext) {
|
|||
state.sessionInfo.verboseLevel = record.verboseLevel ?? state.sessionInfo.verboseLevel;
|
||||
const showTools = (state.sessionInfo.verboseLevel ?? "off") !== "off";
|
||||
chatLog.clearAll();
|
||||
btw.clear();
|
||||
chatLog.addSystem(`session ${state.currentSessionKey}`);
|
||||
for (const entry of record.messages ?? []) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
|
|
@ -367,6 +374,7 @@ export function createSessionActions(context: SessionActionContext) {
|
|||
state.sessionInfo.updatedAt = null;
|
||||
state.historyLoaded = false;
|
||||
clearLocalRunIds?.();
|
||||
btw.clear();
|
||||
updateHeader();
|
||||
updateFooter();
|
||||
await loadHistory();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,17 @@ export type ChatEvent = {
|
|||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export type BtwEvent = {
|
||||
kind: "btw";
|
||||
runId?: string;
|
||||
sessionKey?: string;
|
||||
question: string;
|
||||
text: string;
|
||||
isError?: boolean;
|
||||
seq?: number;
|
||||
ts?: number;
|
||||
};
|
||||
|
||||
export type AgentEvent = {
|
||||
runId: string;
|
||||
stream: string;
|
||||
|
|
|
|||
|
|
@ -344,6 +344,7 @@ export async function runTui(opts: TuiOptions) {
|
|||
let showThinking = false;
|
||||
let pairingHintShown = false;
|
||||
const localRunIds = new Set<string>();
|
||||
const localBtwRunIds = new Set<string>();
|
||||
|
||||
const deliverDefault = opts.deliver ?? false;
|
||||
const autoMessage = opts.message?.trim();
|
||||
|
|
@ -498,6 +499,29 @@ export async function runTui(opts: TuiOptions) {
|
|||
localRunIds.clear();
|
||||
};
|
||||
|
||||
const noteLocalBtwRunId = (runId: string) => {
|
||||
if (!runId) {
|
||||
return;
|
||||
}
|
||||
localBtwRunIds.add(runId);
|
||||
if (localBtwRunIds.size > 200) {
|
||||
const [first] = localBtwRunIds;
|
||||
if (first) {
|
||||
localBtwRunIds.delete(first);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const forgetLocalBtwRunId = (runId: string) => {
|
||||
localBtwRunIds.delete(runId);
|
||||
};
|
||||
|
||||
const isLocalBtwRunId = (runId: string) => localBtwRunIds.has(runId);
|
||||
|
||||
const clearLocalBtwRunIds = () => {
|
||||
localBtwRunIds.clear();
|
||||
};
|
||||
|
||||
const client = await GatewayChatClient.connect({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
|
|
@ -771,6 +795,14 @@ export async function runTui(opts: TuiOptions) {
|
|||
};
|
||||
|
||||
const { openOverlay, closeOverlay } = createOverlayHandlers(tui, editor);
|
||||
const btw = {
|
||||
showResult: (params: { question: string; text: string; isError?: boolean }) => {
|
||||
chatLog.showBtw(params);
|
||||
},
|
||||
clear: () => {
|
||||
chatLog.dismissBtw();
|
||||
},
|
||||
};
|
||||
|
||||
const initialSessionAgentId = (() => {
|
||||
if (!initialSessionInput) {
|
||||
|
|
@ -783,6 +815,7 @@ export async function runTui(opts: TuiOptions) {
|
|||
const sessionActions = createSessionActions({
|
||||
client,
|
||||
chatLog,
|
||||
btw,
|
||||
tui,
|
||||
opts,
|
||||
state,
|
||||
|
|
@ -805,8 +838,9 @@ export async function runTui(opts: TuiOptions) {
|
|||
abortActive,
|
||||
} = sessionActions;
|
||||
|
||||
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
|
||||
const { handleChatEvent, handleAgentEvent, handleBtwEvent } = createEventHandlers({
|
||||
chatLog,
|
||||
btw,
|
||||
tui,
|
||||
state,
|
||||
setActivityStatus,
|
||||
|
|
@ -815,6 +849,9 @@ export async function runTui(opts: TuiOptions) {
|
|||
isLocalRunId,
|
||||
forgetLocalRunId,
|
||||
clearLocalRunIds,
|
||||
isLocalBtwRunId,
|
||||
forgetLocalBtwRunId,
|
||||
clearLocalBtwRunIds,
|
||||
});
|
||||
|
||||
const requestExit = () => {
|
||||
|
|
@ -846,7 +883,9 @@ export async function runTui(opts: TuiOptions) {
|
|||
setActivityStatus,
|
||||
formatSessionKey,
|
||||
noteLocalRunId,
|
||||
noteLocalBtwRunId,
|
||||
forgetLocalRunId,
|
||||
forgetLocalBtwRunId,
|
||||
requestExit,
|
||||
});
|
||||
|
||||
|
|
@ -869,6 +908,11 @@ export async function runTui(opts: TuiOptions) {
|
|||
});
|
||||
|
||||
editor.onEscape = () => {
|
||||
if (chatLog.hasVisibleBtw()) {
|
||||
chatLog.dismissBtw();
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
void abortActive();
|
||||
};
|
||||
const handleCtrlC = () => {
|
||||
|
|
@ -918,10 +962,28 @@ export async function runTui(opts: TuiOptions) {
|
|||
void loadHistory();
|
||||
};
|
||||
|
||||
tui.addInputListener((data) => {
|
||||
if (!chatLog.hasVisibleBtw()) {
|
||||
return undefined;
|
||||
}
|
||||
if (editor.getText().length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (matchesKey(data, "enter")) {
|
||||
chatLog.dismissBtw();
|
||||
tui.requestRender();
|
||||
return { consume: true };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
client.onEvent = (evt) => {
|
||||
if (evt.event === "chat") {
|
||||
handleChatEvent(evt.payload);
|
||||
}
|
||||
if (evt.event === "chat.side_result") {
|
||||
handleBtwEvent(evt.payload);
|
||||
}
|
||||
if (evt.event === "agent") {
|
||||
handleAgentEvent(evt.payload);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue