@poncho-ai/harness 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,11 @@
1
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { createServer } from "node:http";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "path";
5
5
  import { describe, expect, it } from "vitest";
6
6
  import type { ToolContext } from "@poncho-ai/sdk";
7
7
  import { AgentHarness } from "../src/harness.js";
8
+ import { createEditTool } from "../src/default-tools.js";
8
9
  import { loadSkillMetadata } from "../src/skill-context.js";
9
10
 
10
11
  const stubContext: ToolContext = {
@@ -39,6 +40,7 @@ model:
39
40
  expect(names).toContain("list_directory");
40
41
  expect(names).toContain("read_file");
41
42
  expect(names).toContain("write_file");
43
+ expect(names).toContain("edit_file");
42
44
  });
43
45
 
44
46
  it("disables write_file by default in production environment", async () => {
@@ -64,6 +66,7 @@ model:
64
66
  expect(names).toContain("list_directory");
65
67
  expect(names).toContain("read_file");
66
68
  expect(names).not.toContain("write_file");
69
+ expect(names).not.toContain("edit_file");
67
70
  });
68
71
 
69
72
  it("allows disabling built-in tools via poncho.config.js", async () => {
@@ -364,7 +367,7 @@ description: Beta skill
364
367
  });
365
368
  });
366
369
 
