Tests: cover agents and fix web defaults
Co-authored-by: RealSid08 <RealSid08@users.noreply.github.com>main
parent
f31e89d5af
commit
ed080ae988
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { CLAUDE_IDENTITY_PREFIX } from "../auto-reply/claude.js";
|
||||||
|
import { OPENCODE_IDENTITY_PREFIX } from "../auto-reply/opencode.js";
|
||||||
|
import { claudeSpec } from "./claude.js";
|
||||||
|
import { codexSpec } from "./codex.js";
|
||||||
|
import { opencodeSpec } from "./opencode.js";
|
||||||
|
import { piSpec } from "./pi.js";
|
||||||
|
|
||||||
|
describe("agent buildArgs + parseOutput helpers", () => {
|
||||||
|
it("claudeSpec injects flags and identity once", () => {
|
||||||
|
const argv = ["claude", "hi"];
|
||||||
|
const built = claudeSpec.buildArgs({
|
||||||
|
argv,
|
||||||
|
bodyIndex: 1,
|
||||||
|
isNewSession: true,
|
||||||
|
sessionId: "sess",
|
||||||
|
sendSystemOnce: false,
|
||||||
|
systemSent: false,
|
||||||
|
identityPrefix: undefined,
|
||||||
|
format: "json",
|
||||||
|
});
|
||||||
|
expect(built).toContain("--output-format");
|
||||||
|
expect(built).toContain("json");
|
||||||
|
expect(built).toContain("-p");
|
||||||
|
expect(built.at(-1)).toContain(CLAUDE_IDENTITY_PREFIX);
|
||||||
|
|
||||||
|
const builtNoIdentity = claudeSpec.buildArgs({
|
||||||
|
argv,
|
||||||
|
bodyIndex: 1,
|
||||||
|
isNewSession: false,
|
||||||
|
sessionId: "sess",
|
||||||
|
sendSystemOnce: true,
|
||||||
|
systemSent: true,
|
||||||
|
identityPrefix: undefined,
|
||||||
|
format: "json",
|
||||||
|
});
|
||||||
|
expect(builtNoIdentity.at(-1)).not.toContain(CLAUDE_IDENTITY_PREFIX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opencodeSpec adds format flag and identity prefix when needed", () => {
|
||||||
|
const argv = ["opencode", "body"];
|
||||||
|
const built = opencodeSpec.buildArgs({
|
||||||
|
argv,
|
||||||
|
bodyIndex: 1,
|
||||||
|
isNewSession: true,
|
||||||
|
sessionId: "sess",
|
||||||
|
sendSystemOnce: false,
|
||||||
|
systemSent: false,
|
||||||
|
identityPrefix: undefined,
|
||||||
|
format: "json",
|
||||||
|
});
|
||||||
|
expect(built).toContain("--format");
|
||||||
|
expect(built).toContain("json");
|
||||||
|
expect(built.at(-1)).toContain(OPENCODE_IDENTITY_PREFIX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("piSpec parses final assistant message and preserves usage meta", () => {
|
||||||
|
const stdout = [
|
||||||
|
'{"type":"message_start","message":{"role":"assistant"}}',
|
||||||
|
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"hello world"}],"usage":{"input":10,"output":5},"model":"pi-1","provider":"inflection","stopReason":"end"}}',
|
||||||
|
].join("\n");
|
||||||
|
const parsed = piSpec.parseOutput(stdout);
|
||||||
|
expect(parsed.text).toBe("hello world");
|
||||||
|
expect(parsed.meta?.provider).toBe("inflection");
|
||||||
|
expect((parsed.meta?.usage as { output?: number })?.output).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("codexSpec parses agent_message and aggregates usage", () => {
|
||||||
|
const stdout = [
|
||||||
|
'{"type":"item.completed","item":{"type":"agent_message","text":"hi there"}}',
|
||||||
|
'{"type":"turn.completed","usage":{"input_tokens":50,"output_tokens":10,"cached_input_tokens":5}}',
|
||||||
|
].join("\n");
|
||||||
|
const parsed = codexSpec.parseOutput(stdout);
|
||||||
|
expect(parsed.text).toBe("hi there");
|
||||||
|
const usage = parsed.meta?.usage as {
|
||||||
|
input?: number;
|
||||||
|
output?: number;
|
||||||
|
cacheRead?: number;
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
expect(usage?.input).toBe(50);
|
||||||
|
expect(usage?.output).toBe(10);
|
||||||
|
expect(usage?.cacheRead).toBe(5);
|
||||||
|
expect(usage?.total).toBe(65);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opencodeSpec parses streamed events and summarizes meta", () => {
|
||||||
|
const stdout = [
|
||||||
|
'{"type":"step_start","timestamp":0}',
|
||||||
|
'{"type":"text","part":{"text":"hi"}}',
|
||||||
|
'{"type":"step_finish","timestamp":1200,"part":{"cost":0.002,"tokens":{"input":100,"output":20}}}',
|
||||||
|
].join("\n");
|
||||||
|
const parsed = opencodeSpec.parseOutput(stdout);
|
||||||
|
expect(parsed.text).toBe("hi");
|
||||||
|
expect(parsed.meta?.extra?.summary).toContain("duration=1200ms");
|
||||||
|
expect(parsed.meta?.extra?.summary).toContain("cost=$0.0020");
|
||||||
|
expect(parsed.meta?.extra?.summary).toContain("tokens=100+20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("codexSpec buildArgs enforces exec/json/sandbox defaults", () => {
|
||||||
|
const argv = ["codex", "hello world"];
|
||||||
|
const built = codexSpec.buildArgs({
|
||||||
|
argv,
|
||||||
|
bodyIndex: 1,
|
||||||
|
isNewSession: true,
|
||||||
|
sessionId: "sess",
|
||||||
|
sendSystemOnce: false,
|
||||||
|
systemSent: false,
|
||||||
|
identityPrefix: undefined,
|
||||||
|
format: "json",
|
||||||
|
});
|
||||||
|
expect(built[1]).toBe("exec");
|
||||||
|
expect(built).toContain("--json");
|
||||||
|
expect(built).toContain("--skip-git-repo-check");
|
||||||
|
expect(built).toContain("read-only");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,16 +3,11 @@ import path from "node:path";
|
||||||
import {
|
import {
|
||||||
CLAUDE_BIN,
|
CLAUDE_BIN,
|
||||||
CLAUDE_IDENTITY_PREFIX,
|
CLAUDE_IDENTITY_PREFIX,
|
||||||
|
type ClaudeJsonParseResult,
|
||||||
parseClaudeJson,
|
parseClaudeJson,
|
||||||
summarizeClaudeMetadata,
|
summarizeClaudeMetadata,
|
||||||
type ClaudeJsonParseResult,
|
|
||||||
} from "../auto-reply/claude.js";
|
} from "../auto-reply/claude.js";
|
||||||
import type {
|
import type { AgentMeta, AgentSpec } from "./types.js";
|
||||||
AgentMeta,
|
|
||||||
AgentParseResult,
|
|
||||||
AgentSpec,
|
|
||||||
BuildArgsContext,
|
|
||||||
} from "./types.js";
|
|
||||||
|
|
||||||
function toMeta(parsed?: ClaudeJsonParseResult): AgentMeta | undefined {
|
function toMeta(parsed?: ClaudeJsonParseResult): AgentMeta | undefined {
|
||||||
if (!parsed?.parsed) return undefined;
|
if (!parsed?.parsed) return undefined;
|
||||||
|
|
@ -22,10 +17,11 @@ function toMeta(parsed?: ClaudeJsonParseResult): AgentMeta | undefined {
|
||||||
|
|
||||||
export const claudeSpec: AgentSpec = {
|
export const claudeSpec: AgentSpec = {
|
||||||
kind: "claude",
|
kind: "claude",
|
||||||
isInvocation: (argv) => argv.length > 0 && path.basename(argv[0]) === CLAUDE_BIN,
|
isInvocation: (argv) =>
|
||||||
|
argv.length > 0 && path.basename(argv[0]) === CLAUDE_BIN,
|
||||||
buildArgs: (ctx) => {
|
buildArgs: (ctx) => {
|
||||||
// Work off a split of "before body" and "after body" so we don't lose the
|
// Split around the body so we can inject flags without losing the body
|
||||||
// body index when inserting flags.
|
// position. This keeps templated prompts intact even when we add flags.
|
||||||
const argv = [...ctx.argv];
|
const argv = [...ctx.argv];
|
||||||
const body = argv[ctx.bodyIndex] ?? "";
|
const body = argv[ctx.bodyIndex] ?? "";
|
||||||
const beforeBody = argv.slice(0, ctx.bodyIndex);
|
const beforeBody = argv.slice(0, ctx.bodyIndex);
|
||||||
|
|
@ -34,14 +30,18 @@ export const claudeSpec: AgentSpec = {
|
||||||
const wantsOutputFormat = typeof ctx.format === "string";
|
const wantsOutputFormat = typeof ctx.format === "string";
|
||||||
if (wantsOutputFormat) {
|
if (wantsOutputFormat) {
|
||||||
const hasOutputFormat = argv.some(
|
const hasOutputFormat = argv.some(
|
||||||
(part) => part === "--output-format" || part.startsWith("--output-format="),
|
(part) =>
|
||||||
|
part === "--output-format" || part.startsWith("--output-format="),
|
||||||
);
|
);
|
||||||
if (!hasOutputFormat) {
|
if (!hasOutputFormat) {
|
||||||
beforeBody.push("--output-format", ctx.format!);
|
const outputFormat = ctx.format ?? "json";
|
||||||
|
beforeBody.push("--output-format", outputFormat);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPrintFlag = argv.some((part) => part === "-p" || part === "--print");
|
const hasPrintFlag = argv.some(
|
||||||
|
(part) => part === "-p" || part === "--print",
|
||||||
|
);
|
||||||
if (!hasPrintFlag) {
|
if (!hasPrintFlag) {
|
||||||
beforeBody.push("-p");
|
beforeBody.push("-p");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { AgentMeta, AgentParseResult, AgentSpec, BuildArgsContext } from "./types.js";
|
import type { AgentMeta, AgentParseResult, AgentSpec } from "./types.js";
|
||||||
|
|
||||||
function parseCodexJson(raw: string): AgentParseResult {
|
function parseCodexJson(raw: string): AgentParseResult {
|
||||||
const lines = raw.split(/\n+/).filter((l) => l.trim().startsWith("{"));
|
const lines = raw.split(/\n+/).filter((l) => l.trim().startsWith("{"));
|
||||||
|
|
@ -9,11 +9,25 @@ function parseCodexJson(raw: string): AgentParseResult {
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
try {
|
try {
|
||||||
const ev = JSON.parse(line) as { type?: string; item?: { type?: string; text?: string }; usage?: unknown };
|
const ev = JSON.parse(line) as {
|
||||||
if (ev.type === "item.completed" && ev.item?.type === "agent_message" && typeof ev.item.text === "string") {
|
type?: string;
|
||||||
|
item?: { type?: string; text?: string };
|
||||||
|
usage?: unknown;
|
||||||
|
};
|
||||||
|
// Codex streams multiple events; capture the last agent_message text and
|
||||||
|
// the final turn usage for cost/telemetry.
|
||||||
|
if (
|
||||||
|
ev.type === "item.completed" &&
|
||||||
|
ev.item?.type === "agent_message" &&
|
||||||
|
typeof ev.item.text === "string"
|
||||||
|
) {
|
||||||
text = ev.item.text;
|
text = ev.item.text;
|
||||||
}
|
}
|
||||||
if (ev.type === "turn.completed" && ev.usage && typeof ev.usage === "object") {
|
if (
|
||||||
|
ev.type === "turn.completed" &&
|
||||||
|
ev.usage &&
|
||||||
|
typeof ev.usage === "object"
|
||||||
|
) {
|
||||||
const u = ev.usage as {
|
const u = ev.usage as {
|
||||||
input_tokens?: number;
|
input_tokens?: number;
|
||||||
cached_input_tokens?: number;
|
cached_input_tokens?: number;
|
||||||
|
|
@ -63,4 +77,3 @@ export const codexSpec: AgentSpec = {
|
||||||
},
|
},
|
||||||
parseOutput: parseCodexJson,
|
parseOutput: parseCodexJson,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,3 @@ export function getAgentSpec(kind: AgentKind): AgentSpec {
|
||||||
}
|
}
|
||||||
|
|
||||||
export { AgentKind, AgentMeta, AgentParseResult } from "./types.js";
|
export { AgentKind, AgentMeta, AgentParseResult } from "./types.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,42 +6,50 @@ import {
|
||||||
parseOpencodeJson,
|
parseOpencodeJson,
|
||||||
summarizeOpencodeMetadata,
|
summarizeOpencodeMetadata,
|
||||||
} from "../auto-reply/opencode.js";
|
} from "../auto-reply/opencode.js";
|
||||||
import type { AgentMeta, AgentParseResult, AgentSpec, BuildArgsContext } from "./types.js";
|
import type { AgentMeta, AgentSpec } from "./types.js";
|
||||||
|
|
||||||
function toMeta(parsed: ReturnType<typeof parseOpencodeJson>): AgentMeta | undefined {
|
function toMeta(
|
||||||
|
parsed: ReturnType<typeof parseOpencodeJson>,
|
||||||
|
): AgentMeta | undefined {
|
||||||
const summary = summarizeOpencodeMetadata(parsed.meta);
|
const summary = summarizeOpencodeMetadata(parsed.meta);
|
||||||
return summary ? { extra: { summary } } : undefined;
|
return summary ? { extra: { summary } } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const opencodeSpec: AgentSpec = {
|
export const opencodeSpec: AgentSpec = {
|
||||||
kind: "opencode",
|
kind: "opencode",
|
||||||
isInvocation: (argv) => argv.length > 0 && path.basename(argv[0]) === OPENCODE_BIN,
|
isInvocation: (argv) =>
|
||||||
|
argv.length > 0 && path.basename(argv[0]) === OPENCODE_BIN,
|
||||||
buildArgs: (ctx) => {
|
buildArgs: (ctx) => {
|
||||||
|
// Split around the body so we can insert flags without losing the prompt.
|
||||||
const argv = [...ctx.argv];
|
const argv = [...ctx.argv];
|
||||||
|
const body = argv[ctx.bodyIndex] ?? "";
|
||||||
|
const beforeBody = argv.slice(0, ctx.bodyIndex);
|
||||||
|
const afterBody = argv.slice(ctx.bodyIndex + 1);
|
||||||
const wantsJson = ctx.format === "json";
|
const wantsJson = ctx.format === "json";
|
||||||
|
|
||||||
// Ensure format json for parsing
|
// Ensure format json for parsing
|
||||||
if (wantsJson) {
|
if (wantsJson) {
|
||||||
const hasFormat = argv.some(
|
const hasFormat = [...beforeBody, body, ...afterBody].some(
|
||||||
(part) => part === "--format" || part.startsWith("--format="),
|
(part) => part === "--format" || part.startsWith("--format="),
|
||||||
);
|
);
|
||||||
if (!hasFormat) {
|
if (!hasFormat) {
|
||||||
const insertBeforeBody = Math.max(argv.length - 1, 0);
|
beforeBody.push("--format", "json");
|
||||||
argv.splice(insertBeforeBody, 0, "--format", "json");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session args default to --session
|
// Session args default to --session
|
||||||
// Identity prefix
|
// Identity prefix
|
||||||
|
// Opencode streams text tokens; we still seed an identity so the agent
|
||||||
|
// keeps context on first turn.
|
||||||
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent);
|
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent);
|
||||||
if (shouldPrependIdentity && argv[ctx.bodyIndex]) {
|
const bodyWithIdentity =
|
||||||
const existingBody = argv[ctx.bodyIndex];
|
shouldPrependIdentity && body
|
||||||
argv[ctx.bodyIndex] = [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, existingBody]
|
? [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, body]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n")
|
||||||
}
|
: body;
|
||||||
|
|
||||||
return argv;
|
return [...beforeBody, bodyWithIdentity, ...afterBody];
|
||||||
},
|
},
|
||||||
parseOutput: (rawStdout) => {
|
parseOutput: (rawStdout) => {
|
||||||
const parsed = parseOpencodeJson(rawStdout);
|
const parsed = parseOpencodeJson(rawStdout);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { AgentMeta, AgentParseResult, AgentSpec, BuildArgsContext } from "./types.js";
|
import type { AgentMeta, AgentParseResult, AgentSpec } from "./types.js";
|
||||||
|
|
||||||
type PiAssistantMessage = {
|
type PiAssistantMessage = {
|
||||||
role?: string;
|
role?: string;
|
||||||
|
|
@ -16,7 +16,11 @@ function parsePiJson(raw: string): AgentParseResult {
|
||||||
let lastMessage: PiAssistantMessage | undefined;
|
let lastMessage: PiAssistantMessage | undefined;
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
try {
|
try {
|
||||||
const ev = JSON.parse(line) as { type?: string; message?: PiAssistantMessage };
|
const ev = JSON.parse(line) as {
|
||||||
|
type?: string;
|
||||||
|
message?: PiAssistantMessage;
|
||||||
|
};
|
||||||
|
// Pi emits a stream; we only care about the terminal assistant message_end.
|
||||||
if (ev.type === "message_end" && ev.message?.role === "assistant") {
|
if (ev.type === "message_end" && ev.message?.role === "assistant") {
|
||||||
lastMessage = ev.message;
|
lastMessage = ev.message;
|
||||||
}
|
}
|
||||||
|
|
@ -50,14 +54,20 @@ export const piSpec: AgentSpec = {
|
||||||
if (!argv.includes("-p") && !argv.includes("--print")) {
|
if (!argv.includes("-p") && !argv.includes("--print")) {
|
||||||
argv.splice(argv.length - 1, 0, "-p");
|
argv.splice(argv.length - 1, 0, "-p");
|
||||||
}
|
}
|
||||||
if (ctx.format === "json" && !argv.includes("--mode") && !argv.some((a) => a === "--mode")) {
|
if (
|
||||||
|
ctx.format === "json" &&
|
||||||
|
!argv.includes("--mode") &&
|
||||||
|
!argv.some((a) => a === "--mode")
|
||||||
|
) {
|
||||||
argv.splice(argv.length - 1, 0, "--mode", "json");
|
argv.splice(argv.length - 1, 0, "--mode", "json");
|
||||||
}
|
}
|
||||||
// Session defaults
|
// Session defaults
|
||||||
// Identity prefix optional; Pi usually doesn't need, but allow
|
// Identity prefix optional; Pi usually doesn't need it, but allow injection
|
||||||
if (!(ctx.sendSystemOnce && ctx.systemSent) && argv[ctx.bodyIndex]) {
|
if (!(ctx.sendSystemOnce && ctx.systemSent) && argv[ctx.bodyIndex]) {
|
||||||
const existingBody = argv[ctx.bodyIndex];
|
const existingBody = argv[ctx.bodyIndex];
|
||||||
argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody].filter(Boolean).join("\n\n");
|
argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
}
|
}
|
||||||
return argv;
|
return argv;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,3 @@ export interface AgentSpec {
|
||||||
buildArgs: (ctx: BuildArgsContext) => string[];
|
buildArgs: (ctx: BuildArgsContext) => string[];
|
||||||
parseOutput: (rawStdout: string) => AgentParseResult;
|
parseOutput: (rawStdout: string) => AgentParseResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,11 @@ describe("runCommandReply", () => {
|
||||||
throw { stdout: "partial output here", killed: true, signal: "SIGKILL" };
|
throw { stdout: "partial output here", killed: true, signal: "SIGKILL" };
|
||||||
});
|
});
|
||||||
const { payload, meta } = await runCommandReply({
|
const { payload, meta } = await runCommandReply({
|
||||||
reply: { mode: "command", command: ["echo", "hi"], agent: { kind: "claude" } },
|
reply: {
|
||||||
|
mode: "command",
|
||||||
|
command: ["echo", "hi"],
|
||||||
|
agent: { kind: "claude" },
|
||||||
|
},
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: false,
|
sendSystemOnce: false,
|
||||||
isNewSession: true,
|
isNewSession: true,
|
||||||
|
|
@ -214,7 +218,12 @@ describe("runCommandReply", () => {
|
||||||
throw { stdout: "", killed: true, signal: "SIGKILL" };
|
throw { stdout: "", killed: true, signal: "SIGKILL" };
|
||||||
});
|
});
|
||||||
const { payload } = await runCommandReply({
|
const { payload } = await runCommandReply({
|
||||||
reply: { mode: "command", command: ["echo", "hi"], cwd: "/tmp/work", agent: { kind: "claude" } },
|
reply: {
|
||||||
|
mode: "command",
|
||||||
|
command: ["echo", "hi"],
|
||||||
|
cwd: "/tmp/work",
|
||||||
|
agent: { kind: "claude" },
|
||||||
|
},
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: false,
|
sendSystemOnce: false,
|
||||||
isNewSession: true,
|
isNewSession: true,
|
||||||
|
|
@ -236,7 +245,12 @@ describe("runCommandReply", () => {
|
||||||
stdout: `hi\nMEDIA:${tmp}\nMEDIA:https://example.com/img.jpg`,
|
stdout: `hi\nMEDIA:${tmp}\nMEDIA:https://example.com/img.jpg`,
|
||||||
});
|
});
|
||||||
const { payload } = await runCommandReply({
|
const { payload } = await runCommandReply({
|
||||||
reply: { mode: "command", command: ["echo", "hi"], mediaMaxMb: 1, agent: { kind: "claude" } },
|
reply: {
|
||||||
|
mode: "command",
|
||||||
|
command: ["echo", "hi"],
|
||||||
|
mediaMaxMb: 1,
|
||||||
|
agent: { kind: "claude" },
|
||||||
|
},
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: false,
|
sendSystemOnce: false,
|
||||||
isNewSession: true,
|
isNewSession: true,
|
||||||
|
|
@ -279,7 +293,11 @@ describe("runCommandReply", () => {
|
||||||
it("captures queue wait metrics in meta", async () => {
|
it("captures queue wait metrics in meta", async () => {
|
||||||
const runner = makeRunner({ stdout: "ok" });
|
const runner = makeRunner({ stdout: "ok" });
|
||||||
const { meta } = await runCommandReply({
|
const { meta } = await runCommandReply({
|
||||||
reply: { mode: "command", command: ["echo", "{{Body}}"], agent: { kind: "claude" } },
|
reply: {
|
||||||
|
mode: "command",
|
||||||
|
command: ["echo", "{{Body}}"],
|
||||||
|
agent: { kind: "claude" },
|
||||||
|
},
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: false,
|
sendSystemOnce: false,
|
||||||
isNewSession: true,
|
isNewSession: true,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { getAgentSpec } from "../agents/index.js";
|
import { type AgentKind, getAgentSpec } from "../agents/index.js";
|
||||||
import type { AgentMeta } from "../agents/types.js";
|
import type { AgentMeta } from "../agents/types.js";
|
||||||
import type { WarelayConfig } from "../config/config.js";
|
import type { WarelayConfig } from "../config/config.js";
|
||||||
import { isVerbose, logVerbose } from "../globals.js";
|
import { isVerbose, logVerbose } from "../globals.js";
|
||||||
|
|
@ -116,7 +116,8 @@ export async function runCommandReply(
|
||||||
throw new Error("reply.command is required for mode=command");
|
throw new Error("reply.command is required for mode=command");
|
||||||
}
|
}
|
||||||
const agentCfg = reply.agent ?? { kind: "claude" };
|
const agentCfg = reply.agent ?? { kind: "claude" };
|
||||||
const agent = getAgentSpec(agentCfg.kind as any);
|
const agentKind: AgentKind = agentCfg.kind ?? "claude";
|
||||||
|
const agent = getAgentSpec(agentKind);
|
||||||
|
|
||||||
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
||||||
const templatePrefix =
|
const templatePrefix =
|
||||||
|
|
@ -142,14 +143,18 @@ export async function runCommandReply(
|
||||||
: ["--session", "{{SessionId}}"];
|
: ["--session", "{{SessionId}}"];
|
||||||
const sessionArgList = (
|
const sessionArgList = (
|
||||||
isNewSession
|
isNewSession
|
||||||
? reply.session.sessionArgNew ?? defaultNew
|
? (reply.session.sessionArgNew ?? defaultNew)
|
||||||
: reply.session.sessionArgResume ?? defaultResume
|
: (reply.session.sessionArgResume ?? defaultResume)
|
||||||
).map((p) => applyTemplate(p, templatingCtx));
|
).map((p) => applyTemplate(p, templatingCtx));
|
||||||
if (sessionArgList.length) {
|
if (sessionArgList.length) {
|
||||||
const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true;
|
const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true;
|
||||||
const insertAt =
|
const insertAt =
|
||||||
insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length;
|
insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length;
|
||||||
argv = [...argv.slice(0, insertAt), ...sessionArgList, ...argv.slice(insertAt)];
|
argv = [
|
||||||
|
...argv.slice(0, insertAt),
|
||||||
|
...sessionArgList,
|
||||||
|
...argv.slice(insertAt),
|
||||||
|
];
|
||||||
bodyIndex = Math.max(argv.length - 1, 0);
|
bodyIndex = Math.max(argv.length - 1, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -198,6 +203,8 @@ export async function runCommandReply(
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = trimmed ? agent.parseOutput(trimmed) : undefined;
|
const parsed = trimmed ? agent.parseOutput(trimmed) : undefined;
|
||||||
|
// Treat empty string as "no content" so we can fall back to the friendly
|
||||||
|
// "(command produced no output)" message instead of echoing raw JSON.
|
||||||
if (parsed && parsed.text !== undefined) {
|
if (parsed && parsed.text !== undefined) {
|
||||||
trimmed = parsed.text.trim();
|
trimmed = parsed.text.trim();
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +230,9 @@ export async function runCommandReply(
|
||||||
`Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`,
|
`Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`,
|
||||||
);
|
);
|
||||||
// Include any partial output or stderr in error message
|
// Include any partial output or stderr in error message
|
||||||
const partialOut = trimmed ? `\n\nOutput: ${trimmed.slice(0, 500)}${trimmed.length > 500 ? "..." : ""}` : "";
|
const partialOut = trimmed
|
||||||
|
? `\n\nOutput: ${trimmed.slice(0, 500)}${trimmed.length > 500 ? "..." : ""}`
|
||||||
|
: "";
|
||||||
const errorText = `⚠️ Command exited with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}${partialOut}`;
|
const errorText = `⚠️ Command exited with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}${partialOut}`;
|
||||||
return {
|
return {
|
||||||
payload: { text: errorText },
|
payload: { text: errorText },
|
||||||
|
|
|
||||||
|
|
@ -102,4 +102,3 @@ export function summarizeOpencodeMetadata(
|
||||||
}
|
}
|
||||||
return parts.length ? parts.join(", ") : undefined;
|
return parts.length ? parts.join(", ") : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import type { CliDeps } from "../cli/deps.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { sendCommand } from "./send.js";
|
import { sendCommand } from "./send.js";
|
||||||
|
|
||||||
|
vi.mock("../web/ipc.js", () => ({
|
||||||
|
sendViaIpc: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,9 @@ export async function sendCommand(
|
||||||
const ipcResult = await sendViaIpc(opts.to, opts.message, opts.media);
|
const ipcResult = await sendViaIpc(opts.to, opts.message, opts.media);
|
||||||
if (ipcResult) {
|
if (ipcResult) {
|
||||||
if (ipcResult.success) {
|
if (ipcResult.success) {
|
||||||
runtime.log(success(`✅ Sent via relay IPC. Message ID: ${ipcResult.messageId}`));
|
runtime.log(
|
||||||
|
success(`✅ Sent via relay IPC. Message ID: ${ipcResult.messageId}`),
|
||||||
|
);
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
|
|
@ -64,7 +66,11 @@ export async function sendCommand(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// IPC failed but relay is running - warn and fall back
|
// IPC failed but relay is running - warn and fall back
|
||||||
runtime.log(info(`IPC send failed (${ipcResult.error}), falling back to direct connection`));
|
runtime.log(
|
||||||
|
info(
|
||||||
|
`IPC send failed (${ipcResult.error}), falling back to direct connection`,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to direct connection (creates new Baileys socket)
|
// Fall back to direct connection (creates new Baileys socket)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
// Import test-helpers FIRST to set up mocks before other imports
|
import "./test-helpers.js";
|
||||||
import {
|
|
||||||
resetBaileysMocks,
|
|
||||||
resetLoadConfigMock,
|
|
||||||
setLoadConfigMock,
|
|
||||||
} from "./test-helpers.js";
|
|
||||||
|
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
@ -13,8 +7,8 @@ import sharp from "sharp";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { WarelayConfig } from "../config/config.js";
|
import type { WarelayConfig } from "../config/config.js";
|
||||||
import * as commandQueue from "../process/command-queue.js";
|
|
||||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||||
|
import * as commandQueue from "../process/command-queue.js";
|
||||||
import {
|
import {
|
||||||
HEARTBEAT_PROMPT,
|
HEARTBEAT_PROMPT,
|
||||||
HEARTBEAT_TOKEN,
|
HEARTBEAT_TOKEN,
|
||||||
|
|
@ -25,8 +19,11 @@ import {
|
||||||
stripHeartbeatToken,
|
stripHeartbeatToken,
|
||||||
} from "./auto-reply.js";
|
} from "./auto-reply.js";
|
||||||
import type { sendMessageWeb } from "./outbound.js";
|
import type { sendMessageWeb } from "./outbound.js";
|
||||||
import * as commandQueue from "../process/command-queue.js";
|
import {
|
||||||
import { getQueueSize } from "../process/command-queue.js";
|
resetBaileysMocks,
|
||||||
|
resetLoadConfigMock,
|
||||||
|
setLoadConfigMock,
|
||||||
|
} from "./test-helpers.js";
|
||||||
|
|
||||||
const makeSessionStore = async (
|
const makeSessionStore = async (
|
||||||
entries: Record<string, unknown> = {},
|
entries: Record<string, unknown> = {},
|
||||||
|
|
@ -535,9 +532,7 @@ describe("web auto-reply", () => {
|
||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
await fs.writeFile(storePath, JSON.stringify({}));
|
await fs.writeFile(storePath, JSON.stringify({}));
|
||||||
|
|
||||||
const queueSpy = vi
|
const queueSpy = vi.spyOn(commandQueue, "getQueueSize").mockReturnValue(2);
|
||||||
.spyOn(commandQueue, "getQueueSize")
|
|
||||||
.mockReturnValue(2);
|
|
||||||
const replyResolver = vi.fn();
|
const replyResolver = vi.fn();
|
||||||
const listenerFactory = vi.fn(async () => {
|
const listenerFactory = vi.fn(async () => {
|
||||||
const onClose = new Promise<void>(() => {
|
const onClose = new Promise<void>(() => {
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,13 @@ import {
|
||||||
import { danger, info, isVerbose, logVerbose, success } from "../globals.js";
|
import { danger, info, isVerbose, logVerbose, success } from "../globals.js";
|
||||||
import { logInfo } from "../logger.js";
|
import { logInfo } from "../logger.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
|
import { enqueueCommand, getQueueSize } from "../process/command-queue.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { normalizeE164 } from "../utils.js";
|
import { normalizeE164 } from "../utils.js";
|
||||||
import { monitorWebInbox } from "./inbound.js";
|
import { monitorWebInbox } from "./inbound.js";
|
||||||
import { sendViaIpc, startIpcServer, stopIpcServer } from "./ipc.js";
|
import { sendViaIpc, startIpcServer, stopIpcServer } from "./ipc.js";
|
||||||
import { loadWebMedia } from "./media.js";
|
import { loadWebMedia } from "./media.js";
|
||||||
import { sendMessageWeb } from "./outbound.js";
|
import { sendMessageWeb } from "./outbound.js";
|
||||||
import { enqueueCommand, getQueueSize } from "../process/command-queue.js";
|
|
||||||
import {
|
import {
|
||||||
computeBackoff,
|
computeBackoff,
|
||||||
newConnectionId,
|
newConnectionId,
|
||||||
|
|
@ -697,9 +697,7 @@ export async function monitorWebProvider(
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
danger(
|
danger(`Failed sending web auto-reply to ${from}: ${String(err)}`),
|
||||||
`Failed sending web auto-reply to ${from}: ${String(err)}`,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -713,7 +711,8 @@ export async function monitorWebProvider(
|
||||||
if (getQueueSize() === 0) {
|
if (getQueueSize() === 0) {
|
||||||
await processBatch(msg.from);
|
await processBatch(msg.from);
|
||||||
} else {
|
} else {
|
||||||
bucket.timer = bucket.timer ?? setTimeout(() => void processBatch(msg.from), 150);
|
bucket.timer =
|
||||||
|
bucket.timer ?? setTimeout(() => void processBatch(msg.from), 150);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -754,7 +753,12 @@ export async function monitorWebProvider(
|
||||||
mediaBuffer = media.buffer;
|
mediaBuffer = media.buffer;
|
||||||
mediaType = media.contentType;
|
mediaType = media.contentType;
|
||||||
}
|
}
|
||||||
const result = await listener.sendMessage(to, message, mediaBuffer, mediaType);
|
const result = await listener.sendMessage(
|
||||||
|
to,
|
||||||
|
message,
|
||||||
|
mediaBuffer,
|
||||||
|
mediaType,
|
||||||
|
);
|
||||||
// Add to echo detection so we don't process our own message
|
// Add to echo detection so we don't process our own message
|
||||||
if (message) {
|
if (message) {
|
||||||
recentlySent.add(message);
|
recentlySent.add(message);
|
||||||
|
|
@ -763,7 +767,10 @@ export async function monitorWebProvider(
|
||||||
if (firstKey) recentlySent.delete(firstKey);
|
if (firstKey) recentlySent.delete(firstKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logInfo(`📤 IPC send to ${to}: ${message.substring(0, 50)}...`, runtime);
|
logInfo(
|
||||||
|
`📤 IPC send to ${to}: ${message.substring(0, 50)}...`,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
// Show typing indicator after send so user knows more may be coming
|
// Show typing indicator after send so user knows more may be coming
|
||||||
try {
|
try {
|
||||||
await listener.sendComposingTo(to);
|
await listener.sendComposingTo(to);
|
||||||
|
|
@ -807,7 +814,10 @@ export async function monitorWebProvider(
|
||||||
|
|
||||||
// Warn if no messages in 30+ minutes
|
// Warn if no messages in 30+ minutes
|
||||||
if (minutesSinceLastMessage && minutesSinceLastMessage > 30) {
|
if (minutesSinceLastMessage && minutesSinceLastMessage > 30) {
|
||||||
heartbeatLogger.warn(logData, "⚠️ web relay heartbeat - no messages in 30+ minutes");
|
heartbeatLogger.warn(
|
||||||
|
logData,
|
||||||
|
"⚠️ web relay heartbeat - no messages in 30+ minutes",
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
heartbeatLogger.info(logData, "web relay heartbeat");
|
heartbeatLogger.info(logData, "web relay heartbeat");
|
||||||
}
|
}
|
||||||
|
|
@ -818,7 +828,9 @@ export async function monitorWebProvider(
|
||||||
if (lastMessageAt) {
|
if (lastMessageAt) {
|
||||||
const timeSinceLastMessage = Date.now() - lastMessageAt;
|
const timeSinceLastMessage = Date.now() - lastMessageAt;
|
||||||
if (timeSinceLastMessage > MESSAGE_TIMEOUT_MS) {
|
if (timeSinceLastMessage > MESSAGE_TIMEOUT_MS) {
|
||||||
const minutesSinceLastMessage = Math.floor(timeSinceLastMessage / 60000);
|
const minutesSinceLastMessage = Math.floor(
|
||||||
|
timeSinceLastMessage / 60000,
|
||||||
|
);
|
||||||
heartbeatLogger.warn(
|
heartbeatLogger.warn(
|
||||||
{
|
{
|
||||||
connectionId,
|
connectionId,
|
||||||
|
|
@ -978,7 +990,11 @@ export async function monitorWebProvider(
|
||||||
// Apply response prefix if configured (same as regular messages)
|
// Apply response prefix if configured (same as regular messages)
|
||||||
let finalText = stripped.text;
|
let finalText = stripped.text;
|
||||||
const responsePrefix = cfg.inbound?.responsePrefix;
|
const responsePrefix = cfg.inbound?.responsePrefix;
|
||||||
if (responsePrefix && finalText && !finalText.startsWith(responsePrefix)) {
|
if (
|
||||||
|
responsePrefix &&
|
||||||
|
finalText &&
|
||||||
|
!finalText.startsWith(responsePrefix)
|
||||||
|
) {
|
||||||
finalText = `${responsePrefix} ${finalText}`;
|
finalText = `${responsePrefix} ${finalText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,13 @@ export async function monitorWebInbox(options: {
|
||||||
const isSamePhone = from === selfE164;
|
const isSamePhone = from === selfE164;
|
||||||
|
|
||||||
if (!isSamePhone && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
if (!isSamePhone && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||||
if (!allowFrom.includes("*") && !allowFrom.map(normalizeE164).includes(from)) {
|
if (
|
||||||
logVerbose(`Blocked unauthorized sender ${from} (not in allowFrom list)`);
|
!allowFrom.includes("*") &&
|
||||||
|
!allowFrom.map(normalizeE164).includes(from)
|
||||||
|
) {
|
||||||
|
logVerbose(
|
||||||
|
`Blocked unauthorized sender ${from} (not in allowFrom list)`,
|
||||||
|
);
|
||||||
continue; // Skip processing entirely
|
continue; // Skip processing entirely
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,13 +78,13 @@ export function startIpcServer(sendHandler: SendHandler): void {
|
||||||
success: true,
|
success: true,
|
||||||
messageId: result.messageId,
|
messageId: result.messageId,
|
||||||
};
|
};
|
||||||
conn.write(JSON.stringify(response) + "\n");
|
conn.write(`${JSON.stringify(response)}\n`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const response: IpcSendResponse = {
|
const response: IpcSendResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
error: String(err),
|
error: String(err),
|
||||||
};
|
};
|
||||||
conn.write(JSON.stringify(response) + "\n");
|
conn.write(`${JSON.stringify(response)}\n`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -93,7 +93,7 @@ export function startIpcServer(sendHandler: SendHandler): void {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Invalid request format",
|
error: "Invalid request format",
|
||||||
};
|
};
|
||||||
conn.write(JSON.stringify(response) + "\n");
|
conn.write(`${JSON.stringify(response)}\n`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -174,7 +174,7 @@ export async function sendViaIpc(
|
||||||
message,
|
message,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
};
|
};
|
||||||
client.write(JSON.stringify(request) + "\n");
|
client.write(`${JSON.stringify(request)}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("data", (data) => {
|
client.on("data", (data) => {
|
||||||
|
|
@ -198,7 +198,7 @@ export async function sendViaIpc(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("error", (err) => {
|
client.on("error", (_err) => {
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
resolved = true;
|
resolved = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,11 @@ describe("web monitor inbox", () => {
|
||||||
type: "notify",
|
type: "notify",
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
key: { id: "unauth1", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
key: {
|
||||||
|
id: "unauth1",
|
||||||
|
fromMe: false,
|
||||||
|
remoteJid: "999@s.whatsapp.net",
|
||||||
|
},
|
||||||
message: { conversation: "unauthorized message" },
|
message: { conversation: "unauthorized message" },
|
||||||
messageTimestamp: 1_700_000_000,
|
messageTimestamp: 1_700_000_000,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue