Tests: cover agents and fix web defaults
Co-authored-by: RealSid08 <RealSid08@users.noreply.github.com>
This commit is contained in:
118
src/agents/agents.test.ts
Normal file
118
src/agents/agents.test.ts
Normal file
@@ -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 {
|
||||
CLAUDE_BIN,
|
||||
CLAUDE_IDENTITY_PREFIX,
|
||||
type ClaudeJsonParseResult,
|
||||
parseClaudeJson,
|
||||
summarizeClaudeMetadata,
|
||||
type ClaudeJsonParseResult,
|
||||
} from "../auto-reply/claude.js";
|
||||
import type {
|
||||
AgentMeta,
|
||||
AgentParseResult,
|
||||
AgentSpec,
|
||||
BuildArgsContext,
|
||||
} from "./types.js";
|
||||
import type { AgentMeta, AgentSpec } from "./types.js";
|
||||
|
||||
function toMeta(parsed?: ClaudeJsonParseResult): AgentMeta | undefined {
|
||||
if (!parsed?.parsed) return undefined;
|
||||
@@ -22,10 +17,11 @@ function toMeta(parsed?: ClaudeJsonParseResult): AgentMeta | undefined {
|
||||
|
||||
export const claudeSpec: AgentSpec = {
|
||||
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) => {
|
||||
// Work off a split of "before body" and "after body" so we don't lose the
|
||||
// body index when inserting flags.
|
||||
// Split around the body so we can inject flags without losing the body
|
||||
// position. This keeps templated prompts intact even when we add flags.
|
||||
const argv = [...ctx.argv];
|
||||
const body = argv[ctx.bodyIndex] ?? "";
|
||||
const beforeBody = argv.slice(0, ctx.bodyIndex);
|
||||
@@ -34,14 +30,18 @@ export const claudeSpec: AgentSpec = {
|
||||
const wantsOutputFormat = typeof ctx.format === "string";
|
||||
if (wantsOutputFormat) {
|
||||
const hasOutputFormat = argv.some(
|
||||
(part) => part === "--output-format" || part.startsWith("--output-format="),
|
||||
(part) =>
|
||||
part === "--output-format" || part.startsWith("--output-format="),
|
||||
);
|
||||
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) {
|
||||
beforeBody.push("-p");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 {
|
||||
const lines = raw.split(/\n+/).filter((l) => l.trim().startsWith("{"));
|
||||
@@ -9,11 +9,25 @@ function parseCodexJson(raw: string): AgentParseResult {
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const ev = JSON.parse(line) as { type?: string; item?: { type?: string; text?: string }; usage?: unknown };
|
||||
if (ev.type === "item.completed" && ev.item?.type === "agent_message" && typeof ev.item.text === "string") {
|
||||
const ev = JSON.parse(line) as {
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
input_tokens?: number;
|
||||
cached_input_tokens?: number;
|
||||
@@ -63,4 +77,3 @@ export const codexSpec: AgentSpec = {
|
||||
},
|
||||
parseOutput: parseCodexJson,
|
||||
};
|
||||
|
||||
|
||||
@@ -16,4 +16,3 @@ export function getAgentSpec(kind: AgentKind): AgentSpec {
|
||||
}
|
||||
|
||||
export { AgentKind, AgentMeta, AgentParseResult } from "./types.js";
|
||||
|
||||
|
||||
@@ -6,42 +6,50 @@ import {
|
||||
parseOpencodeJson,
|
||||
summarizeOpencodeMetadata,
|
||||
} 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);
|
||||
return summary ? { extra: { summary } } : undefined;
|
||||
}
|
||||
|
||||
export const opencodeSpec: AgentSpec = {
|
||||
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) => {
|
||||
// Split around the body so we can insert flags without losing the prompt.
|
||||
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";
|
||||
|
||||
// Ensure format json for parsing
|
||||
if (wantsJson) {
|
||||
const hasFormat = argv.some(
|
||||
const hasFormat = [...beforeBody, body, ...afterBody].some(
|
||||
(part) => part === "--format" || part.startsWith("--format="),
|
||||
);
|
||||
if (!hasFormat) {
|
||||
const insertBeforeBody = Math.max(argv.length - 1, 0);
|
||||
argv.splice(insertBeforeBody, 0, "--format", "json");
|
||||
beforeBody.push("--format", "json");
|
||||
}
|
||||
}
|
||||
|
||||
// Session args default to --session
|
||||
// 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);
|
||||
if (shouldPrependIdentity && argv[ctx.bodyIndex]) {
|
||||
const existingBody = argv[ctx.bodyIndex];
|
||||
argv[ctx.bodyIndex] = [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, existingBody]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
const bodyWithIdentity =
|
||||
shouldPrependIdentity && body
|
||||
? [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, body]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: body;
|
||||
|
||||
return argv;
|
||||
return [...beforeBody, bodyWithIdentity, ...afterBody];
|
||||
},
|
||||
parseOutput: (rawStdout) => {
|
||||
const parsed = parseOpencodeJson(rawStdout);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentMeta, AgentParseResult, AgentSpec, BuildArgsContext } from "./types.js";
|
||||
import type { AgentMeta, AgentParseResult, AgentSpec } from "./types.js";
|
||||
|
||||
type PiAssistantMessage = {
|
||||
role?: string;
|
||||
@@ -16,7 +16,11 @@ function parsePiJson(raw: string): AgentParseResult {
|
||||
let lastMessage: PiAssistantMessage | undefined;
|
||||
for (const line of lines) {
|
||||
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") {
|
||||
lastMessage = ev.message;
|
||||
}
|
||||
@@ -50,14 +54,20 @@ export const piSpec: AgentSpec = {
|
||||
if (!argv.includes("-p") && !argv.includes("--print")) {
|
||||
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");
|
||||
}
|
||||
// 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]) {
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -39,4 +39,3 @@ export interface AgentSpec {
|
||||
buildArgs: (ctx: BuildArgsContext) => string[];
|
||||
parseOutput: (rawStdout: string) => AgentParseResult;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user