Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"node-pty": "^1.1.0"
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "^0.80.2",
"@earendil-works/pi-coding-agent": "^0.80.10",
"@effect/vitest": "catalog:",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
Expand Down
3 changes: 2 additions & 1 deletion apps/server/scripts/pi-mock-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ rl.on("line", (line: string) => {
lastAssistantText = replyText;
write({ type: "message_end" });
write({ type: "turn_end" });
write({ type: "agent_end" });
write({ type: "agent_end", willRetry: false });
write({ type: "agent_settled" });
return;
}
case "get_last_assistant_text": {
Expand Down
10 changes: 6 additions & 4 deletions apps/server/src/provider/Drivers/PiDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { ServerSettingsService } from "../../serverSettings.ts";
import { makePiTextGeneration } from "../../textGeneration/PiTextGeneration.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makePiAdapter } from "../Layers/PiAdapter.ts";
import { resolvePiProcessEnv } from "../Layers/PiEnvironment.ts";
import { buildInitialPiProviderSnapshot, checkPiProviderStatus } from "../Layers/PiProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import {
defaultProviderContinuationIdentity,
Expand Down Expand Up @@ -52,7 +52,6 @@ export type PiDriverEnv =
| FileSystem.FileSystem
| HttpClient.HttpClient
| Path.Path
| ProviderEventLoggers
| ServerConfig
| ServerSettingsService;

Expand Down Expand Up @@ -86,7 +85,11 @@ export const PiDriver: ProviderDriver<PiSettings, PiDriverEnv> = {
const httpClient = yield* HttpClient.HttpClient;
const serverSettings = yield* ServerSettingsService;
const serverConfig = yield* ServerConfig;
const processEnv = mergeProviderInstanceEnvironment(environment);
const effectiveConfig = { ...config, enabled } satisfies PiSettings;
const processEnv = resolvePiProcessEnv(
effectiveConfig,
mergeProviderInstanceEnvironment(environment),
);
const continuationIdentity = defaultProviderContinuationIdentity({
driverKind: DRIVER_KIND,
instanceId,
Expand All @@ -97,7 +100,6 @@ export const PiDriver: ProviderDriver<PiSettings, PiDriverEnv> = {
accentColor,
continuationGroupKey: continuationIdentity.continuationKey,
});
const effectiveConfig = { ...config, enabled } satisfies PiSettings;
const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
binaryPath: effectiveConfig.binaryPath,
env: processEnv,
Expand Down
27 changes: 27 additions & 0 deletions apps/server/src/provider/Layers/PiAdapter.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,4 +519,31 @@ it.layer(HarnessLayer)("PiAdapter integration", (it) => {
expect(fake.commands.some((command) => command.type === "steer")).toBe(true);
}),
);

it.effect("completes a turn on agent_settled without double-completing after agent_end", () =>
Effect.gen(function* () {
const { adapter, fake } = yield* makePiAdapterForTest(enabledSettings());
const threadId = ThreadId.make("pi-int-settled");
const { fiber, store } = yield* collectEvents(
adapter,
threadId,
(event) => event.type === "turn.completed",
);
yield* adapter.startSession({
threadId,
provider: PI,
cwd: process.cwd(),
runtimeMode: "full-access",
});
yield* adapter.sendTurn({ threadId, input: "hello", attachments: [] });
yield* fake.pushEvent({ type: "turn_start" } as AgentSessionEvent);
yield* fake.pushEvent({ type: "agent_end", willRetry: false } as AgentSessionEvent);
yield* fake.pushEvent({ type: "agent_settled" } as AgentSessionEvent);
yield* Fiber.join(fiber);
const completions = (yield* Ref.get(store)).filter(
(event) => event.type === "turn.completed",
);
expect(completions.length).toBe(1);
}),
);
});
45 changes: 16 additions & 29 deletions apps/server/src/provider/Layers/PiAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
extractStateModelSlug,
makePiRpcTransport,
type MakePiRpcTransportOptions,
PI_APPROVAL_SENTINEL_COMMAND,
piForkSucceeded,
piImageContentFromBytes,
type PiImageContent,
Expand All @@ -68,6 +69,7 @@ import {
type RpcExtensionUIRequest,
type RpcExtensionUIResponse,
} from "./PiRpcClient.ts";
import { resolvePiProcessEnv } from "./PiEnvironment.ts";

const PROVIDER = ProviderDriverKind.make("pi");

Expand All @@ -78,9 +80,6 @@ const PI_MESSAGES_TIMEOUT_MS = 5_000;
const PI_FORK_TIMEOUT_MS = 15_000;
const PI_MODEL_OPTIONS_TIMEOUT_MS = 5_000;

// keep in sync with SENTINEL_COMMAND in t3-approvals.ts
const PI_APPROVAL_SENTINEL_COMMAND = "t3-approval-gate";

// like Claude/Cursor: full-access runs ungated; approval-required and
// auto-accept-edits gate via the bundled extension (Pi has no native per-tool approval)
function approvalGateForRuntimeMode(
Expand Down Expand Up @@ -307,7 +306,8 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* (
const crypto = yield* Crypto.Crypto;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const baseEnvironment = options?.environment ?? process.env;
// Caller may already resolve PI_CODING_AGENT_DIR; re-applying is idempotent.
const baseEnvironment = resolvePiProcessEnv(piSettings, options?.environment ?? process.env);

let approvalExtensionPath: string | undefined;
for (const candidate of APPROVAL_EXTENSION_CANDIDATES) {
Expand Down Expand Up @@ -529,15 +529,24 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* (
}

case "agent_end": {
// willRetry means pi will auto-retry (another agent_start/end cycle) —
// finalize only on the terminal end, since a retry isn't a user interrupt
// willRetry means pi will auto-retry (another agent_start/end cycle).
// Prefer agent_settled (Pi >= 0.80.5) for true idle; keep this as a
// fallback for older binaries. completeTurn is idempotent either way.
if (event.willRetry) return;
if (context.turnState) {
yield* completeTurn(context, "completed");
}
return;
}

case "agent_settled": {
// Session-level idle: no retry, compaction retry, or queued follow-up remains.
if (context.turnState) {
yield* completeTurn(context, "completed");
}
return;
}

case "compaction_start": {
yield* offerRuntimeEvent({
...base,
Expand Down Expand Up @@ -1110,29 +1119,7 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* (
yield* applyThinkingLevel(context, input.modelSelection);
}

if (!context.turnState) {
const turnId = TurnId.make(yield* nextUuid);
const startedAt = yield* nowIso;
context.turnState = { turnId, startedAt, items: [] };
context.session = {
...context.session,
status: "running",
activeTurnId: turnId,
updatedAt: startedAt,
};
const stamp = yield* makeEventStamp();
yield* offerRuntimeEvent({
...stamp,
type: "turn.started",
provider: PROVIDER,
providerInstanceId: boundInstanceId,
threadId: context.session.threadId,
turnId,
payload: context.currentModel ? { model: context.currentModel } : {},
});
}

const turnId = context.turnState.turnId;
const turnId = context.turnState?.turnId ?? (yield* openTurn(context));

yield* context.transport
.writeCommand(buildPiTurnCommand({ isMidTurn, message: promptText, images }))
Expand Down
24 changes: 24 additions & 0 deletions apps/server/src/provider/Layers/PiEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "@effect/vitest";

import { expandHomePath } from "../../pathExpansion.ts";
import { resolvePiProcessEnv } from "./PiEnvironment.ts";

describe("resolvePiProcessEnv", () => {
it("returns the base env unchanged when codingAgentDir is empty", () => {
const base = { PATH: "/usr/bin", PI_CODING_AGENT_DIR: "/existing" };
expect(resolvePiProcessEnv({ codingAgentDir: "" }, base)).toBe(base);
expect(resolvePiProcessEnv({ codingAgentDir: " " }, base)).toBe(base);
});

it("sets PI_CODING_AGENT_DIR from codingAgentDir, expanding ~", () => {
const resolved = resolvePiProcessEnv(
{ codingAgentDir: "~/.pi/work" },
{ PATH: "/usr/bin", KEEP: "1" },
);
expect(resolved).toEqual({
PATH: "/usr/bin",
KEEP: "1",
PI_CODING_AGENT_DIR: expandHomePath("~/.pi/work"),
});
});
});
24 changes: 24 additions & 0 deletions apps/server/src/provider/Layers/PiEnvironment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { PiSettings } from "@t3tools/contracts";

import { expandHomePath } from "../../pathExpansion.ts";

/**
* Resolve the process environment for a Pi CLI spawn.
*
* When `codingAgentDir` is set, it becomes `PI_CODING_AGENT_DIR` so Pi loads
* auth/models/settings/extensions/packages from that directory — the same
* config surface the `pi` CLI uses.
*/
export function resolvePiProcessEnv(
settings: Pick<PiSettings, "codingAgentDir">,
baseEnv: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const codingAgentDir = settings.codingAgentDir.trim();
if (codingAgentDir.length === 0) {
return baseEnv;
}
return {
...baseEnv,
PI_CODING_AGENT_DIR: expandHomePath(codingAgentDir),
};
}
34 changes: 26 additions & 8 deletions apps/server/src/provider/Layers/PiProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,39 @@ import { buildInitialPiProviderSnapshot, checkPiProviderStatus } from "./PiProvi

const decodePiSettings = Schema.decodeSync(PiSettings);

// fake `pi`: `--version` exits 0; for RPC, wait for a stdin request then reply with models.
// Responding only after a request avoids racing the transport's pending-request registration.
const healthyPiScript = (modelsJson: string) =>
// fake `pi`: `--version` exits 0; for RPC, answer get_available_models + get_commands
// then exit. Responding only after a request avoids racing pending-request registration.
const healthyPiScript = (modelsJson: string, commandsJson = "[]") =>
[
"#!/bin/sh",
'if [ "$1" = "--version" ]; then',
' printf "pi 0.80.2\\n"',
' printf "pi 0.80.10\\n"',
" exit 0",
"fi",
"answered_models=0",
"answered_commands=0",
"while IFS= read -r line; do",
' id=$(printf "%s" "$line" | sed -n \'s/.*"id":"\\([^"]*\\)".*/\\1/p\')',
' [ -z "$id" ] && id="pi-model-discovery"',
` printf '{"type":"response","command":"get_available_models","id":"%s","success":true,"data":{"models":${modelsJson}}}\\n' "$id"`,
" exit 0",
' [ -z "$id" ] && id="pi-discovery"',
' if printf "%s" "$line" | grep -q \'"type":"get_available_models"\'; then',
` printf '{"type":"response","command":"get_available_models","id":"%s","success":true,"data":{"models":${modelsJson}}}\\n' "$id"`,
" answered_models=1",
' elif printf "%s" "$line" | grep -q \'"type":"get_commands"\'; then',
` printf '{"type":"response","command":"get_commands","id":"%s","success":true,"data":{"commands":${commandsJson}}}\\n' "$id"`,
" answered_commands=1",
" fi",
' if [ "$answered_models" = "1" ] && [ "$answered_commands" = "1" ]; then',
" exit 0",
" fi",
"done",
"",
].join("\n");

const HEALTHY_PI_SCRIPT_NO_MODELS = healthyPiScript("[]");
const HEALTHY_PI_SCRIPT_WITH_MODELS = healthyPiScript('[{"provider":"openai","id":"gpt-4o"}]');
const HEALTHY_PI_SCRIPT_WITH_MODELS = healthyPiScript(
'[{"provider":"openai","id":"gpt-4o"}]',
'[{"name":"session-name","description":"Rename session","source":"extension"},{"name":"t3-approval-gate","source":"extension"},{"name":"skill:demo","description":"Demo skill","source":"skill"}]',
);

describe("buildInitialPiProviderSnapshot", () => {
it.effect("returns a disabled snapshot when settings.enabled is false", () =>
Expand Down Expand Up @@ -139,6 +152,11 @@ it.layer(NodeServices.layer)("checkPiProviderStatus", (it) => {
expect(snapshot.status).toBe("ready");
expect(snapshot.auth.status).toBe("authenticated");
expect(snapshot.models.map((model) => model.slug)).toContain("openai/gpt-4o");
expect(snapshot.slashCommands.map((command) => command.name)).toEqual([
"session-name",
"skill:demo",
]);
expect(snapshot.showInteractionModeToggle).toBe(false);
}),
);

Expand Down
Loading
Loading