mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
chore: Pi 开发依赖跟随最新版并去掉测试里的固定版本号
- pi-hermes-memory / pi-notify / pi-permission-system / pi-subagents 的 @earendil-works/* devDependencies 改为 latest,pi-rtk-optimizer 放开 peer 范围;各子包锁文件刷新到 Pi 1.3.0,测试改为验证当前宿主 API。 - 新增 scripts/update-pi-devdeps.sh,统一刷新根与子包锁文件中的 Pi 版本。 - pi-subagents 适配 Pi 1.3:mention clone 改走 systemPromptOverride、 BOM frontmatter、e2e 读取规范化后的 system 消息与工具列表。 - 根测试不再写死 ssh2 / jiti / better-sqlite3 / context-mode 版本号, 改为精确版本格式与锁文件一致性断言;新增 latest 约束测试。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
ba164896d2
commit
b817d66a11
Generated
+621
-2269
File diff suppressed because it is too large
Load Diff
@@ -53,9 +53,9 @@
|
|||||||
"@earendil-works/pi-tui": "*"
|
"@earendil-works/pi-tui": "*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@earendil-works/pi-ai": "0.84.2",
|
"@earendil-works/pi-ai": "latest",
|
||||||
"@earendil-works/pi-coding-agent": "0.84.2",
|
"@earendil-works/pi-coding-agent": "latest",
|
||||||
"@earendil-works/pi-tui": "0.84.2",
|
"@earendil-works/pi-tui": "latest",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"tsx": "4.23.15",
|
"tsx": "4.23.15",
|
||||||
"typebox": "1.3.7",
|
"typebox": "1.3.7",
|
||||||
|
|||||||
Generated
+1210
-1587
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@
|
|||||||
"@earendil-works/pi-coding-agent": "*"
|
"@earendil-works/pi-coding-agent": "*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "0.84.2",
|
"@earendil-works/pi-coding-agent": "latest",
|
||||||
"@types/node": "24.10.13",
|
"@types/node": "24.10.13",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+636
-2285
File diff suppressed because it is too large
Load Diff
@@ -84,9 +84,9 @@
|
|||||||
"@earendil-works/pi-tui": { "optional": true }
|
"@earendil-works/pi-tui": { "optional": true }
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@earendil-works/pi-ai": "0.84.2",
|
"@earendil-works/pi-ai": "latest",
|
||||||
"@earendil-works/pi-coding-agent": "0.84.2",
|
"@earendil-works/pi-coding-agent": "latest",
|
||||||
"@earendil-works/pi-tui": "0.84.2",
|
"@earendil-works/pi-tui": "latest",
|
||||||
"@types/node": "24.10.13",
|
"@types/node": "24.10.13",
|
||||||
"rollup": "4.62.2",
|
"rollup": "4.62.2",
|
||||||
"rollup-plugin-dts": "6.4.1",
|
"rollup-plugin-dts": "6.4.1",
|
||||||
|
|||||||
Generated
+1725
-1245
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,8 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "latest",
|
||||||
|
"@earendil-works/pi-tui": "latest",
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.28.1",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3"
|
||||||
},
|
},
|
||||||
@@ -62,8 +64,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.78.0 || ^0.79.0 || ^0.80.0",
|
"@earendil-works/pi-coding-agent": "*",
|
||||||
"@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.78.0 || ^0.79.0 || ^0.80.0"
|
"@earendil-works/pi-tui": "*"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"protobufjs": "7.6.3",
|
"protobufjs": "7.6.3",
|
||||||
|
|||||||
Generated
+593
-2342
File diff suppressed because it is too large
Load Diff
@@ -57,9 +57,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.14",
|
"@biomejs/biome": "^2.4.14",
|
||||||
"@earendil-works/pi-ai": "0.84.2",
|
"@earendil-works/pi-ai": "latest",
|
||||||
"@earendil-works/pi-coding-agent": "0.84.2",
|
"@earendil-works/pi-coding-agent": "latest",
|
||||||
"@earendil-works/pi-tui": "0.84.2",
|
"@earendil-works/pi-tui": "latest",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@vitest/coverage-istanbul": "^4.1.10",
|
"@vitest/coverage-istanbul": "^4.1.10",
|
||||||
"typescript": "^6.0.0",
|
"typescript": "^6.0.0",
|
||||||
|
|||||||
@@ -104,15 +104,17 @@ const FENCE = /^---[ \t]*$/;
|
|||||||
* what `parseFrontmatter` (the load side) considers a frontmatter block.
|
* what `parseFrontmatter` (the load side) considers a frontmatter block.
|
||||||
*
|
*
|
||||||
* Lines keep their terminators, so an edit preserves the file's existing line
|
* Lines keep their terminators, so an edit preserves the file's existing line
|
||||||
* endings instead of rewriting CRLF to LF. Returns undefined when there is no
|
* endings instead of rewriting CRLF to LF. A leading BOM stays attached to the
|
||||||
* usable block — notably for a BOM-prefixed file, which the parser also reads
|
* fence line — the parser skips it, so the block behind it is real frontmatter.
|
||||||
* as having none, so writing a key into it would change nothing on load.
|
* Returns undefined when there is no usable block.
|
||||||
*/
|
*/
|
||||||
function splitFrontmatter(content: string):
|
function splitFrontmatter(content: string):
|
||||||
| { lines: string[]; openIdx: number; closeIdx: number; eol: string }
|
| { lines: string[]; openIdx: number; closeIdx: number; eol: string }
|
||||||
| undefined {
|
| undefined {
|
||||||
const lines = content.split(/(?<=\n)/);
|
const lines = content.split(/(?<=\n)/);
|
||||||
if (lines.length === 0 || !FENCE.test(lines[0].replace(/\r?\n$/, ""))) return undefined;
|
if (lines.length === 0 || !FENCE.test(lines[0].replace(/\r?\n$/, "").replace(/^/, ""))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const closeIdx = lines.findIndex((l, i) => i > 0 && FENCE.test(l.replace(/\r?\n$/, "")));
|
const closeIdx = lines.findIndex((l, i) => i > 0 && FENCE.test(l.replace(/\r?\n$/, "")));
|
||||||
if (closeIdx === -1) return undefined;
|
if (closeIdx === -1) return undefined;
|
||||||
return { lines, openIdx: 0, closeIdx, eol: lines[0].endsWith("\r\n") ? "\r\n" : "\n" };
|
return { lines, openIdx: 0, closeIdx, eol: lines[0].endsWith("\r\n") ? "\r\n" : "\n" };
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import {
|
|||||||
createAgentSession,
|
createAgentSession,
|
||||||
DefaultResourceLoader,
|
DefaultResourceLoader,
|
||||||
type ExtensionContext,
|
type ExtensionContext,
|
||||||
|
type ExtensionToolContext,
|
||||||
getAgentDir,
|
getAgentDir,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
@@ -127,7 +128,10 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
|
|||||||
{ ...(params as Record<string, unknown>), run_in_background: true } as typeof params,
|
{ ...(params as Record<string, unknown>), run_in_background: true } as typeof params,
|
||||||
signal,
|
signal,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
ctx,
|
// Pi 1.3 narrowed tool ctx to ExtensionToolContext; the two added
|
||||||
|
// members (tools, executeTool) serve tool-to-tool calls, which the
|
||||||
|
// Agent tool never makes — runAgent reads only session fields.
|
||||||
|
ctx as ExtensionToolContext,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -148,6 +152,11 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
|
|||||||
// the settings level instead, which is what a session that never ran
|
// the settings level instead, which is what a session that never ran
|
||||||
// `/think` is on anyway. Same shim shape as `modelRuntime` below.
|
// `/think` is on anyway. Same shim shape as `modelRuntime` below.
|
||||||
const thinkingLevel = (ctx as { thinkingLevel?: ThinkingLevel }).thinkingLevel;
|
const thinkingLevel = (ctx as { thinkingLevel?: ThinkingLevel }).thinkingLevel;
|
||||||
|
// The clone rebuilds a system prompt from cwd and agentDir, which is close
|
||||||
|
// but not the live one — extensions contribute to it per turn. Copy the
|
||||||
|
// real thing via the loader override, so the copy reasons under the
|
||||||
|
// instructions the user's model is actually working under.
|
||||||
|
const systemPrompt = ctx.getSystemPrompt?.();
|
||||||
// This off-screen clone must expose only its synthetic Agent tool. Loading
|
// This off-screen clone must expose only its synthetic Agent tool. Loading
|
||||||
// host/project extensions would let active-tool owners such as Tool Search
|
// host/project extensions would let active-tool owners such as Tool Search
|
||||||
// replace the clone's allowlist during session_start, and would also run
|
// replace the clone's allowlist during session_start, and would also run
|
||||||
@@ -159,6 +168,7 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
|
|||||||
noSkills: true,
|
noSkills: true,
|
||||||
noPromptTemplates: true,
|
noPromptTemplates: true,
|
||||||
noThemes: true,
|
noThemes: true,
|
||||||
|
...(systemPrompt !== undefined && { systemPromptOverride: () => systemPrompt }),
|
||||||
});
|
});
|
||||||
const created = await runInChildSessionContext(() =>
|
const created = await runInChildSessionContext(() =>
|
||||||
createAgentSession({
|
createAgentSession({
|
||||||
@@ -185,13 +195,6 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
|
|||||||
);
|
);
|
||||||
session = created.session;
|
session = created.session;
|
||||||
|
|
||||||
// The clone rebuilds a system prompt from cwd and agentDir, which is close
|
|
||||||
// but not the live one — extensions contribute to it per turn. Copy the
|
|
||||||
// real thing, so the copy reasons under the instructions the user's model
|
|
||||||
// is actually working under.
|
|
||||||
const systemPrompt = ctx.getSystemPrompt?.();
|
|
||||||
if (systemPrompt) session.agent.state.systemPrompt = systemPrompt;
|
|
||||||
|
|
||||||
// The conversation itself. Pushed rather than assigned so the array the
|
// The conversation itself. Pushed rather than assigned so the array the
|
||||||
// session was built around stays the one it goes on using.
|
// session was built around stays the one it goes on using.
|
||||||
session.agent.state.messages.push(...conversation.messages);
|
session.agent.state.messages.push(...conversation.messages);
|
||||||
|
|||||||
@@ -124,15 +124,15 @@ describe("disableInContent", () => {
|
|||||||
expect(loaderSeesDisabled(content)).toBe(true);
|
expect(loaderSeesDisabled(content)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports no-frontmatter for a BOM-prefixed file, matching what the loader sees", () => {
|
it("disables a BOM-prefixed file, matching what the loader sees", () => {
|
||||||
// The parser doesn't accept a BOM either — it reports an empty frontmatter
|
// Pi ≥1.3's parser skips a leading BOM, so the block behind it is real
|
||||||
// and treats the whole file as body. So refusing here is agreement with the
|
// frontmatter — refusing here would leave a file the loader reads enabled
|
||||||
// read side, not a gap: inserting the key would have no effect on loading.
|
// while reporting "nothing to do". The edit keeps the BOM in place.
|
||||||
const src = "---\ndescription: Scout\n---\n\nBody.\n";
|
const src = "---\ndescription: Scout\n---\n\nBody.\n";
|
||||||
expect(parseFrontmatter<Record<string, unknown>>(src).frontmatter).toEqual({});
|
|
||||||
const { content, outcome } = disableInContent(src);
|
const { content, outcome } = disableInContent(src);
|
||||||
expect(outcome).toBe("no-frontmatter");
|
expect(outcome).toBe("disabled");
|
||||||
expect(content).toBe(src);
|
expect(content.startsWith("")).toBe(true);
|
||||||
|
expect(loaderSeesDisabled(content)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -112,20 +112,19 @@ describe("subagent usage reaches the parent session's stats (real pi)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves the context-window percentage alone", async () => {
|
it("counts only the result's own text toward context, not its reported usage", async () => {
|
||||||
// pi derives context usage from assistant messages only. If that ever
|
// Since Pi 1.3 the percentage reflects the transcript's real contents, so
|
||||||
// changed, a delegating session would look like it was filling its context
|
// the message's own few tokens do register. What must not register is the
|
||||||
// with work that happened somewhere else entirely — and users would compact
|
// reported usage: 150k delegated tokens on a 200k window would read as
|
||||||
// for no reason.
|
// ~75% occupied, and a delegating session would look like it was filling
|
||||||
|
// its context with work that happened somewhere else entirely.
|
||||||
const session = await realSession();
|
const session = await realSession();
|
||||||
try {
|
try {
|
||||||
const before = session.getSessionStats().contextUsage?.percent ?? null;
|
|
||||||
|
|
||||||
const pool = new PendingUsagePool();
|
const pool = new PendingUsagePool();
|
||||||
pool.add({ input: 150_000, output: 400, cacheWrite: 100, cost: 1.5 });
|
pool.add({ input: 150_000, output: 400, cacheWrite: 100, cost: 1.5 });
|
||||||
session.sessionManager.appendMessage(toolResultCarrying(pool.drain()) as any);
|
session.sessionManager.appendMessage(toolResultCarrying(pool.drain()) as any);
|
||||||
|
|
||||||
expect(session.getSessionStats().contextUsage?.percent ?? null).toBe(before);
|
expect(session.getSessionStats().contextUsage?.percent ?? 0).toBeLessThan(1);
|
||||||
} finally {
|
} finally {
|
||||||
session.dispose?.();
|
session.dispose?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,46 @@ function resolveReply(
|
|||||||
return typeof reply === "function" ? (reply as (c: Context) => FauxReply)(ctx) : reply;
|
return typeof reply === "function" ? (reply as (c: Context) => FauxReply)(ctx) : reply;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names of the tools the session offered the model for this call. Pi ≥1.3
|
||||||
|
* normalizes `Context.tools` into the transcript's system messages
|
||||||
|
* (`toolsAdded`/`toolsRemoved`) before the provider sees the request, so read
|
||||||
|
* both shapes.
|
||||||
|
*/
|
||||||
|
export function contextToolNames(context: Context): string[] {
|
||||||
|
const names = new Set<string>((context.tools ?? []).map((t) => t.name));
|
||||||
|
for (const m of context.messages) {
|
||||||
|
if (m.role !== "system") continue;
|
||||||
|
for (const t of m.toolsAdded ?? []) names.add(t.name);
|
||||||
|
for (const t of m.toolsRemoved ?? []) names.delete(t.name);
|
||||||
|
}
|
||||||
|
return [...names];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The system-prompt text the session offered for this call. Pi ≥1.3 normalizes
|
||||||
|
* `Context.systemPrompt` into the transcript's system messages, so read both
|
||||||
|
* shapes: `context.systemPrompt` plus every system message's `content` and
|
||||||
|
* named `sections` values.
|
||||||
|
*/
|
||||||
|
export function contextSystemPrompt(context: Context): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (context.systemPrompt) parts.push(context.systemPrompt);
|
||||||
|
for (const m of context.messages) {
|
||||||
|
if (m.role !== "system") continue;
|
||||||
|
const content = m.content;
|
||||||
|
if (typeof content === "string") {
|
||||||
|
parts.push(content);
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
for (const b of content) if (b.type === "text" && b.text) parts.push(b.text);
|
||||||
|
}
|
||||||
|
for (const v of Object.values(m.sections ?? {})) {
|
||||||
|
if (typeof v === "string") parts.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The common single-spawn flow as a responder. Routes by inspecting the calling
|
* The common single-spawn flow as a responder. Routes by inspecting the calling
|
||||||
* session's own context:
|
* session's own context:
|
||||||
@@ -210,7 +250,7 @@ export function routeBySession(routes: {
|
|||||||
subagent: FauxReply | ((ctx: Context) => FauxReply);
|
subagent: FauxReply | ((ctx: Context) => FauxReply);
|
||||||
}): FauxResponder {
|
}): FauxResponder {
|
||||||
return (context) => {
|
return (context) => {
|
||||||
const isParent = (context.tools ?? []).some((t) => t.name === "Agent");
|
const isParent = contextToolNames(context).includes("Agent");
|
||||||
if (!isParent) return resolveReply(routes.subagent, context);
|
if (!isParent) return resolveReply(routes.subagent, context);
|
||||||
const spawned = context.messages.some(
|
const spawned = context.messages.some(
|
||||||
(m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent",
|
(m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent",
|
||||||
|
|||||||
@@ -17,10 +17,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
// Hoisted: vi.mock's factory is lifted above the imports, so it cannot close
|
// Hoisted: vi.mock's factory is lifted above the imports, so it cannot close
|
||||||
// over ordinary top-level consts.
|
// over ordinary top-level consts.
|
||||||
const { buildSessionContext, createAgentSession, inMemory } = vi.hoisted(() => ({
|
const { buildSessionContext, createAgentSession, inMemory, loaderOptions } = vi.hoisted(() => ({
|
||||||
buildSessionContext: vi.fn(),
|
buildSessionContext: vi.fn(),
|
||||||
createAgentSession: vi.fn(),
|
createAgentSession: vi.fn(),
|
||||||
inMemory: vi.fn(),
|
inMemory: vi.fn(),
|
||||||
|
// The clone's prompt reaches the session through the loader's
|
||||||
|
// systemPromptOverride (Pi 1.3 made AgentState.systemPrompt read-only), so
|
||||||
|
// capture what runMentionClone hands DefaultResourceLoader.
|
||||||
|
loaderOptions: { current: undefined as Record<string, unknown> | undefined },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@earendil-works/pi-coding-agent", async () => {
|
vi.mock("@earendil-works/pi-coding-agent", async () => {
|
||||||
@@ -29,6 +33,12 @@ vi.mock("@earendil-works/pi-coding-agent", async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
buildSessionContext,
|
buildSessionContext,
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
|
DefaultResourceLoader: class extends actual.DefaultResourceLoader {
|
||||||
|
constructor(options?: Record<string, unknown>) {
|
||||||
|
loaderOptions.current = options;
|
||||||
|
super(options);
|
||||||
|
}
|
||||||
|
},
|
||||||
SessionManager: { ...actual.SessionManager, inMemory },
|
SessionManager: { ...actual.SessionManager, inMemory },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -44,6 +54,7 @@ const CONVERSATION = [
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createAgentSession.mockReset();
|
createAgentSession.mockReset();
|
||||||
|
loaderOptions.current = undefined;
|
||||||
inMemory.mockReset();
|
inMemory.mockReset();
|
||||||
inMemory.mockReturnValue({ kind: "in-memory-session-manager" } as any);
|
inMemory.mockReturnValue({ kind: "in-memory-session-manager" } as any);
|
||||||
buildSessionContext.mockReset();
|
buildSessionContext.mockReset();
|
||||||
@@ -104,6 +115,13 @@ function cloneSession(turn?: (tool: any) => Promise<void> | void) {
|
|||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
} as any;
|
} as any;
|
||||||
createAgentSession.mockImplementation(async (opts: any) => {
|
createAgentSession.mockImplementation(async (opts: any) => {
|
||||||
|
// What the real wiring does: the loader's systemPromptOverride rewrites the
|
||||||
|
// rebuilt base before the session's state gets it.
|
||||||
|
const override = loaderOptions.current?.systemPromptOverride as
|
||||||
|
| ((base: string | undefined) => string | undefined)
|
||||||
|
| undefined;
|
||||||
|
const overridden = override?.(session.agent.state.systemPrompt);
|
||||||
|
if (overridden !== undefined) session.agent.state.systemPrompt = overridden;
|
||||||
const tools = visibleTools(opts);
|
const tools = visibleTools(opts);
|
||||||
session.prompt.mockImplementation(async () => {
|
session.prompt.mockImplementation(async () => {
|
||||||
// No tool, no tool call: the model can only answer in prose.
|
// No tool, no tool call: the model can only answer in prose.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { loadCustomAgents } from "../src/custom-agents.js";
|
|||||||
import { encodeCwd } from "../src/output-file.js";
|
import { encodeCwd } from "../src/output-file.js";
|
||||||
import {
|
import {
|
||||||
agentCall,
|
agentCall,
|
||||||
|
contextToolNames,
|
||||||
type FauxReply,
|
type FauxReply,
|
||||||
type PrintModeRun,
|
type PrintModeRun,
|
||||||
runPrintMode,
|
runPrintMode,
|
||||||
@@ -101,7 +102,7 @@ describe("nested delegation e2e (real pi-mono, faux model)", () => {
|
|||||||
|
|
||||||
const respond = (context: Context): FauxReply => {
|
const respond = (context: Context): FauxReply => {
|
||||||
const text = firstUserText(context);
|
const text = firstUserText(context);
|
||||||
const names = (context.tools ?? []).map((t) => t.name);
|
const names = contextToolNames(context);
|
||||||
|
|
||||||
// Leaf: no nested tools (it never opted in) — just answer.
|
// Leaf: no nested tools (it never opted in) — just answer.
|
||||||
if (text.includes("Do the leaf work")) {
|
if (text.includes("Do the leaf work")) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { registerAgents } from "../src/agent-types.js";
|
|||||||
import { loadCustomAgents } from "../src/custom-agents.js";
|
import { loadCustomAgents } from "../src/custom-agents.js";
|
||||||
import {
|
import {
|
||||||
agentCall,
|
agentCall,
|
||||||
|
contextToolNames,
|
||||||
type FauxResponder,
|
type FauxResponder,
|
||||||
type PrintModeRun,
|
type PrintModeRun,
|
||||||
runPrintMode,
|
runPrintMode,
|
||||||
@@ -47,7 +48,7 @@ function userPrompt(ctx: Context): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tools(ctx: Context): string[] {
|
function tools(ctx: Context): string[] {
|
||||||
return (ctx.tools ?? []).map((tool) => tool.name);
|
return contextToolNames(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolResults(ctx: Context, name: string): string[] {
|
function toolResults(ctx: Context, name: string): string[] {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
agentCall,
|
agentCall,
|
||||||
agentToolCalls,
|
agentToolCalls,
|
||||||
agentToolResults,
|
agentToolResults,
|
||||||
|
contextSystemPrompt,
|
||||||
|
contextToolNames,
|
||||||
conversationText,
|
conversationText,
|
||||||
invokedToolNames,
|
invokedToolNames,
|
||||||
type PrintModeRun,
|
type PrintModeRun,
|
||||||
@@ -92,7 +94,7 @@ describe.skipIf(LIVE)("subagents print-mode e2e (scripted faux, real pi-mono)",
|
|||||||
// finishes → the child's own model turn actually runs (≥3 calls).
|
// finishes → the child's own model turn actually runs (≥3 calls).
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
const respond = async (ctx: Context) => {
|
const respond = async (ctx: Context) => {
|
||||||
const isParent = (ctx.tools ?? []).some((t) => t.name === "Agent");
|
const isParent = contextToolNames(ctx).includes("Agent");
|
||||||
if (!isParent) {
|
if (!isParent) {
|
||||||
await sleep(80); // child takes long enough that a non-held parent exits first
|
await sleep(80); // child takes long enough that a non-held parent exits first
|
||||||
return "CHILD_BG_RAN";
|
return "CHILD_BG_RAN";
|
||||||
@@ -151,7 +153,7 @@ describe.skipIf(LIVE)("subagents print-mode e2e (scripted faux, real pi-mono)",
|
|||||||
parentFinal: "Reported.",
|
parentFinal: "Reported.",
|
||||||
// The child reflects whether the frontmatter body reached its own prompt.
|
// The child reflects whether the frontmatter body reached its own prompt.
|
||||||
subagent: (ctx: Context) =>
|
subagent: (ctx: Context) =>
|
||||||
`child saw: ${ctx.systemPrompt?.includes(MARKER) ? MARKER : "MISSING"}`,
|
`child saw: ${contextSystemPrompt(ctx).includes(MARKER) ? MARKER : "MISSING"}`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,7 +187,7 @@ describe.skipIf(LIVE)("subagents print-mode e2e (scripted faux, real pi-mono)",
|
|||||||
}),
|
}),
|
||||||
parentFinal: "Reported.",
|
parentFinal: "Reported.",
|
||||||
subagent: (ctx: Context) =>
|
subagent: (ctx: Context) =>
|
||||||
`child saw: ${ctx.systemPrompt?.includes(MARKER) ? MARKER : "MISSING"}`,
|
`child saw: ${contextSystemPrompt(ctx).includes(MARKER) ? MARKER : "MISSING"}`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# update-pi-devdeps.sh — refresh every package-local @earendil-works/* dev
|
||||||
|
# dependency (pinned to "latest" in each manifest) and re-resolve the lockfiles
|
||||||
|
# to the newest published Pi release.
|
||||||
|
#
|
||||||
|
# Runs `npm update` in the bundle root and in every top-level directory that has
|
||||||
|
# its own package-lock.json and declares @earendil-works/* devDependencies.
|
||||||
|
# Package dirs without Pi devDependencies (locks without @earendil-works
|
||||||
|
# entries) are skipped, so the Pi host runtime never lands where it does not
|
||||||
|
# belong.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
ROOT=$(pwd)
|
||||||
|
|
||||||
|
# prints the @earendil-works/* devDependency names declared in $1/package.json
|
||||||
|
pi_devdeps_of() {
|
||||||
|
node -e '
|
||||||
|
const pkg = require(process.argv[1]);
|
||||||
|
const devDeps = pkg.devDependencies || {};
|
||||||
|
const names = Object.keys(devDeps).filter((n) => n.startsWith("@earendil-works/"));
|
||||||
|
process.stdout.write(names.join(" "));
|
||||||
|
' "$1/package.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
update_dir() {
|
||||||
|
dir=$1
|
||||||
|
names=$(pi_devdeps_of "$dir")
|
||||||
|
if [ -z "$names" ]; then
|
||||||
|
echo "skip $dir (no @earendil-works/* devDependencies)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "==> npm update in $dir: $names"
|
||||||
|
# Word splitting is intended: $names is a space-separated package list.
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
(cd "$dir" && npm update $names)
|
||||||
|
}
|
||||||
|
|
||||||
|
# The root declares no devDependencies, but its lock records the linked
|
||||||
|
# packages' @earendil-works/* devDependencies, so it is always refreshed.
|
||||||
|
echo "==> npm update in $ROOT"
|
||||||
|
(cd "$ROOT" && npm update @earendil-works/pi-ai @earendil-works/pi-coding-agent @earendil-works/pi-tui)
|
||||||
|
for dir in "$ROOT"/*/; do
|
||||||
|
[ -f "$dir/package-lock.json" ] || continue
|
||||||
|
[ -f "$dir/package.json" ] || continue
|
||||||
|
update_dir "$dir"
|
||||||
|
done
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -115,3 +115,24 @@ test("disposable runtime state stays under the agent-dir my-pi tree", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("every package-local @earendil-works/* devDependency is pinned to latest", async () => {
|
||||||
|
for (const entry of await readdir(repositoryRoot, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const manifestPath = join(repositoryRoot, entry.name, "package.json");
|
||||||
|
let manifest: { devDependencies?: Record<string, string> };
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
continue; // not a package directory
|
||||||
|
}
|
||||||
|
for (const [name, range] of Object.entries(manifest.devDependencies ?? {})) {
|
||||||
|
if (!name.startsWith("@earendil-works/")) continue;
|
||||||
|
assert.equal(
|
||||||
|
range,
|
||||||
|
"latest",
|
||||||
|
`${entry.name} devDependency ${name} must track "latest" (refresh locks via scripts/update-pi-devdeps.sh)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ test("all package extensions load together without global registration conflicts
|
|||||||
);
|
);
|
||||||
assert.ok(packageJson.pi.extensions.includes("./pi-ssh/index.ts"), "the bundle must load the locally maintained SSH extension");
|
assert.ok(packageJson.pi.extensions.includes("./pi-ssh/index.ts"), "the bundle must load the locally maintained SSH extension");
|
||||||
assert.equal(packageJson.dependencies["pi-ssh"], "file:./pi-ssh");
|
assert.equal(packageJson.dependencies["pi-ssh"], "file:./pi-ssh");
|
||||||
assert.equal(packageJson.dependencies.ssh2, "1.17.0", "the packed bundle must install pi-ssh's runtime transport");
|
assert.match(packageJson.dependencies.ssh2 ?? "", /^\d+\.\d+\.\d+$/, "the packed bundle must install pi-ssh's runtime transport at an exact version");
|
||||||
assert.equal(packageJson.dependencies.jiti, "2.7.0", "the packed helper must load local TypeScript outside Pi");
|
assert.match(packageJson.dependencies.jiti ?? "", /^\d+\.\d+\.\d+$/, "the packed helper must load local TypeScript outside Pi at an exact version");
|
||||||
assert.ok(packageJson.files.includes("pi-ssh"), "the packed bundle must include the locally maintained SSH source");
|
assert.ok(packageJson.files.includes("pi-ssh"), "the packed bundle must include the locally maintained SSH source");
|
||||||
assert.ok(packageJson.files.includes("configure.sh"), "the packed bundle must include the search/SSH config helper");
|
assert.ok(packageJson.files.includes("configure.sh"), "the packed bundle must include the search/SSH config helper");
|
||||||
assert.ok(packageJson.pi.extensions.includes("./pi-ask-user/index.ts"), "the bundle must load pi-ask-user");
|
assert.ok(packageJson.pi.extensions.includes("./pi-ask-user/index.ts"), "the bundle must load pi-ask-user");
|
||||||
@@ -210,11 +210,28 @@ test("all package extensions load together without global registration conflicts
|
|||||||
);
|
);
|
||||||
assert.equal(packageJson.dependencies["@the-forge-flow/hippo-memory-pi"], undefined);
|
assert.equal(packageJson.dependencies["@the-forge-flow/hippo-memory-pi"], undefined);
|
||||||
assert.equal(packageJson.dependencies["better-sqlite3"], undefined);
|
assert.equal(packageJson.dependencies["better-sqlite3"], undefined);
|
||||||
assert.equal(packageJson.allowScripts["better-sqlite3@12.11.1"], true);
|
// allowScripts must approve exactly the root lockfile's better-sqlite3
|
||||||
|
// build — and nothing else. pi-hermes-memory's own lockfile carries a
|
||||||
|
// nested major that ships N-API prebuilds and needs no approval entry.
|
||||||
|
const rootLock = JSON.parse(await readFile(join(repositoryRoot, "package-lock.json"), "utf8")) as {
|
||||||
|
packages: Record<string, { version?: string }>;
|
||||||
|
};
|
||||||
|
const hermesLock = JSON.parse(await readFile(join(repositoryRoot, "pi-hermes-memory", "package-lock.json"), "utf8")) as {
|
||||||
|
packages: Record<string, { version?: string }>;
|
||||||
|
};
|
||||||
|
const rootSqliteVersion = rootLock.packages["node_modules/better-sqlite3"]?.version;
|
||||||
|
const hermesSqliteVersion = hermesLock.packages["node_modules/better-sqlite3"]?.version;
|
||||||
|
assert.match(rootSqliteVersion ?? "", /^\d+\.\d+\.\d+$/, "the root lockfile must pin a root-level better-sqlite3");
|
||||||
|
assert.match(hermesSqliteVersion ?? "", /^\d+\.\d+\.\d+$/, "the Hermes lockfile must pin its own better-sqlite3");
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(packageJson.allowScripts).filter((key) => key.startsWith("better-sqlite3@")),
|
||||||
|
[`better-sqlite3@${rootSqliteVersion}`],
|
||||||
|
"allowScripts must approve exactly the root lockfile's better-sqlite3 build",
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
packageJson.allowScripts["better-sqlite3@13.0.3"],
|
packageJson.allowScripts[`better-sqlite3@${hermesSqliteVersion}`],
|
||||||
undefined,
|
undefined,
|
||||||
"Hermes uses better-sqlite3 13 N-API prebuilds, so no extra install-script approval is required",
|
`Hermes uses better-sqlite3 ${hermesSqliteVersion} N-API prebuilds, so no extra install-script approval is required`,
|
||||||
);
|
);
|
||||||
assert.equal(packageJson.dependencies["@sinclair/typebox"], undefined);
|
assert.equal(packageJson.dependencies["@sinclair/typebox"], undefined);
|
||||||
const home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-"));
|
const home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-"));
|
||||||
|
|||||||
@@ -551,10 +551,10 @@ test("context-mode upstream still appends the single anchor-bearing user message
|
|||||||
const packageJson = JSON.parse(
|
const packageJson = JSON.parse(
|
||||||
await readFile(join(repositoryRoot, "package.json"), "utf8"),
|
await readFile(join(repositoryRoot, "package.json"), "utf8"),
|
||||||
) as { dependencies: Record<string, string> };
|
) as { dependencies: Record<string, string> };
|
||||||
assert.equal(
|
assert.match(
|
||||||
packageJson.dependencies["context-mode"],
|
packageJson.dependencies["context-mode"] ?? "",
|
||||||
"1.0.169",
|
/^\d+\.\d+\.\d+$/,
|
||||||
"the context-filter pin requires the exact upstream version",
|
"the context-filter pin requires an exact upstream version",
|
||||||
);
|
);
|
||||||
const upstream = await readFile(
|
const upstream = await readFile(
|
||||||
join(repositoryRoot, "node_modules/context-mode/build/adapters/pi/extension.js"),
|
join(repositoryRoot, "node_modules/context-mode/build/adapters/pi/extension.js"),
|
||||||
|
|||||||
Reference in New Issue
Block a user