367
- it("clears active skills when skill metadata changes in development mode", async () => {
370
+ it("preserves active skills when skill metadata changes in development mode", async () => {
368
371
  const dir = await mkdtemp(join(tmpdir(), "poncho-harness-skill-refresh-clear-active-"));
369
372
  await writeFile(
370
373
  join(dir, "AGENT.md"),
@@ -402,6 +405,7 @@ description: Alpha skill
402
405
  await activate!.handler({ name: "alpha" }, {} as any);
403
406
  expect(await listActive!.handler({}, {} as any)).toEqual({ activeSkills: ["alpha"] });
404
407
 
408
+ // Update the skill metadata — the skill keeps the same name so it stays active
405
409
  await writeFile(
406
410
  join(dir, "skills", "alpha", "SKILL.md"),
407
411
  `---
@@ -414,7 +418,7 @@ description: Alpha skill updated
414
418
  "utf8",
415
419
  );
416
420
  await (harness as any).refreshSkillsIfChanged();
417
- expect(await listActive!.handler({}, {} as any)).toEqual({ activeSkills: [] });
421
+ expect(await listActive!.handler({}, {} as any)).toEqual({ activeSkills: ["alpha"] });
418
422
  });
419
423
 
420
424
  it("lists skill scripts through list_skill_scripts", async () => {
@@ -907,6 +911,132 @@ allowed-tools:
907
911
  await new Promise<void>((resolveClose) => mcpServer.close(() => resolveClose()));
908
912
  });
909
913
 
914
+ it("agent-level MCP tools persist when a skill is activated (additive)", async () => {
915
+ process.env.LINEAR_TOKEN = "token-123";
916
+ const mcpServer = createServer(async (req, res) => {
917
+ if (req.method === "DELETE") {
918
+ res.statusCode = 200;
919
+ res.end();
920
+ return;
921
+ }
922
+ const chunks: Buffer[] = [];
923
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
924
+ const body = Buffer.concat(chunks).toString("utf8");
925
+ const payload = body.trim().length > 0 ? (JSON.parse(body) as any) : {};
926
+ if (payload.method === "initialize") {
927
+ res.setHeader("Content-Type", "application/json");
928
+ res.setHeader("Mcp-Session-Id", "sess");
929
+ res.end(
930
+ JSON.stringify({
931
+ jsonrpc: "2.0",
932
+ id: payload.id,
933
+ result: {
934
+ protocolVersion: "2025-03-26",
935
+ capabilities: { tools: { listChanged: true } },
936
+ serverInfo: { name: "remote", version: "1.0.0" },
937
+ },
938
+ }),
939
+ );
940
+ return;
941
+ }
942
+ if (payload.method === "notifications/initialized") {
943
+ res.statusCode = 202;
944
+ res.end();
945
+ return;
946
+ }
947
+ if (payload.method === "tools/list") {
948
+ res.setHeader("Content-Type", "application/json");
949
+ res.end(
950
+ JSON.stringify({
951
+ jsonrpc: "2.0",
952
+ id: payload.id,
953
+ result: {
954
+ tools: [
955
+ { name: "a", inputSchema: { type: "object", properties: {} } },
956
+ { name: "b", inputSchema: { type: "object", properties: {} } },
957
+ ],
958
+ },
959
+ }),
960
+ );
961
+ return;
962
+ }
963
+ if (payload.method === "tools/call") {
964
+ res.setHeader("Content-Type", "application/json");
965
+ res.end(
966
+ JSON.stringify({
967
+ jsonrpc: "2.0",
968
+ id: payload.id,
969
+ result: { result: { ok: true } },
970
+ }),
971
+ );
972
+ return;
973
+ }
974
+ res.statusCode = 404;
975
+ res.end();
976
+ });
977
+ await new Promise<void>((resolveOpen) => mcpServer.listen(0, () => resolveOpen()));
978
+ const address = mcpServer.address();
979
+ if (!address || typeof address === "string") throw new Error("Unexpected address");
980
+ const dir = await mkdtemp(join(tmpdir(), "poncho-harness-additive-mcp-"));
981
+ await writeFile(
982
+ join(dir, "AGENT.md"),
983
+ `---
984
+ name: additive-agent
985
+ model:
986
+ provider: anthropic
987
+ name: claude-opus-4-5
988
+ allowed-tools:
989
+ - mcp:remote/a
990
+ ---
991
+
992
+ # Additive Agent
993
+ `,
994
+ "utf8",
995
+ );
996
+ await writeFile(
997
+ join(dir, "poncho.config.js"),
998
+ `export default {
999
+ mcp: [
1000
+ {
1001
+ name: "remote",
1002
+ url: "http://127.0.0.1:${address.port}/mcp",
1003
+ auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" }
1004
+ }
1005
+ ]
1006
+ };
1007
+ `,
1008
+ "utf8",
1009
+ );
1010
+ await mkdir(join(dir, "skills", "skill-b"), { recursive: true });
1011
+ await writeFile(
1012
+ join(dir, "skills", "skill-b", "SKILL.md"),
1013
+ `---
1014
+ name: skill-b
1015
+ description: B
1016
+ allowed-tools:
1017
+ - mcp:remote/b
1018
+ ---
1019
+ # B
1020
+ `,
1021
+ "utf8",
1022
+ );
1023
+ const harness = new AgentHarness({ workingDir: dir });
1024
+ await harness.initialize();
1025
+ const toolNames = () => harness.listTools().map((t) => t.name);
1026
+ expect(toolNames()).toContain("remote/a");
1027
+ expect(toolNames()).not.toContain("remote/b");
1028
+ const activate = harness.listTools().find((t) => t.name === "activate_skill")!;
1029
+ const deactivate = harness.listTools().find((t) => t.name === "deactivate_skill")!;
1030
+ await activate.handler({ name: "skill-b" }, {} as any);
1031
+ expect(toolNames()).toContain("remote/a");
1032
+ expect(toolNames()).toContain("remote/b");
1033
+ await deactivate.handler({ name: "skill-b" }, {} as any);
1034
+ expect(toolNames()).toContain("remote/a");
1035
+ expect(toolNames()).not.toContain("remote/b");
1036
+ await harness.shutdown();
1037
+ await new Promise<void>((resolveClose) => mcpServer.close(() => resolveClose()));
1038
+ });
1039
+
910
1040
  it("supports flat tool access config format", async () => {
911
1041
  const dir = await mkdtemp(join(tmpdir(), "poncho-harness-flat-tool-access-"));
912
1042
  await writeFile(
@@ -1230,3 +1360,63 @@ allowed-tools:
1230
1360
  });
1231
1361
 
1232
1362
  });
1363
+
1364
+ describe("edit_file tool", () => {
1365
+ it("replaces a unique string match in a file", async () => {
1366
+ const dir = await mkdtemp(join(tmpdir(), "poncho-edit-tool-"));
1367
+ const filePath = join(dir, "test.txt");
1368
+ await writeFile(filePath, "hello world\nfoo bar\nbaz qux\n", "utf8");
1369
+
1370
+ const tool = createEditTool(dir);
1371
+ const result = await tool.handler(
1372
+ { path: "test.txt", old_str: "foo bar", new_str: "replaced" },
1373
+ stubContext,
1374
+ );
1375
+
1376
+ expect(result).toEqual({ path: "test.txt", edited: true });
1377
+ const content = await readFile(filePath, "utf8");
1378
+ expect(content).toBe("hello world\nreplaced\nbaz qux\n");
1379
+ });
1380
+
1381
+ it("errors when old_str is not found in the file", async () => {
1382
+ const dir = await mkdtemp(join(tmpdir(), "poncho-edit-tool-notfound-"));
1383
+ await writeFile(join(dir, "test.txt"), "hello world\n", "utf8");
1384
+
1385
+ const tool = createEditTool(dir);
1386
+ await expect(
1387
+ tool.handler({ path: "test.txt", old_str: "nonexistent", new_str: "x" }, stubContext),
1388
+ ).rejects.toThrow("old_str not found in file");
1389
+ });
1390
+
1391
+ it("errors when old_str matches multiple locations", async () => {
1392
+ const dir = await mkdtemp(join(tmpdir(), "poncho-edit-tool-multi-"));
1393
+ await writeFile(join(dir, "test.txt"), "aaa\nbbb\naaa\n", "utf8");
1394
+
1395
+ const tool = createEditTool(dir);
1396
+ await expect(
1397
+ tool.handler({ path: "test.txt", old_str: "aaa", new_str: "ccc" }, stubContext),
1398
+ ).rejects.toThrow("old_str appears multiple times");
1399
+ });
1400
+
1401
+ it("deletes matched content when new_str is empty", async () => {
1402
+ const dir = await mkdtemp(join(tmpdir(), "poncho-edit-tool-delete-"));
1403
+ const filePath = join(dir, "test.txt");
1404
+ await writeFile(filePath, "keep this\nremove this\nkeep this too\n", "utf8");
1405
+
1406
+ const tool = createEditTool(dir);
1407
+ await tool.handler({ path: "test.txt", old_str: "remove this\n", new_str: "" }, stubContext);
1408
+
1409
+ const content = await readFile(filePath, "utf8");
1410
+ expect(content).toBe("keep this\nkeep this too\n");
1411
+ });
1412
+
1413
+ it("errors when old_str is empty", async () => {
1414
+ const dir = await mkdtemp(join(tmpdir(), "poncho-edit-tool-empty-"));
1415
+ await writeFile(join(dir, "test.txt"), "content\n", "utf8");
1416
+
1417
+ const tool = createEditTool(dir);
1418
+ await expect(
1419
+ tool.handler({ path: "test.txt", old_str: "", new_str: "x" }, stubContext),
1420
+ ).rejects.toThrow("old_str must not be empty");
1421
+ });
1422
+ });
@@ -5,7 +5,6 @@ describe("memory store factory", () => {
5
5
  it("uses memory provider by default", async () => {
6
6
  const store = createMemoryStore("agent-test");
7
7
  const updated = await store.updateMainMemory({
8
- mode: "replace",
9
8
  content: "Cesar prefers short bullet points.",
10
9
  });
11
10
  expect(updated.content).toContain("short bullet points");
@@ -13,24 +12,17 @@ describe("memory store factory", () => {
13
12
  expect(fetched.content).toContain("short bullet points");
14
13
  });
15
14
 
16
- it("supports append updates", async () => {
17
- const store = createMemoryStore("agent-append");
18
- await store.updateMainMemory({
19
- mode: "replace",
20
- content: "Initial memory.",
21
- });
22
- const result = await store.updateMainMemory({
23
- mode: "append",
24
- content: "Appended line.",
25
- });
26
- expect(result.content).toContain("Initial memory.");
27
- expect(result.content).toContain("Appended line.");
15
+ it("overwrites previous content on update", async () => {
16
+ const store = createMemoryStore("agent-overwrite");
17
+ await store.updateMainMemory({ content: "First version." });
18
+ const result = await store.updateMainMemory({ content: "Second version." });
19
+ expect(result.content).toBe("Second version.");
20
+ expect(result.content).not.toContain("First version.");
28
21
  });
29
22
 
30
23
  it("falls back gracefully when upstash is not configured", async () => {
31
24
  const store = createMemoryStore("agent-fallback", { provider: "upstash" });
32
25
  const updated = await store.updateMainMemory({
33
- mode: "replace",
34
26
  content: "Fallback path still stores memory",
35
27
  });
36
28
  expect(updated.content).toContain("Fallback path");
@@ -43,8 +35,101 @@ describe("memory tools", () => {
43
35
  const tools = createMemoryTools(store);
44
36
  expect(tools.map((tool) => tool.name)).toEqual([
45
37
  "memory_main_get",
46
- "memory_main_update",
38
+ "memory_main_write",
39
+ "memory_main_edit",
47
40
  "conversation_recall",
48
41
  ]);
49
42
  });
43
+
44
+ describe("memory_main_write", () => {
45
+ it("writes content to memory", async () => {
46
+ const store = createMemoryStore("agent-write");
47
+ const tools = createMemoryTools(store);
48
+ const writeTool = tools.find((t) => t.name === "memory_main_write")!;
49
+ const result = await writeTool.handler(
50
+ { content: "User prefers dark mode." },
51
+ { runId: "r1", agentId: "a1", step: 0, workingDir: ".", parameters: {} },
52
+ );
53
+ expect(result).toEqual({
54
+ ok: true,
55
+ memory: expect.objectContaining({ content: "User prefers dark mode." }),
56
+ });
57
+ });
58
+
59
+ it("errors when content is empty", async () => {
60
+ const store = createMemoryStore("agent-write-empty");
61
+ const tools = createMemoryTools(store);
62
+ const writeTool = tools.find((t) => t.name === "memory_main_write")!;
63
+ await expect(
64
+ writeTool.handler(
65
+ { content: " " },
66
+ { runId: "r1", agentId: "a1", step: 0, workingDir: ".", parameters: {} },
67
+ ),
68
+ ).rejects.toThrow("content is required");
69
+ });
70
+ });
71
+
72
+ describe("memory_main_edit", () => {
73
+ const setupMemory = async () => {
74
+ const store = createMemoryStore("agent-edit-" + Math.random());
75
+ await store.updateMainMemory({
76
+ content: "- prefers dark mode\n- likes TypeScript\n- uses vim",
77
+ });
78
+ const tools = createMemoryTools(store);
79
+ const editTool = tools.find((t) => t.name === "memory_main_edit")!;
80
+ const ctx = { runId: "r1", agentId: "a1", step: 0, workingDir: ".", parameters: {} };
81
+ return { store, editTool, ctx };
82
+ };
83
+
84
+ it("replaces a unique string match in memory", async () => {
85
+ const { store, editTool, ctx } = await setupMemory();
86
+ const result = await editTool.handler(
87
+ { old_str: "likes TypeScript", new_str: "loves TypeScript" },
88
+ ctx,
89
+ );
90
+ expect(result).toEqual({
91
+ ok: true,
92
+ memory: expect.objectContaining({
93
+ content: "- prefers dark mode\n- loves TypeScript\n- uses vim",
94
+ }),
95
+ });
96
+ const fetched = await store.getMainMemory();
97
+ expect(fetched.content).toContain("loves TypeScript");
98
+ });
99
+
100
+ it("deletes matched content when new_str is empty", async () => {
101
+ const { store, editTool, ctx } = await setupMemory();
102
+ await editTool.handler(
103
+ { old_str: "\n- likes TypeScript", new_str: "" },
104
+ ctx,
105
+ );
106
+ const fetched = await store.getMainMemory();
107
+ expect(fetched.content).toBe("- prefers dark mode\n- uses vim");
108
+ });
109
+
110
+ it("errors when old_str is empty", async () => {
111
+ const { editTool, ctx } = await setupMemory();
112
+ await expect(
113
+ editTool.handler({ old_str: "", new_str: "anything" }, ctx),
114
+ ).rejects.toThrow("old_str must not be empty");
115
+ });
116
+
117
+ it("errors when old_str is not found in memory", async () => {
118
+ const { editTool, ctx } = await setupMemory();
119
+ await expect(
120
+ editTool.handler({ old_str: "nonexistent text", new_str: "x" }, ctx),
121
+ ).rejects.toThrow("old_str not found in memory");
122
+ });
123
+
124
+ it("errors when old_str matches multiple locations", async () => {
125
+ const store = createMemoryStore("agent-edit-dup");
126
+ await store.updateMainMemory({ content: "foo bar foo" });
127
+ const tools = createMemoryTools(store);
128
+ const editTool = tools.find((t) => t.name === "memory_main_edit")!;
129
+ const ctx = { runId: "r1", agentId: "a1", step: 0, workingDir: ".", parameters: {} };
130
+ await expect(
131
+ editTool.handler({ old_str: "foo", new_str: "baz" }, ctx),
132
+ ).rejects.toThrow("old_str appears multiple times");
133
+ });
134
+ });
50
135
  });