feat: embed pi agent runtime

This commit is contained in:
Peter Steinberger
2025-12-17 11:29:04 +01:00
parent c5867b2876
commit fece42ce0a
42 changed files with 2076 additions and 4009 deletions

View File

@@ -1,548 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as tauRpc from "../process/tau-rpc.js";
import { runCommandReply } from "./command-reply.js";
const noopTemplateCtx = {
Body: "hello",
BodyStripped: "hello",
SessionId: "sess",
IsNewSession: "true",
};
const enqueueImmediate = vi.fn(
async <T>(
task: () => Promise<T>,
opts?: { onWait?: (ms: number, ahead: number) => void },
) => {
opts?.onWait?.(25, 2);
return task();
},
);
function mockPiRpc(result: {
stdout: string;
stderr?: string;
code: number;
signal?: NodeJS.Signals | null;
killed?: boolean;
}) {
return vi
.spyOn(tauRpc, "runPiRpc")
.mockResolvedValue({ killed: false, signal: null, ...result });
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("runCommandReply (pi)", () => {
it("injects pi flags and forwards prompt via RPC", async () => {
const rpcMock = mockPiRpc({
stdout:
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}',
stderr: "",
code: 0,
});
const { payloads } = await runCommandReply({
reply: {
mode: "command",
command: ["pi", "{{Body}}"],
agent: { kind: "pi", format: "json" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
enqueue: enqueueImmediate,
thinkLevel: "medium",
});
const payload = payloads?.[0];
expect(payload?.text).toBe("ok");
const call = rpcMock.mock.calls[0]?.[0];
expect(call?.prompt).toBe("hello");
expect(call?.argv).toContain("-p");
expect(call?.argv).toContain("--mode");
expect(call?.argv).toContain("rpc");
expect(call?.argv).toContain("--thinking");
expect(call?.argv).toContain("medium");
});
it("sends the body via RPC even when the command omits {{Body}}", async () => {
const rpcMock = mockPiRpc({
stdout:
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}',
stderr: "",
code: 0,
});
await runCommandReply({
reply: {
mode: "command",
command: ["pi", "--mode", "rpc", "--session", "/tmp/demo.jsonl"],
agent: { kind: "pi" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
enqueue: enqueueImmediate,
});
const call = rpcMock.mock.calls[0]?.[0];
expect(call?.prompt).toBe("hello");
expect(
(call?.argv ?? []).some((arg: string) => arg.includes("hello")),
).toBe(false);
});
it("does not echo the user's prompt when the agent returns no assistant text", async () => {
const rpcMock = mockPiRpc({
stdout: [
'{"type":"agent_start"}',
'{"type":"turn_start"}',
'{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}',
'{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}',
// assistant emits nothing useful
'{"type":"agent_end"}',
].join("\n"),
stderr: "",
code: 0,
});
const { payloads } = await runCommandReply({
reply: {
mode: "command",
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
},
templatingCtx: {
...noopTemplateCtx,
Body: "hello",
BodyStripped: "hello",
},
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
enqueue: enqueueImmediate,
});
expect(rpcMock).toHaveBeenCalledOnce();
expect(payloads?.length).toBe(1);
expect(payloads?.[0]?.text).toMatch(/no output/i);
expect(payloads?.[0]?.text).not.toContain("hello");
});
it("does not echo the prompt even when the fallback text matches after stripping prefixes", async () => {
const rpcMock = mockPiRpc({
stdout: [
'{"type":"agent_start"}',
'{"type":"turn_start"}',
'{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"[Dec 5 22:52] https://example.com"}]}}',
'{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"[Dec 5 22:52] https://example.com"}]}}',
// No assistant content
'{"type":"agent_end"}',
].join("\n"),
stderr: "",
code: 0,
});
const { payloads } = await runCommandReply({
reply: {
mode: "command",
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
},
templatingCtx: {
...noopTemplateCtx,
Body: "[Dec 5 22:52] https://example.com",
BodyStripped: "[Dec 5 22:52] https://example.com",
},
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
enqueue: enqueueImmediate,
});
expect(rpcMock).toHaveBeenCalledOnce();
expect(payloads?.length).toBe(1);
expect(payloads?.[0]?.text).toMatch(/no output/i);
expect(payloads?.[0]?.text).not.toContain("example.com");
});
it("forwards tool events even when verbose is off", async () => {
const events: Array<{ stream: string; data: Record<string, unknown> }> = [];
vi.spyOn(tauRpc, "runPiRpc").mockImplementation(
async (opts: Parameters<typeof tauRpc.runPiRpc>[0]) => {
opts.onEvent?.(
JSON.stringify({
type: "tool_execution_start",
toolName: "bash",
toolCallId: "call-1",
args: { cmd: "echo 1" },
}),
);
opts.onEvent?.(
JSON.stringify({
type: "message",
message: {
role: "tool_result",
toolCallId: "call-1",
content: [{ type: "text", text: "ok" }],
},
}),
);
return {
stdout:
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"done"}]}}',
stderr: "",
code: 0,
killed: false,
signal: null,
};
},
);
await runCommandReply({
reply: {
mode: "command",
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
enqueue: enqueueImmediate,
onAgentEvent: (evt) => events.push(evt),
});
expect(events).toContainEqual({
stream: "tool",
data: expect.objectContaining({
phase: "start",
name: "bash",
toolCallId: "call-1",
}),
});
expect(events).toContainEqual({
stream: "tool",
data: expect.objectContaining({ phase: "result", toolCallId: "call-1" }),
});
});
it("adds session args and --continue when resuming", async () => {
const rpcMock = mockPiRpc({
stdout:
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}',
stderr: "",
code: 0,
});
await runCommandReply({
reply: {
mode: "command",
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
session: {},
},
templatingCtx: { ...noopTemplateCtx, SessionId: "abc" },
sendSystemOnce: true,
isNewSession: false,
isFirstTurnInSession: false,
systemSent: true,
timeoutMs: 1000,
timeoutSeconds: 1,
enqueue: enqueueImmediate,
});
const argv = rpcMock.mock.calls[0]?.[0]?.argv ?? [];
expect(argv).toContain("--session");
expect(argv.some((a) => a.includes("abc"))).toBe(true);
expect(argv).toContain("--continue");
});
it("returns timeout text with partial snippet", async () => {
vi.spyOn(tauRpc, "runPiRpc").mockRejectedValue({
stdout: "partial output here",
killed: true,
signal: "SIGKILL",
});
const { payloads, meta } = await runCommandReply({
reply: {
mode: "command",
command: ["pi", "hi"],
agent: { kind: "pi" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 10,
timeoutSeconds: 1,
enqueue: enqueueImmediate,
});
const payload = payloads?.[0];
expect(payload?.text).toContain("Command timed out after 1s");
expect(payload?.text).toContain("partial output");
expect(meta.killed).toBe(true);
});
it("collapses rpc deltas instead of emitting raw JSON spam", async () => {
mockPiRpc({
stdout: [
'{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Hello"}}',
'{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":" world"}}',
].join("\n"),
stderr: "",
code: 0,
});
const { payloads } = await runCommandReply({
reply: {
mode: "command",
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
enqueue: enqueueImmediate,
});
expect(payloads?.[0]?.text).toBe("Hello world");
});
it("falls back to assistant text when parseOutput yields nothing", async () => {
mockPiRpc({
stdout: [
'{"type":"agent_start"}',
'{"type":"turn_start"}',
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"Acknowledged."}]}}',
].join("\n"),
stderr: "",
code: 0,
});
// Force parser to return nothing so we exercise fallback.
const parseSpy = vi
.spyOn((await import("../agents/pi.js")).piSpec, "parseOutput")
.mockReturnValue({ texts: [], toolResults: [], meta: undefined });
const { payloads } = await runCommandReply({
reply: {
mode: "command",
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
enqueue: enqueueImmediate,
});
parseSpy.mockRestore();
expect(payloads?.[0]?.text).toBe("Acknowledged.");
});
it("parses assistant text from agent_end messages", async () => {
mockPiRpc({
stdout: JSON.stringify({
type: "agent_end",
messages: [
{
role: "assistant",
content: [{ type: "text", text: "from agent_end" }],
model: "pi-1",
provider: "inflection",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
total: 2,
},
stopReason: "stop",
},
],
}),
stderr: "",
code: 0,
});
const { payloads } = await runCommandReply({
reply: {
mode: "command",
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
enqueue: enqueueImmediate,
});
expect(payloads?.[0]?.text).toBe("from agent_end");
});
it("does not leak JSON protocol frames when assistant emits no text", async () => {
mockPiRpc({
stdout: [
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"hmm"}],"usage":{"input":10,"output":5}}}',
].join("\n"),
stderr: "",
code: 0,
});
const { payloads } = await runCommandReply({
reply: {
mode: "command",
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
enqueue: enqueueImmediate,
});
expect(payloads?.[0]?.text).toMatch(/produced no output/i);
expect(payloads?.[0]?.text).not.toContain("message_end");
expect(payloads?.[0]?.text).not.toContain('"type"');
});
it("does not stream tool results when verbose is off", async () => {
const onPartial = vi.fn();
mockPiRpc({
stdout: [
'{"type":"tool_execution_start","toolName":"bash","args":{"command":"ls"}}',
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"done"}]}}',
].join("\n"),
stderr: "",
code: 0,
});
await runCommandReply({
reply: {
mode: "command",
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
enqueue: enqueueImmediate,
onPartialReply: onPartial,
verboseLevel: "off",
});
expect(onPartial).not.toHaveBeenCalled();
});
it("parses MEDIA tokens and respects mediaMaxMb for local files", async () => {
const tmp = path.join(os.tmpdir(), `clawdis-test-${Date.now()}.bin`);
const bigBuffer = Buffer.alloc(2 * 1024 * 1024, 1);
await fs.writeFile(tmp, bigBuffer);
mockPiRpc({
stdout: `hi\nMEDIA:${tmp}\nMEDIA:https://example.com/img.jpg`,
stderr: "",
code: 0,
});
const { payloads } = await runCommandReply({
reply: {
mode: "command",
command: ["pi", "hi"],
mediaMaxMb: 1,
agent: { kind: "pi" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
enqueue: enqueueImmediate,
});
const payload = payloads?.[0];
expect(payload?.mediaUrls).toEqual(["https://example.com/img.jpg"]);
await fs.unlink(tmp);
});
it("captures queue wait metrics and agent meta", async () => {
mockPiRpc({
stdout:
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input":10,"output":5}}}',
stderr: "",
code: 0,
});
const { meta } = await runCommandReply({
reply: {
mode: "command",
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: false,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 100,
timeoutSeconds: 1,
enqueue: enqueueImmediate,
});
expect(meta.queuedMs).toBe(25);
expect(meta.queuedAhead).toBe(2);
expect((meta.agentMeta?.usage as { output?: number })?.output).toBe(5);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,32 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as tauRpc from "../process/tau-rpc.js";
vi.mock("../agents/pi-embedded.js", () => ({
runEmbeddedPiAgent: vi.fn(),
}));
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import {
extractThinkDirective,
extractVerboseDirective,
getReplyFromConfig,
} from "./reply.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-reply-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
return await fn(base);
} finally {
process.env.HOME = previousHome;
await fs.rm(base, { recursive: true, force: true });
}
}
describe("directive parsing", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -44,66 +65,57 @@ describe("directive parsing", () => {
});
it("applies inline think and still runs agent content", async () => {
const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
stdout:
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"done"}]}}',
stderr: "",
code: 0,
signal: null,
killed: false,
});
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "done" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await getReplyFromConfig(
{
Body: "please sync /think:high now",
From: "+1004",
To: "+2000",
},
{},
{
inbound: {
allowFrom: ["*"],
reply: {
mode: "command",
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
session: {},
const res = await getReplyFromConfig(
{
Body: "please sync /think:high now",
From: "+1004",
To: "+2000",
},
{},
{
inbound: {
allowFrom: ["*"],
workspace: path.join(home, "clawd"),
agent: { provider: "anthropic", model: "claude-opus-4-5" },
session: { store: path.join(home, "sessions.json") },
},
},
},
);
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("done");
expect(rpcMock).toHaveBeenCalledOnce();
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
});
it("acks verbose directive immediately with system marker", async () => {
const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
});
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{ Body: "/verbose on", From: "+1222", To: "+1222" },
{},
{
inbound: {
reply: {
mode: "command",
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
session: {},
const res = await getReplyFromConfig(
{ Body: "/verbose on", From: "+1222", To: "+1222" },
{},
{
inbound: {
workspace: path.join(home, "clawd"),
agent: { provider: "anthropic", model: "claude-opus-4-5" },
session: { store: path.join(home, "sessions.json") },
},
},
},
);
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toMatch(/^⚙️ Verbose logging enabled\./);
expect(rpcMock).not.toHaveBeenCalled();
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toMatch(/^⚙️ Verbose logging enabled\./);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,9 +1,13 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as tauRpc from "../process/tau-rpc.js";
import * as commandReply from "./command-reply.js";
vi.mock("../agents/pi-embedded.js", () => ({
runEmbeddedPiAgent: vi.fn(),
}));
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
const webMocks = vi.hoisted(() => ({
@@ -14,16 +18,29 @@ const webMocks = vi.hoisted(() => ({
vi.mock("../web/session.js", () => webMocks);
const baseCfg = {
inbound: {
allowFrom: ["*"],
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
session: undefined,
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(join(tmpdir(), "clawdis-triggers-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
vi.mocked(runEmbeddedPiAgent).mockClear();
return await fn(base);
} finally {
process.env.HOME = previousHome;
await fs.rm(base, { recursive: true, force: true });
}
}
function makeCfg(home: string) {
return {
inbound: {
allowFrom: ["*"],
workspace: join(home, "clawd"),
agent: { provider: "anthropic", model: "claude-opus-4-5" },
session: { store: join(home, "sessions.json") },
},
},
};
};
}
afterEach(() => {
vi.restoreAllMocks();
@@ -31,146 +48,142 @@ afterEach(() => {
describe("trigger handling", () => {
it("aborts even with timestamp prefix", async () => {
const commandSpy = vi.spyOn(commandReply, "runCommandReply");
const res = await getReplyFromConfig(
{
Body: "[Dec 5 10:00] stop",
From: "+1000",
To: "+2000",
},
{},
baseCfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("⚙️ Agent was aborted.");
expect(commandSpy).not.toHaveBeenCalled();
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "[Dec 5 10:00] stop",
From: "+1000",
To: "+2000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("⚙️ Agent was aborted.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("restarts even with prefix/whitespace", async () => {
const commandSpy = vi.spyOn(commandReply, "runCommandReply");
const res = await getReplyFromConfig(
{
Body: " [Dec 5] /restart",
From: "+1001",
To: "+2000",
},
{},
baseCfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text?.startsWith("⚙️ Restarting" ?? "")).toBe(true);
expect(commandSpy).not.toHaveBeenCalled();
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: " [Dec 5] /restart",
From: "+1001",
To: "+2000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text?.startsWith("⚙️ Restarting" ?? "")).toBe(true);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("reports status without invoking the agent", async () => {
const commandSpy = vi.spyOn(commandReply, "runCommandReply");
const res = await getReplyFromConfig(
{
Body: "/status",
From: "+1002",
To: "+2000",
},
{},
baseCfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Status");
expect(commandSpy).not.toHaveBeenCalled();
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/status",
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Status");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("acknowledges a bare /new without treating it as empty", async () => {
const commandSpy = vi.spyOn(commandReply, "runCommandReply");
const res = await getReplyFromConfig(
{
Body: "/new",
From: "+1003",
To: "+2000",
},
{},
{
inbound: {
allowFrom: ["*"],
reply: {
mode: "command",
command: ["echo", "{{Body}}"],
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/new",
From: "+1003",
To: "+2000",
},
{},
{
inbound: {
allowFrom: ["*"],
workspace: join(home, "clawd"),
agent: { provider: "anthropic", model: "claude-opus-4-5" },
session: {
store: join(tmpdir(), `clawdis-session-test-${Date.now()}.json`),
},
},
},
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toMatch(/fresh session/i);
expect(commandSpy).not.toHaveBeenCalled();
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toMatch(/fresh session/i);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("ignores think directives that only appear in the context wrapper", async () => {
const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
stdout:
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}',
stderr: "",
code: 0,
signal: null,
killed: false,
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await getReplyFromConfig(
{
Body: [
"[Chat messages since your last reply - for context]",
"Peter: /thinking high [2025-12-05T21:45:00.000Z]",
"",
"[Current message - respond to this]",
"Give me the status",
].join("\n"),
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).toContain("Give me the status");
expect(prompt).not.toContain("/thinking high");
});
const res = await getReplyFromConfig(
{
Body: [
"[Chat messages since your last reply - for context]",
"Peter: /thinking high [2025-12-05T21:45:00.000Z]",
"",
"[Current message - respond to this]",
"Give me the status",
].join("\n"),
From: "+1002",
To: "+2000",
},
{},
baseCfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(rpcMock).toHaveBeenCalledOnce();
const prompt = rpcMock.mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).toContain("Give me the status");
expect(prompt).not.toContain("/thinking high");
});
it("does not emit directive acks for heartbeats with /think", async () => {
const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
stdout:
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}',
stderr: "",
code: 0,
signal: null,
killed: false,
});
const res = await getReplyFromConfig(
{
Body: "HEARTBEAT /think:high",
From: "+1003",
To: "+1003",
},
{ isHeartbeat: true },
{
inbound: {
reply: {
mode: "command",
command: ["pi", "{{Body}}"],
agent: { kind: "pi" },
session: {},
},
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
},
);
});
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(text).not.toMatch(/Thinking level set/i);
expect(rpcMock).toHaveBeenCalledOnce();
const res = await getReplyFromConfig(
{
Body: "HEARTBEAT /think:high",
From: "+1003",
To: "+1003",
},
{ isHeartbeat: true },
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(text).not.toMatch(/Thinking level set/i);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
});
});

View File

@@ -6,7 +6,7 @@ import {
DEFAULT_MODEL,
DEFAULT_PROVIDER,
} from "../agents/defaults.js";
import { resolveBundledPiBinary } from "../agents/pi-path.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
ensureAgentWorkspace,
@@ -17,25 +17,20 @@ import {
DEFAULT_RESET_TRIGGER,
loadSessionStore,
resolveSessionKey,
resolveSessionTranscriptPath,
resolveStorePath,
type SessionEntry,
saveSessionStore,
} from "../config/sessions.js";
import { isVerbose, logVerbose } from "../globals.js";
import { logVerbose } from "../globals.js";
import { buildProviderSummary } from "../infra/provider-summary.js";
import { triggerClawdisRestart } from "../infra/restart.js";
import { drainSystemEvents } from "../infra/system-events.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
import { runCommandReply } from "./command-reply.js";
import { buildStatusMessage } from "./status.js";
import {
applyTemplate,
type MsgContext,
type TemplateContext,
} from "./templating.js";
import type { MsgContext, TemplateContext } from "./templating.js";
import {
normalizeThinkLevel,
normalizeVerboseLevel,
@@ -51,10 +46,6 @@ const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]);
const ABORT_MEMORY = new Map<string, boolean>();
const SYSTEM_MARK = "⚙️";
type ReplyConfig = NonNullable<ClawdisConfig["inbound"]>["reply"];
type ResolvedReplyConfig = NonNullable<ReplyConfig>;
export function extractThinkDirective(body?: string): {
cleaned: string;
thinkLevel?: ThinkLevel;
@@ -147,63 +138,31 @@ function stripMentions(
return result.replace(/\s+/g, " ").trim();
}
function makeDefaultPiReply(): ResolvedReplyConfig {
const piBin = resolveBundledPiBinary() ?? "pi";
const defaultContext =
lookupContextTokens(DEFAULT_MODEL) ?? DEFAULT_CONTEXT_TOKENS;
return {
mode: "command" as const,
command: [piBin, "--mode", "rpc", "{{BodyStripped}}"],
agent: {
kind: "pi" as const,
provider: DEFAULT_PROVIDER,
model: DEFAULT_MODEL,
contextTokens: defaultContext,
format: "json" as const,
},
session: {
scope: "per-sender" as const,
resetTriggers: [DEFAULT_RESET_TRIGGER],
idleMinutes: DEFAULT_IDLE_MINUTES,
},
timeoutSeconds: 600,
};
}
export async function getReplyFromConfig(
ctx: MsgContext,
opts?: GetReplyOptions,
configOverride?: ClawdisConfig,
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
// Choose reply from config: static text or external command stdout.
const cfg = configOverride ?? loadConfig();
const workspaceDir = cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const configuredReply = cfg.inbound?.reply as ResolvedReplyConfig | undefined;
const reply: ResolvedReplyConfig = configuredReply
? { ...configuredReply, cwd: configuredReply.cwd ?? workspaceDir }
: { ...makeDefaultPiReply(), cwd: workspaceDir };
const identity = cfg.identity;
if (identity?.name?.trim() && reply.session && !reply.session.sessionIntro) {
const name = identity.name.trim();
const theme = identity.theme?.trim();
const emoji = identity.emoji?.trim();
const introParts = [
`You are ${name}.`,
theme ? `Theme: ${theme}.` : undefined,
emoji ? `Your emoji is ${emoji}.` : undefined,
].filter(Boolean);
reply.session = { ...reply.session, sessionIntro: introParts.join(" ") };
}
const workspaceDirRaw = cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const agentCfg = cfg.inbound?.agent;
const sessionCfg = cfg.inbound?.session;
// Bootstrap the workspace (and a starter AGENTS.md) only when we actually run from it.
if (reply.mode === "command" && typeof reply.cwd === "string") {
const resolvedWorkspace = resolveUserPath(workspaceDir);
const resolvedCwd = resolveUserPath(reply.cwd);
if (resolvedCwd === resolvedWorkspace) {
await ensureAgentWorkspace({ dir: workspaceDir, ensureAgentsFile: true });
}
}
const timeoutSeconds = Math.max(reply.timeoutSeconds ?? 600, 1);
const provider = agentCfg?.provider?.trim() || DEFAULT_PROVIDER;
const model = agentCfg?.model?.trim() || DEFAULT_MODEL;
const contextTokens =
agentCfg?.contextTokens ??
lookupContextTokens(model) ??
DEFAULT_CONTEXT_TOKENS;
// Bootstrap the workspace and the required files (AGENTS.md, SOUL.md, TOOLS.md).
const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw,
ensureBootstrapFiles: true,
});
const workspaceDir = workspace.dir;
const timeoutSeconds = Math.max(agentCfg?.timeoutSeconds ?? 600, 1);
const timeoutMs = timeoutSeconds * 1000;
let started = false;
const triggerTyping = async () => {
@@ -216,11 +175,9 @@ export async function getReplyFromConfig(
};
let typingTimer: NodeJS.Timeout | undefined;
const typingIntervalMs =
reply?.mode === "command"
? (reply.typingIntervalSeconds ??
reply?.session?.typingIntervalSeconds ??
8) * 1000
: 0;
(agentCfg?.typingIntervalSeconds ??
sessionCfg?.typingIntervalSeconds ??
8) * 1000;
const cleanupTyping = () => {
if (typingTimer) {
clearInterval(typingTimer);
@@ -250,7 +207,6 @@ export async function getReplyFromConfig(
}
// Optional session handling (conversation reuse + /new resets)
const sessionCfg = reply?.session;
const mainKey = sessionCfg?.mainKey ?? "main";
const resetTriggers = sessionCfg?.resetTriggers?.length
? sessionCfg.resetTriggers
@@ -278,65 +234,63 @@ export async function getReplyFromConfig(
.trim()
.toLowerCase();
if (sessionCfg) {
const rawBody = ctx.Body ?? "";
const trimmedBody = rawBody.trim();
// Timestamp/message prefixes (e.g. "[Dec 4 17:35] ") are added by the
// web inbox before we get here. They prevented reset triggers like "/new"
// from matching, so strip structural wrappers when checking for resets.
const strippedForReset = triggerBodyNormalized;
for (const trigger of resetTriggers) {
if (!trigger) continue;
if (trimmedBody === trigger || strippedForReset === trigger) {
isNewSession = true;
bodyStripped = "";
break;
}
const triggerPrefix = `${trigger} `;
if (
trimmedBody.startsWith(triggerPrefix) ||
strippedForReset.startsWith(triggerPrefix)
) {
isNewSession = true;
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
break;
}
}
sessionKey = resolveSessionKey(sessionScope, ctx, mainKey);
sessionStore = loadSessionStore(storePath);
const entry = sessionStore[sessionKey];
const idleMs = idleMinutes * 60_000;
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
if (!isNewSession && freshEntry) {
sessionId = entry.sessionId;
systemSent = entry.systemSent ?? false;
abortedLastRun = entry.abortedLastRun ?? false;
persistedThinking = entry.thinkingLevel;
persistedVerbose = entry.verboseLevel;
} else {
sessionId = crypto.randomUUID();
const rawBody = ctx.Body ?? "";
const trimmedBody = rawBody.trim();
// Timestamp/message prefixes (e.g. "[Dec 4 17:35] ") are added by the
// web inbox before we get here. They prevented reset triggers like "/new"
// from matching, so strip structural wrappers when checking for resets.
const strippedForReset = triggerBodyNormalized;
for (const trigger of resetTriggers) {
if (!trigger) continue;
if (trimmedBody === trigger || strippedForReset === trigger) {
isNewSession = true;
systemSent = false;
abortedLastRun = false;
bodyStripped = "";
break;
}
const triggerPrefix = `${trigger} `;
if (
trimmedBody.startsWith(triggerPrefix) ||
strippedForReset.startsWith(triggerPrefix)
) {
isNewSession = true;
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
break;
}
const baseEntry = !isNewSession && freshEntry ? entry : undefined;
sessionEntry = {
...baseEntry,
sessionId,
updatedAt: Date.now(),
systemSent,
abortedLastRun,
// Persist previously stored thinking/verbose levels when present.
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
};
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
sessionKey = resolveSessionKey(sessionScope, ctx, mainKey);
sessionStore = loadSessionStore(storePath);
const entry = sessionStore[sessionKey];
const idleMs = idleMinutes * 60_000;
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
if (!isNewSession && freshEntry) {
sessionId = entry.sessionId;
systemSent = entry.systemSent ?? false;
abortedLastRun = entry.abortedLastRun ?? false;
persistedThinking = entry.thinkingLevel;
persistedVerbose = entry.verboseLevel;
} else {
sessionId = crypto.randomUUID();
isNewSession = true;
systemSent = false;
abortedLastRun = false;
}
const baseEntry = !isNewSession && freshEntry ? entry : undefined;
sessionEntry = {
...baseEntry,
sessionId,
updatedAt: Date.now(),
systemSent,
abortedLastRun,
// Persist previously stored thinking/verbose levels when present.
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
};
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
const sessionCtx: TemplateContext = {
...ctx,
BodyStripped: bodyStripped ?? ctx.Body,
@@ -366,12 +320,12 @@ export async function getReplyFromConfig(
let resolvedThinkLevel =
inlineThink ??
(sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
(reply?.thinkingDefault as ThinkLevel | undefined);
(agentCfg?.thinkingDefault as ThinkLevel | undefined);
const resolvedVerboseLevel =
inlineVerbose ??
(sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
(reply?.verboseDefault as VerboseLevel | undefined);
(agentCfg?.verboseDefault as VerboseLevel | undefined);
const combinedDirectiveOnly =
hasThinkDirective &&
@@ -565,7 +519,14 @@ export async function getReplyFromConfig(
const webAuthAgeMs = getWebAuthAgeMs();
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
const statusText = buildStatusMessage({
reply,
agent: {
provider,
model,
contextTokens,
thinkingDefault: agentCfg?.thinkingDefault,
verboseDefault: agentCfg?.verboseDefault,
},
workspaceDir,
sessionEntry,
sessionKey,
sessionScope,
@@ -580,8 +541,7 @@ export async function getReplyFromConfig(
return { text: statusText };
}
const abortRequested =
reply?.mode === "command" && isAbortTrigger(rawBodyNormalized);
const abortRequested = isAbortTrigger(rawBodyNormalized);
if (abortRequested) {
if (sessionEntry && sessionStore && sessionKey) {
@@ -598,13 +558,7 @@ export async function getReplyFromConfig(
await startTypingLoop();
// Optional prefix injected before Body for templating/command prompts.
const sendSystemOnce = sessionCfg?.sendSystemOnce === true;
const isFirstTurnInSession = isNewSession || !systemSent;
const sessionIntro =
isFirstTurnInSession && sessionCfg?.sessionIntro
? applyTemplate(sessionCfg.sessionIntro ?? "", sessionCtx)
: "";
const groupIntro =
isFirstTurnInSession && sessionCtx.ChatType === "group"
? (() => {
@@ -624,9 +578,6 @@ export async function getReplyFromConfig(
);
})()
: "";
const bodyPrefix = reply?.bodyPrefix
? applyTemplate(reply.bodyPrefix ?? "", sessionCtx)
: "";
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
const baseBodyTrimmed = baseBody.trim();
const rawBodyTrimmed = (ctx.Body ?? "").trim();
@@ -648,19 +599,10 @@ export async function getReplyFromConfig(
text: "I didn't receive any text in your message. Please resend or add a caption.",
};
}
const abortedHint =
reply?.mode === "command" && abortedLastRun
? "Note: The previous agent run was aborted by the user. Resume carefully or ask for clarification."
: "";
const abortedHint = abortedLastRun
? "Note: The previous agent run was aborted by the user. Resume carefully or ask for clarification."
: "";
let prefixedBodyBase = baseBody;
if (!sendSystemOnce || isFirstTurnInSession) {
prefixedBodyBase = bodyPrefix
? `${bodyPrefix}${prefixedBodyBase}`
: prefixedBodyBase;
}
if (sessionIntro) {
prefixedBodyBase = `${sessionIntro}\n\n${prefixedBodyBase}`;
}
if (groupIntro) {
prefixedBodyBase = `${groupIntro}\n\n${prefixedBodyBase}`;
}
@@ -711,13 +653,7 @@ export async function getReplyFromConfig(
prefixedBodyBase = `${block}\n\n${prefixedBodyBase}`;
}
}
if (
sessionCfg &&
sendSystemOnce &&
isFirstTurnInSession &&
sessionStore &&
sessionKey
) {
if (isFirstTurnInSession && sessionStore && sessionKey) {
const current = sessionEntry ??
sessionStore[sessionKey] ?? {
sessionId: sessionId ?? crypto.randomUUID(),
@@ -734,20 +670,17 @@ export async function getReplyFromConfig(
systemSent = true;
}
const prefixedBody =
transcribedText && reply?.mode === "command"
? [prefixedBodyBase, `Transcript:\n${transcribedText}`]
.filter(Boolean)
.join("\n\n")
: prefixedBodyBase;
const prefixedBody = transcribedText
? [prefixedBodyBase, `Transcript:\n${transcribedText}`]
.filter(Boolean)
.join("\n\n")
: prefixedBodyBase;
const mediaNote = ctx.MediaPath?.length
? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]`
: undefined;
// For command prompts we prepend the media note so Pi sees it; text replies stay clean.
const mediaReplyHint =
mediaNote && reply?.mode === "command"
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
: undefined;
const mediaReplyHint = mediaNote
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
: undefined;
let commandBody = mediaNote
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
.filter(Boolean)
@@ -764,169 +697,92 @@ export async function getReplyFromConfig(
commandBody = parts.slice(1).join(" ").trim();
}
}
const templatingCtx: TemplateContext = {
...sessionCtx,
Body: commandBody,
BodyStripped: commandBody,
};
if (!reply) {
logVerbose("No inbound.reply configured; skipping auto-reply");
cleanupTyping();
return undefined;
}
if (reply.mode === "text" && reply.text) {
await onReplyStart();
logVerbose("Using text auto-reply from config");
const result = {
text: applyTemplate(reply.text ?? "", templatingCtx),
mediaUrl: reply.mediaUrl,
};
cleanupTyping();
return result;
}
const sessionIdFinal = sessionId ?? crypto.randomUUID();
const sessionFile = resolveSessionTranscriptPath(sessionIdFinal);
const isHeartbeat = opts?.isHeartbeat === true;
await onReplyStart();
if (reply && reply.mode === "command") {
const heartbeatCommand = isHeartbeat
? (reply as { heartbeatCommand?: string[] }).heartbeatCommand
: undefined;
const commandArgs = heartbeatCommand?.length
? heartbeatCommand
: reply.command;
try {
const runId = crypto.randomUUID();
const runResult = await runEmbeddedPiAgent({
sessionId: sessionIdFinal,
sessionFile,
workspaceDir,
prompt: commandBody,
provider,
model,
thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel,
timeoutMs,
runId,
onPartialReply: opts?.onPartialReply
? (payload) =>
opts.onPartialReply?.({
text: payload.text,
mediaUrls: payload.mediaUrls,
})
: undefined,
});
if (!commandArgs?.length) {
cleanupTyping();
return undefined;
}
const payloadArray = runResult.payloads ?? [];
if (payloadArray.length === 0) return undefined;
await onReplyStart();
const commandReply = {
...reply,
command: commandArgs,
mode: "command" as const,
};
try {
const runResult = await runCommandReply({
reply: commandReply,
templatingCtx,
sendSystemOnce,
isNewSession,
isFirstTurnInSession,
systemSent,
timeoutMs,
timeoutSeconds,
thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel,
onPartialReply: opts?.onPartialReply,
});
const payloadArray = runResult.payloads ?? [];
const meta = runResult.meta;
let finalPayloads = payloadArray;
if (!finalPayloads || finalPayloads.length === 0) {
return undefined;
}
if (sessionCfg && sessionStore && sessionKey) {
const returnedSessionId = meta.agentMeta?.sessionId;
// TODO: remove once pi-mono persists stable session ids for custom --session paths.
const allowMetaSessionId = false;
if (
allowMetaSessionId &&
returnedSessionId &&
returnedSessionId !== sessionId
) {
const entry = sessionEntry ??
sessionStore[sessionKey] ?? {
sessionId: returnedSessionId,
updatedAt: Date.now(),
systemSent,
abortedLastRun,
};
if (sessionStore && sessionKey) {
const usage = runResult.meta.agentMeta?.usage;
const modelUsed =
runResult.meta.agentMeta?.model ?? agentCfg?.model ?? DEFAULT_MODEL;
const contextTokensUsed =
agentCfg?.contextTokens ??
lookupContextTokens(modelUsed) ??
sessionEntry?.contextTokens ??
DEFAULT_CONTEXT_TOKENS;
if (usage) {
const entry = sessionEntry ?? sessionStore[sessionKey];
if (entry) {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const promptTokens =
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
sessionEntry = {
...entry,
sessionId: returnedSessionId,
inputTokens: input,
outputTokens: output,
totalTokens:
promptTokens > 0 ? promptTokens : (usage.total ?? input),
model: modelUsed,
contextTokens: contextTokensUsed ?? entry.contextTokens,
updatedAt: Date.now(),
};
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
sessionId = returnedSessionId;
if (isVerbose()) {
logVerbose(
`Session id updated from agent meta: ${returnedSessionId} (store: ${storePath})`,
);
}
}
const usage = meta.agentMeta?.usage;
const model =
meta.agentMeta?.model ||
reply?.agent?.model ||
sessionEntry?.model ||
DEFAULT_MODEL;
const contextTokens =
reply?.agent?.contextTokens ??
lookupContextTokens(model) ??
sessionEntry?.contextTokens ??
DEFAULT_CONTEXT_TOKENS;
if (usage) {
const entry = sessionEntry ?? sessionStore[sessionKey];
if (entry) {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const promptTokens =
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
sessionEntry = {
...entry,
inputTokens: input,
outputTokens: output,
// Track the effective prompt/context size (cached + uncached input).
totalTokens:
promptTokens > 0 ? promptTokens : (usage.total ?? input),
model,
contextTokens: contextTokens ?? entry.contextTokens,
updatedAt: Date.now(),
};
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
} else if (model || contextTokens) {
const entry = sessionEntry ?? sessionStore[sessionKey];
if (entry) {
sessionEntry = {
...entry,
model: model ?? entry.model,
contextTokens: contextTokens ?? entry.contextTokens,
};
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
} else if (modelUsed || contextTokensUsed) {
const entry = sessionEntry ?? sessionStore[sessionKey];
if (entry) {
sessionEntry = {
...entry,
model: modelUsed ?? entry.model,
contextTokens: contextTokensUsed ?? entry.contextTokens,
};
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
}
if (meta.agentMeta && isVerbose()) {
logVerbose(`Agent meta: ${JSON.stringify(meta.agentMeta)}`);
}
// If verbose is enabled and this is a new session, prepend a session hint.
const sessionIdHint =
resolvedVerboseLevel === "on" && isNewSession
? (sessionId ??
meta.agentMeta?.sessionId ??
templatingCtx.SessionId ??
"unknown")
: undefined;
if (sessionIdHint) {
finalPayloads = [
{ text: `🧭 New session: ${sessionIdHint}` },
...payloadArray,
];
}
return finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads;
} finally {
cleanupTyping();
}
}
cleanupTyping();
return undefined;
// If verbose is enabled and this is a new session, prepend a session hint.
let finalPayloads = payloadArray;
if (resolvedVerboseLevel === "on" && isNewSession) {
finalPayloads = [
{ text: `🧭 New session: ${sessionIdFinal}` },
...payloadArray,
];
}
return finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads;
} finally {
cleanupTyping();
}
}

View File

@@ -11,12 +11,7 @@ afterEach(() => {
describe("buildStatusMessage", () => {
it("summarizes agent readiness and context usage", () => {
const text = buildStatusMessage({
reply: {
mode: "command",
command: ["echo", "{{Body}}"],
agent: { kind: "pi", model: "pi:opus", contextTokens: 32_000 },
session: { scope: "per-sender" },
},
agent: { provider: "anthropic", model: "pi:opus", contextTokens: 32_000 },
sessionEntry: {
sessionId: "abc",
updatedAt: 0,
@@ -37,7 +32,7 @@ describe("buildStatusMessage", () => {
});
expect(text).toContain("⚙️ Status");
expect(text).toContain("Agent: ready");
expect(text).toContain("Agent: embedded pi");
expect(text).toContain("Context: 16k/32k (50%)");
expect(text).toContain("Session: main");
expect(text).toContain("Web: linked");
@@ -46,71 +41,81 @@ describe("buildStatusMessage", () => {
expect(text).toContain("verbose=off");
});
it("handles missing agent command gracefully", () => {
it("handles missing agent config gracefully", () => {
const text = buildStatusMessage({
reply: {
mode: "command",
command: [],
session: { scope: "per-sender" },
},
agent: {},
sessionScope: "per-sender",
webLinked: false,
});
expect(text).toContain("Agent: check");
expect(text).toContain("not set");
expect(text).toContain("Agent: embedded pi");
expect(text).toContain("Context:");
expect(text).toContain("Web: not linked");
});
it("prefers cached prompt tokens from the session log", () => {
it("prefers cached prompt tokens from the session log", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-status-"));
const storePath = path.join(dir, "sessions.json");
const sessionId = "sess-1";
const logPath = path.join(dir, `${sessionId}.jsonl`);
const previousHome = process.env.HOME;
process.env.HOME = dir;
try {
vi.resetModules();
const { buildStatusMessage: buildStatusMessageDynamic } = await import(
"./status.js"
);
fs.writeFileSync(
logPath,
[
JSON.stringify({
type: "message",
message: {
role: "assistant",
model: "claude-opus-4-5",
usage: {
input: 1,
output: 2,
cacheRead: 1000,
cacheWrite: 0,
totalTokens: 1003,
const storePath = path.join(dir, ".clawdis", "sessions", "sessions.json");
const sessionId = "sess-1";
const logPath = path.join(
dir,
".clawdis",
"sessions",
`${sessionId}.jsonl`,
);
fs.mkdirSync(path.dirname(logPath), { recursive: true });
fs.writeFileSync(
logPath,
[
JSON.stringify({
type: "message",
message: {
role: "assistant",
model: "claude-opus-4-5",
usage: {
input: 1,
output: 2,
cacheRead: 1000,
cacheWrite: 0,
totalTokens: 1003,
},
},
},
}),
].join("\n"),
"utf-8",
);
}),
].join("\n"),
"utf-8",
);
const text = buildStatusMessage({
reply: {
mode: "command",
command: ["echo", "{{Body}}"],
agent: { kind: "pi", model: "claude-opus-4-5", contextTokens: 32_000 },
session: { scope: "per-sender" },
},
sessionEntry: {
sessionId,
updatedAt: 0,
totalTokens: 3, // would be wrong if cached prompt tokens exist
contextTokens: 32_000,
},
sessionKey: "main",
sessionScope: "per-sender",
storePath,
webLinked: true,
});
const text = buildStatusMessageDynamic({
agent: {
provider: "anthropic",
model: "claude-opus-4-5",
contextTokens: 32_000,
},
sessionEntry: {
sessionId,
updatedAt: 0,
totalTokens: 3, // would be wrong if cached prompt tokens exist
contextTokens: 32_000,
},
sessionKey: "main",
sessionScope: "per-sender",
storePath,
webLinked: true,
});
expect(text).toContain("Context: 1.0k/32k");
fs.rmSync(dir, { recursive: true, force: true });
expect(text).toContain("Context: 1.0k/32k");
} finally {
process.env.HOME = previousHome;
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -1,7 +1,5 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
@@ -11,13 +9,18 @@ import {
type UsageLike,
} from "../agents/usage.js";
import type { ClawdisConfig } from "../config/config.js";
import type { SessionEntry, SessionScope } from "../config/sessions.js";
import {
resolveSessionTranscriptPath,
type SessionEntry,
type SessionScope,
} from "../config/sessions.js";
import type { ThinkLevel, VerboseLevel } from "./thinking.js";
type ReplyConfig = NonNullable<ClawdisConfig["inbound"]>["reply"];
type AgentConfig = NonNullable<ClawdisConfig["inbound"]>["agent"];
type StatusArgs = {
reply: ReplyConfig;
agent: AgentConfig;
workspaceDir?: string;
sessionEntry?: SessionEntry;
sessionKey?: string;
sessionScope?: SessionScope;
@@ -30,12 +33,6 @@ type StatusArgs = {
heartbeatSeconds?: number;
};
type AgentProbe = {
ok: boolean;
detail: string;
label: string;
};
const formatAge = (ms?: number | null) => {
if (!ms || ms < 0) return "unknown";
const minutes = Math.round(ms / 60_000);
@@ -57,49 +54,6 @@ const abbreviatePath = (p?: string) => {
return p;
};
const probeAgentCommand = (command?: string[]): AgentProbe => {
const bin = command?.[0];
if (!bin) {
return { ok: false, detail: "no command configured", label: "not set" };
}
const commandLabel = command
.slice(0, 3)
.map((c) => c.replace(/\{\{[^}]+}}/g, "{…}"))
.join(" ")
.concat(command.length > 3 ? " …" : "");
const looksLikePath = bin.includes("/") || bin.startsWith(".");
if (looksLikePath) {
const exists = fs.existsSync(bin);
return {
ok: exists,
detail: exists ? "binary found" : "binary missing",
label: commandLabel || bin,
};
}
try {
const res = spawnSync("which", [bin], {
encoding: "utf-8",
timeout: 1500,
});
const found =
res.status === 0 && res.stdout ? res.stdout.split("\n")[0]?.trim() : "";
return {
ok: Boolean(found),
detail: found || "not in PATH",
label: commandLabel || bin,
};
} catch (err) {
return {
ok: false,
detail: `probe failed: ${String(err)}`,
label: commandLabel || bin,
};
}
};
const formatTokens = (
total: number | null | undefined,
contextTokens: number | null,
@@ -117,7 +71,6 @@ const formatTokens = (
const readUsageFromSessionLog = (
sessionId?: string,
storePath?: string,
):
| {
input: number;
@@ -127,24 +80,10 @@ const readUsageFromSessionLog = (
model?: string;
}
| undefined => {
// Prefer the coding-agent session log (pi-mono) if present.
// Path resolution rules (priority):
// 1) Store directory sibling file <sessionId>.jsonl
// 2) PI coding agent dir: ~/.pi/agent/sessions/<sessionId>.jsonl
// Transcripts always live at: ~/.clawdis/sessions/<SessionId>.jsonl
if (!sessionId) return undefined;
const candidatePaths: string[] = [];
if (storePath) {
const dir = path.dirname(storePath);
candidatePaths.push(path.join(dir, `${sessionId}.jsonl`));
}
const piDir = path.join(os.homedir(), ".pi", "agent", "sessions");
candidatePaths.push(path.join(piDir, `${sessionId}.jsonl`));
const logPath = candidatePaths.find((p) => fs.existsSync(p));
if (!logPath) return undefined;
const logPath = resolveSessionTranscriptPath(sessionId);
if (!fs.existsSync(logPath)) return undefined;
try {
const lines = fs.readFileSync(logPath, "utf-8").split(/\n+/);
@@ -190,10 +129,10 @@ const readUsageFromSessionLog = (
export function buildStatusMessage(args: StatusArgs): string {
const now = args.now ?? Date.now();
const entry = args.sessionEntry;
let model = entry?.model ?? args.reply?.agent?.model ?? DEFAULT_MODEL;
let model = entry?.model ?? args.agent?.model ?? DEFAULT_MODEL;
let contextTokens =
entry?.contextTokens ??
args.reply?.agent?.contextTokens ??
args.agent?.contextTokens ??
lookupContextTokens(model) ??
DEFAULT_CONTEXT_TOKENS;
@@ -203,7 +142,7 @@ export function buildStatusMessage(args: StatusArgs): string {
// Prefer prompt-size tokens from the session transcript when it looks larger
// (cached prompt tokens are often missing from agent meta/store).
const logUsage = readUsageFromSessionLog(entry?.sessionId, args.storePath);
const logUsage = readUsageFromSessionLog(entry?.sessionId);
if (logUsage) {
const candidate = logUsage.promptTokens || logUsage.total;
if (!totalTokens || totalTokens === 0 || candidate > totalTokens) {
@@ -214,12 +153,10 @@ export function buildStatusMessage(args: StatusArgs): string {
contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens;
}
}
const agentProbe = probeAgentCommand(args.reply?.command);
const thinkLevel =
args.resolvedThink ?? args.reply?.thinkingDefault ?? "auto";
const thinkLevel = args.resolvedThink ?? args.agent?.thinkingDefault ?? "off";
const verboseLevel =
args.resolvedVerbose ?? args.reply?.verboseDefault ?? "off";
args.resolvedVerbose ?? args.agent?.verboseDefault ?? "off";
const webLine = (() => {
if (args.webLinked === false) {
@@ -251,7 +188,17 @@ export function buildStatusMessage(args: StatusArgs): string {
const optionsLine = `Options: thinking=${thinkLevel} | verbose=${verboseLevel} (set with /think <level>, /verbose on|off)`;
const agentLine = `Agent: ${agentProbe.ok ? "ready" : "check"}${agentProbe.label}${agentProbe.detail ? ` (${agentProbe.detail})` : ""}${model ? ` • model ${model}` : ""}`;
const modelLabel = args.agent?.provider?.trim()
? `${args.agent.provider}/${args.agent?.model ?? model}`
: model
? model
: "unknown";
const agentLine = `Agent: embedded pi • ${modelLabel}`;
const workspaceLine = args.workspaceDir
? `Workspace: ${abbreviatePath(args.workspaceDir)}`
: undefined;
const helpersLine = "Shortcuts: /new reset | /restart relink";
@@ -259,6 +206,7 @@ export function buildStatusMessage(args: StatusArgs): string {
"⚙️ Status",
webLine,
agentLine,
workspaceLine,
contextLine,
sessionLine,
optionsLine,