Skip to content

Commit ef09bd5

Browse files
mason: verify historian host requests and preserve headless wrapup
Carry the host lifetime into Pi/OMP wrapup historian calls so headless RPC does not schedule a detached second turn. Add print, RPC UI-shim, and interactive regression cases; removing the lifetime argument makes them red. Existing behavior expectations are retained. Extend the real-host harness with Pi, explicit release pins, native OpenCode 2 provider-body options, raw request bytes, cutoff controls, and an authored provider timeout. The private report and not-run paid operator scripts are carried separately under .cortexkit. Truncated OpenCode 1 turn two remains fail-closed without an immediate retry; retry policy is intentionally unchanged. Co-authored-by: Alfonso <alfonso-magic-context@users.noreply.github.com>
1 parent 4d9d170 commit ef09bd5

8 files changed

Lines changed: 152 additions & 25 deletions

File tree

‎packages/pi-plugin/src/commands/ctx-wrapup.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,48 @@ function deps(
238238
}
239239

240240
describe("Pi /ctx-wrapup", () => {
241+
it.each([
242+
{ name: "print", hasUI: false, mode: undefined, expected: false },
243+
{ name: "RPC with UI shims", hasUI: true, mode: "rpc", expected: false },
244+
{ name: "interactive", hasUI: true, mode: undefined, expected: true },
245+
])("passes $name lifetime to the historian", async ({
246+
hasUI,
247+
mode,
248+
expected,
249+
}) => {
250+
const db = createDb();
251+
try {
252+
const sessionId = `wrapup-lifetime-${String(mode)}-${hasUI}`;
253+
const seen: Array<boolean | undefined> = [];
254+
const configured = deps(db, {
255+
runPiHistorianForWrapup: mock(async (args) => {
256+
seen.push(args.hasUI);
257+
appendRange(
258+
db,
259+
sessionId,
260+
1,
261+
args.boundarySnapshot.eligibleEndOrdinal - 1,
262+
);
263+
}),
264+
});
265+
await runPiWrapup(
266+
pi().api,
267+
configured,
268+
{
269+
...(ctx(sessionId, 8) as object),
270+
hasUI,
271+
mode,
272+
ui: { setStatus() {}, notify() {} },
273+
} as never,
274+
sessionId,
275+
2,
276+
);
277+
expect(seen).toEqual([expected]);
278+
} finally {
279+
closeQuietly(db);
280+
}
281+
});
282+
241283
it("parses optional positive messages_to_keep", () => {
242284
expect(parseWrapupArgs("")).toEqual({ ok: true, messagesToKeep: 20 });
243285
expect(parseWrapupArgs(" 7 ")).toEqual({ ok: true, messagesToKeep: 7 });

‎packages/pi-plugin/src/commands/ctx-wrapup.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,10 @@ export async function runPiWrapup(
469469
const runHistorian = deps.runPiHistorianForWrapup ?? runPiHistorian;
470470
await runHistorian({
471471
db: deps.db,
472+
// RPC can expose UI methods without a persistent interactive host.
473+
// A detached second turn would outlive the wrapup command's caller.
474+
hasUI:
475+
ctx.hasUI === true && (ctx as { mode?: string }).mode !== "rpc",
472476
sessionId,
473477
directory: ctx.cwd,
474478
provider,

‎packages/plugin/scripts/two-pass-activation-runs/event-observer.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { join } from "node:path";
33
import { underRoot } from "./activation-driver";
44

55
/** Sidecars only record host events; they register no tools and change no model input. */
6-
export function stageEventObserver(root: string, lane: "opencode-1" | "opencode-2" | "omp") {
6+
export function stageEventObserver(root: string, lane: "opencode-1" | "opencode-2" | "pi" | "omp") {
77
const directory = underRoot(root, join(root, "host-event-observer"));
88
mkdirSync(directory, { recursive: true, mode: 0o700 });
99
const eventsPath = underRoot(root, join(directory, "events.jsonl"));

‎packages/plugin/scripts/two-pass-activation-runs/host-adapters.ts‎

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { mockMeteringProxy } from "./provider-meter";
1010
import { readHostEvents, stageEventObserver } from "./event-observer";
1111
import { redactedLines } from "./credential-protocol";
1212

13-
export type HostLane = "opencode-1" | "opencode-2" | "omp";
13+
export type HostLane = "opencode-1" | "opencode-2" | "pi" | "omp";
1414
export interface HostAdapterOptions {
1515
sourceRoot: string;
1616
mockProviderURL: string;
@@ -22,8 +22,13 @@ export interface HostAdapterOptions {
2222
classify?(path: string, body: string): CallBound["kind"];
2323
magicContext: Record<string, unknown>;
2424
outputCeiling: number;
25+
/** Pin an explicitly installed release when comparing host versions. */
26+
hostVersion?: string;
27+
modelOptions?: Record<string, unknown>;
28+
reasoning?: boolean;
2529
timeoutMs?: number;
2630
startupTimeoutMs?: number;
31+
providerTimeoutMs?: number;
2732
expectTurnTwo?: boolean;
2833
/** Synthetic probes can add a second foreground turn and explicitly request wrapup. */
2934
forceWrapup?: boolean;
@@ -51,8 +56,9 @@ export function hostAdapter(lane: HostLane, options: HostAdapterOptions): Activa
5156
let hostPid: number | undefined;
5257
const processProofs = new Map<number, Isolation>();
5358
const logFilters: ReturnType<typeof redactedLines>[] = [];
54-
const binary = (root: string) => join(root, "host-dist/node_modules/.bin", lane === "omp" ? "omp" : "opencode");
55-
const entrypoint = (root: string) => join(root, lane === "omp" ? "packages/pi-plugin/dist/index.js" : lane === "opencode-2" ? "packages/plugin/dist/v2/server.js" : "packages/plugin/dist/index.js");
59+
const isPi = lane === "pi" || lane === "omp";
60+
const binary = (root: string) => join(root, "host-dist/node_modules/.bin", isPi ? lane : "opencode");
61+
const entrypoint = (root: string) => join(root, isPi ? "packages/pi-plugin/dist/index.js" : lane === "opencode-2" ? "packages/plugin/dist/v2/server.js" : "packages/plugin/dist/index.js");
5662
return processActivationTransport({
5763
stage: (root) => ({ argv: [process.execPath, installer, "stage", options.sourceRoot, root, lane], cwd: root }),
5864
build: (root) => ({ argv: [process.execPath, installer, "build", options.sourceRoot, root, lane], cwd: root }),
@@ -77,12 +83,13 @@ export function hostAdapter(lane: HostLane, options: HostAdapterOptions): Activa
7783
const result = spawnSync(binary(input.installRoot), ["--version"], { cwd: work, env: { PATH: process.env.PATH, ...versionEnv }, encoding: "utf8" });
7884
versionOutput = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
7985
version = versionOutput.match(/\d+\.\d+\.\d+/)?.[0] ?? "";
80-
if (result.status !== 0 || (lane === "opencode-1" ? !/^1\.18\.(3[1-9]|[4-9]\d|\d{3,})$/.test(version) : lane === "opencode-2" ? version !== "2.0.22" : !/18\.12\.\d+/.test(version)))
86+
if (result.status !== 0 || (options.hostVersion ? version !== options.hostVersion : lane === "opencode-1" ? !/^1\.18\.(3[1-9]|[4-9]\d|\d{3,})$/.test(version) : lane === "opencode-2" ? version !== "2.0.22" : lane === "pi" ? version !== "1.4.0" : !/18\.12\.\d+/.test(version)))
8187
throw new Error("host_version_not_pinned");
8288
proxy = await mockMeteringProxy({ upstream: options.mockProviderURL, meter: input.meter,
8389
secrets, forwardCredentials: !!options.providerKeyEnv || !!options.grantFree, paidProvider: options.paidProvider,
8490
onProviderAttempt: options.onProviderAttempt,
85-
outputCeiling: options.outputCeiling,
91+
outputCeiling: options.outputCeiling,
92+
timeoutMs: options.providerTimeoutMs,
8693
// Prompt text can mention another stage; it is not evidence of this dispatch's purpose.
8794
classify: options.classify ?? (() => "unclassified"),
8895
async beforeDispatch() {
@@ -96,7 +103,7 @@ export function hostAdapter(lane: HostLane, options: HostAdapterOptions): Activa
96103
observer = stageEventObserver(root, lane);
97104
input.env.MC_HOST_EVENT_JSONL = observer.eventsPath;
98105
writeConfigs(lane, input.installRoot, work, hostEnv, proxy.url, options, observer);
99-
return { argv: lane === "omp" ? [binary(input.installRoot), "--mode", "rpc", "--extension", entrypoint(input.installRoot), "--extension", observer.entrypoint]
106+
return { argv: isPi ? [binary(input.installRoot), "--mode", "rpc", "--extension", entrypoint(input.installRoot), "--extension", observer.entrypoint]
100107
: [binary(input.installRoot), "serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], cwd: work };
101108
},
102109
async connect(input) {
@@ -113,7 +120,7 @@ export function hostAdapter(lane: HostLane, options: HostAdapterOptions): Activa
113120
let observedStore: string | null = null;
114121
let sessionId = "";
115122
let prompt: (text: string) => Promise<unknown>;
116-
if (lane === "omp") {
123+
if (isPi) {
117124
const lines = createInterface({ input: input.stdout });
118125
const pending = new Map<string, { resolve(value: any): void; reject(error: Error): void }>();
119126
const settled: Array<() => void> = [];
@@ -143,6 +150,7 @@ export function hostAdapter(lane: HostLane, options: HostAdapterOptions): Activa
143150
for (;;) {
144151
if (Date.now() >= end) throw new Error("omp_settlement_timeout");
145152
const value = (await rpc("get_state")).data;
153+
if (lane === "pi" && value?.isStreaming === false) return value;
146154
if (typeof value?.isSettled !== "boolean" || typeof value?.hasPendingAsyncWork !== "boolean")
147155
throw new Error("omp_settlement_unobservable");
148156
if (value.isSettled && !value.isStreaming && !value.hasPendingAsyncWork) return {
@@ -305,37 +313,39 @@ async function deadline<T>(promise: Promise<T>, timeout: number): Promise<T> {
305313
finally { clearTimeout(timer!); }
306314
}
307315

308-
function writeConfigs(lane: HostLane, install: string, work: string, env: Record<string, string>, endpoint: string, options: HostAdapterOptions, observer: ReturnType<typeof stageEventObserver>) {
316+
export function writeConfigs(lane: HostLane, install: string, work: string, env: Record<string, string>, endpoint: string, options: HostAdapterOptions, observer: ReturnType<typeof stageEventObserver>) {
317+
const isPi = lane === "pi" || lane === "omp";
309318
const providerId = options.providerId ?? "mock";
310319
const model = `${providerId}/${options.modelId}`;
311320
const plugin = lane === "opencode-2" ? join(install, "packages/plugin") : pathToFileURL(join(install, "packages/plugin/dist/index.js")).href;
312321
const limits = { context: 200_000, output: options.outputCeiling };
313322
// OpenCode's grant-free Zen path uses the public marker, not an account credential.
314-
const apiKey = options.grantFree ? "public" : options.providerKeyEnv ? (lane === "omp" ? options.providerKeyEnv : `{env:${options.providerKeyEnv}}`) : "mock-only-key";
323+
const apiKey = options.grantFree ? "public" : options.providerKeyEnv ? (isPi ? options.providerKeyEnv : `{env:${options.providerKeyEnv}}`) : "mock-only-key";
315324
const config = lane === "opencode-2" ? { plugins: [plugin, observer.directory], model,
316-
providers: { [providerId]: { package: options.providerPackage ?? "@ai-sdk/openai-compatible", settings: { baseURL: `${endpoint}/v1`, apiKey }, models: { [options.modelId]: { name: options.modelId, limit: limits } } } },
325+
providers: { [providerId]: { package: options.providerPackage ?? "@opencode/ai/providers/openai-compatible", settings: { baseURL: `${endpoint}/v1`, apiKey }, ...(options.modelOptions ? { body: options.modelOptions } : {}), models: { [options.modelId]: { name: options.modelId, limit: limits } } } },
317326
compaction: { auto: false },
318327
} : { plugin: [plugin, pathToFileURL(observer.entrypoint).href], model, small_model: model, enabled_providers: [providerId], autoupdate: false,
319-
provider: { [providerId]: { npm: options.providerPackage ?? "@ai-sdk/openai-compatible", options: { baseURL: `${endpoint}/v1`, apiKey }, models: { [options.modelId]: { name: options.modelId, limit: limits } } } },
328+
provider: { [providerId]: { npm: options.providerPackage ?? "@ai-sdk/openai-compatible", options: { baseURL: `${endpoint}/v1`, apiKey }, models: { [options.modelId]: { name: options.modelId, limit: limits, options: options.modelOptions, reasoning: options.reasoning } } } },
320329
compaction: { auto: false, prune: false }, agent: { title: { disable: true }, summary: { disable: true } },
321330
};
322-
if (lane === "omp") {
331+
if (isPi) {
323332
mkdirSync(env.PI_CODING_AGENT_DIR, { recursive: true });
324333
writeFileSync(join(env.PI_CODING_AGENT_DIR, "config.yml"), JSON.stringify({ defaultProvider: providerId, defaultModel: options.modelId,
325334
enabledModels: [model], retry: { enabled: false }, compaction: { enabled: false }, quietStartup: true, enableInstallTelemetry: false, tools: { xdev: false, intentTracing: false } }));
326335
const models = JSON.stringify({ providers: { [providerId]: { api: options.piApi ?? "anthropic-messages", baseUrl: endpoint, apiKey,
327336
...(options.piAuth ? { auth: options.piAuth } : {}),
328-
models: [{ id: options.modelId, name: options.modelId, input: ["text"], contextWindow: 200_000, maxTokens: options.outputCeiling, reasoning: false, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }] } } });
337+
models: [{ id: options.modelId, name: options.modelId, input: ["text"], contextWindow: 200_000, maxTokens: options.outputCeiling, reasoning: options.reasoning ?? false, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }] } } });
329338
// OMP 18.12 reads YAML first; updating only JSON leaves a previously imported endpoint active.
330339
writeFileSync(join(env.PI_CODING_AGENT_DIR, "models.yml"), models);
331340
writeFileSync(join(env.PI_CODING_AGENT_DIR, "models.json"), models);
341+
if (lane === "pi") writeFileSync(join(env.PI_CODING_AGENT_DIR, "settings.json"), JSON.stringify({ defaultProvider: providerId, defaultModel: options.modelId, compaction: { enabled: false }, retry: { enabled: false } }));
332342
} else if (lane === "opencode-2") writeFileSync(join(work, "opencode.json"), JSON.stringify(config));
333343
else {
334344
if (existsSync(join(work, "opencode.json"))) unlinkSync(join(work, "opencode.json"));
335345
writeFileSync(join(env.XDG_CONFIG_HOME, "opencode.json"), JSON.stringify(config));
336346
}
337347
const settings = { enabled: true, memory: { enabled: true, auto_search: { enabled: false }, git_commit_indexing: { enabled: false } },
338-
embedding: { provider: "off" }, dreamer: { disable: true }, historian: { [lane === "omp" ? "omp" : "opencode"]: { model } }, ...options.magicContext };
348+
embedding: { provider: "off" }, dreamer: { disable: true }, historian: { [isPi ? lane : "opencode"]: { model } }, ...options.magicContext };
339349
mkdirSync(join(env.XDG_CONFIG_HOME, "cortexkit"), { recursive: true });
340350
writeFileSync(join(env.XDG_CONFIG_HOME, "cortexkit/magic-context.jsonc"), JSON.stringify(settings));
341351
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { writeConfigs, type HostLane } from "./host-adapters";
6+
import { mockProvider } from "./mock-provider";
7+
import { mockMeteringProxy } from "./provider-meter";
8+
9+
describe("host request check configuration", () => {
10+
for (const lane of ["opencode-1", "opencode-2", "pi", "omp"] as HostLane[]) {
11+
it(`writes documented ${lane} thinking and output settings`, () => {
12+
const root = mkdtempSync(join(tmpdir(), "host-request-config-"));
13+
try {
14+
const env = { XDG_CONFIG_HOME: join(root, "config"), PI_CODING_AGENT_DIR: join(root, "agent") };
15+
mkdirSync(env.XDG_CONFIG_HOME, { recursive: true });
16+
const thinking = { thinking: { type: "enabled" } };
17+
writeConfigs(lane, root, root, env, "http://127.0.0.1:1234", {
18+
sourceRoot: root, mockProviderURL: "http://127.0.0.1:1234", modelId: "deepseek-v4-flash",
19+
providerId: "deepseek", outputCeiling: 65536, reasoning: true, modelOptions: thinking,
20+
magicContext: { historian: { maxTokens: 65536, [lane === "pi" || lane === "omp" ? lane : "opencode"]: { model: "deepseek/deepseek-v4-flash" } } },
21+
}, { directory: root, entrypoint: join(root, "observer.js"), eventsPath: join(root, "events.jsonl") });
22+
const read = (path: string) => JSON.parse(readFileSync(path, "utf8"));
23+
if (lane === "pi" || lane === "omp") {
24+
const model = read(join(env.PI_CODING_AGENT_DIR, "models.json")).providers.deepseek.models[0];
25+
expect(model.reasoning).toBe(true);
26+
expect(model.maxTokens).toBe(65536);
27+
} else {
28+
const config = read(join(lane === "opencode-1" ? env.XDG_CONFIG_HOME : root, "opencode.json"));
29+
const provider = (config.providers ?? config.provider).deepseek;
30+
expect(provider.models["deepseek-v4-flash"].limit.output).toBe(65536);
31+
expect(lane === "opencode-2" ? provider.body : provider.models["deepseek-v4-flash"].options).toEqual(thinking);
32+
}
33+
} finally { rmSync(root, { recursive: true, force: true }); }
34+
});
35+
}
36+
});
37+
38+
describe("capturing provider truncation", () => {
39+
it("honors the configured provider timeout", async () => {
40+
const upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch() {
41+
await Bun.sleep(200);
42+
return Response.json({ usage: { prompt_tokens: 1, completion_tokens: 1 } });
43+
} });
44+
const proxy = await mockMeteringProxy({ upstream: upstream.url.href.replace(/\/$/, ""), timeoutMs: 20,
45+
outputCeiling: 64, classify: () => "unclassified", meter: { beforeSend: () => 0, settle() {} } });
46+
try {
47+
const response = await fetch(proxy.url, { method: "POST", body: "{}" });
48+
expect(response.status).toBe(502);
49+
expect(proxy.receipts).toHaveLength(0);
50+
expect(proxy.failures).toEqual(["provider_dispatch_failed"]);
51+
} finally { await proxy.close(); upstream.stop(true); }
52+
});
53+
54+
for (const stream of [false, true]) {
55+
it(`captures exact bytes and emits length with stream=${stream}`, async () => {
56+
const server = mockProvider(() => "complete but capped JSON", undefined, undefined, () => "length");
57+
try {
58+
const raw = `{ "model": "deepseek-v4-flash", "stream": ${stream}, "messages": [] }`;
59+
const response = await fetch(`${server.url}/v1/chat/completions`, { method: "POST", body: raw });
60+
const body = await response.text();
61+
expect(server.requests[0]?.rawBody).toBe(raw);
62+
expect(body).toContain('"finish_reason":"length"');
63+
expect(body).not.toContain('"finish_reason":"stop"');
64+
} finally { server.close(); }
65+
});
66+
}
67+
});

‎packages/plugin/scripts/two-pass-activation-runs/install-host.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process";
55
export const hostPackages = {
66
"opencode-1": "opencode-ai@1.18.31",
77
"opencode-2": "@opencode/cli@2.0.22",
8+
pi: "@earendil-works/pi-coding-agent@1.4.0",
89
omp: "@oh-my-pi/pi-coding-agent@18.12.3",
910
} as const;
1011

@@ -25,14 +26,14 @@ if (import.meta.main) {
2526
};
2627
run([process.execPath, "install", "--frozen-lockfile"], root);
2728
run([process.execPath, "run", "--cwd", "packages/plugin", "build"], root);
28-
if (lane === "omp") run([process.execPath, "run", "--cwd", "packages/pi-plugin", "build"], root);
29+
if (lane === "omp" || lane === "pi") run([process.execPath, "run", "--cwd", "packages/pi-plugin", "build"], root);
2930
const host = join(root, "host-dist");
3031
mkdirSync(host, { recursive: true });
3132
const pinned = hostPackages[lane as keyof typeof hostPackages];
3233
writeFileSync(join(host, "package.json"), JSON.stringify({ private: true, trustedDependencies: [pinned.slice(0, pinned.lastIndexOf("@"))] }));
3334
run([process.execPath, "add", "--exact", hostPackages[lane as keyof typeof hostPackages]], host);
3435
if (lane === "opencode-2") run([process.execPath, join(host, "node_modules/@opencode/cli/postinstall.mjs")], host);
35-
if (lane !== "omp") {
36+
if (lane !== "omp" && lane !== "pi") {
3637
// OpenCode otherwise installs its plugin SDK on first directory initialization,
3738
// which cannot finish when the proof container has external networking disabled.
3839
const config = process.env.XDG_CONFIG_HOME;

0 commit comments

Comments
 (0)