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
4 changes: 3 additions & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"@types/ws": "^8.18.1",
"@wdio/electron-service": "10.1.0",
"@wdio/native-utils": "2.5.0",
"electron": "34.5.8",
"electron": "36.9.5",
"electron-devtools-installer": "^4.0.0",
"eslint": "^9.22.0",
"eslint-config-prettier": "^10.1.1",
Expand All @@ -90,6 +90,8 @@
"dependencies": {
"@ai-sdk/openai": "^3.0.90",
"@anthropic-ai/sandbox-runtime": "^0.0.67",
"@earendil-works/pi-agent-core": "0.83.0",
"@earendil-works/pi-ai": "0.83.0",
"@earendil-works/pi-coding-agent": "^0.83.0",
"@fontsource/inter": "^5.2.5",
"@leeoniya/ufuzzy": "^1.0.18",
Expand Down
26 changes: 26 additions & 0 deletions packages/app/src/electro-bridge/ipc/local-ai-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,32 @@ describe("local AI IPC", () => {
expect(runtime.updateMemorySettings).toHaveBeenCalledOnce();
});

it.each(["openai-api", "fireworks-api"] as const)(
"accepts the registered %s provider as a memory curator",
async (subconsciousProvider) => {
const sender = new FakeWebContents(1);
const runtime = createRuntime();
const { handlers, ipc } = createMainIPC();
setupLocalAIIPC(
{
runtime,
getAllowedWebContents: () => sender as never,
},
ipc as never,
);

await expect(
handlers.get(LOCAL_AI_CHANNELS.UPDATE_MEMORY_SETTINGS)?.(
createEvent(sender),
{ subconsciousProvider },
),
).resolves.toMatchObject({ success: true });
expect(runtime.updateMemorySettings).toHaveBeenCalledWith({
subconsciousProvider,
});
},
);

it("validates read-only block changes before they reach memory storage", async () => {
const sender = new FakeWebContents(1);
const runtime = createRuntime();
Expand Down
22 changes: 7 additions & 15 deletions packages/app/src/electro-bridge/ipc/local-ai-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
LocalAITurnRuntimeStateRequest,
} from "@/shared/types/local-ai";
import { isLocalAIMemoryProvider } from "@/shared/types/local-ai";
import { isLocalAiProviderId } from "@/electron/ai/types";
import { createLocalAIAPI, LOCAL_AI_CHANNELS } from "./local-ai-api";
import {
contextBridge,
Expand Down Expand Up @@ -53,12 +54,6 @@ interface ActiveConversationLease {
}

const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
const ALLOWED_PROVIDER_IDS = new Set([
"claude-code",
"codex-cli",
"openai-api",
"fireworks-api",
]);
const MAX_MESSAGE_CHARS = 200_000;
const MAX_REQUEST_CHARS = 1_000_000;
const MAX_INTERACTION_RESPONSE_CHARS = 20_000;
Expand Down Expand Up @@ -214,7 +209,7 @@ function validateRequest(request: unknown): request is LocalAIChatRequest {
!isValidIdentifier(request.conversationId) ||
!isValidIdentifier(request.turnId) ||
typeof request.providerId !== "string" ||
!ALLOWED_PROVIDER_IDS.has(request.providerId) ||
!isLocalAiProviderId(request.providerId) ||
!isRecord(request.operation)
) {
return false;
Expand Down Expand Up @@ -387,7 +382,7 @@ function validateResetRequest(
isRecord(request) &&
isValidIdentifier(request.conversationId) &&
typeof request.providerId === "string" &&
ALLOWED_PROVIDER_IDS.has(request.providerId)
isLocalAiProviderId(request.providerId)
);
}

Expand All @@ -411,9 +406,9 @@ function validateMemorySettingsUpdate(
isLocalAIMemoryProvider(update.provider))) &&
(update.subconsciousProvider === undefined ||
update.subconsciousProvider === "off" ||
update.subconsciousProvider === "codex-cli" ||
update.subconsciousProvider === "claude-code" ||
update.subconsciousProvider === "follow-active") &&
update.subconsciousProvider === "follow-active" ||
(typeof update.subconsciousProvider === "string" &&
isLocalAiProviderId(update.subconsciousProvider))) &&
(update.schedule === undefined ||
update.schedule === "every-turn" ||
update.schedule === "batch" ||
Expand Down Expand Up @@ -622,10 +617,7 @@ export function setupLocalAIIPC(
);
}
if (!options.runtime) return failure(runtimeUnavailable());
if (
typeof providerId !== "string" ||
!ALLOWED_PROVIDER_IDS.has(providerId)
) {
if (typeof providerId !== "string" || !isLocalAiProviderId(providerId)) {
return failure(
createError("Invalid provider id", "LOCAL_AI_INVALID_REQUEST"),
);
Expand Down
26 changes: 26 additions & 0 deletions packages/app/src/electron/ai/__tests__/agent-host-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,32 @@ function input(
}

describe("Agent Host task tools", () => {
it("preserves lifecycle hooks while adding task tools", async () => {
const prepareDurableTurnHook = vi.fn(() => undefined);
const replayDurableTurnHook = vi.fn(() => undefined);
const onTurnCompleted = vi.fn(() => undefined);
const onTurnFailed = vi.fn(() => undefined);
const hooks = withAgentHostTools(
{
prepareDurableTurnHook,
replayDurableTurnHook,
onTurnCompleted,
onTurnFailed,
},
() => undefined,
);

await hooks.prepareDurableTurnHook?.(undefined as never);
await hooks.replayDurableTurnHook?.(undefined as never);
await hooks.onTurnCompleted?.(undefined as never);
await hooks.onTurnFailed?.(undefined as never);

expect(prepareDurableTurnHook).toHaveBeenCalledOnce();
expect(replayDurableTurnHook).toHaveBeenCalledOnce();
expect(onTurnCompleted).toHaveBeenCalledOnce();
expect(onTurnFailed).toHaveBeenCalledOnce();
});

it("injects one task tool wherever the agent is standing", async () => {
const host = {
listTasks: vi.fn(async () => [task]),
Expand Down
124 changes: 124 additions & 0 deletions packages/app/src/electron/ai/__tests__/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type LocalAiProviderAdapter,
} from "../provider-adapter";
import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "../provider-descriptors";
import type { PiModel } from "../pi-agent-types";
import {
describeSandboxMemory,
fingerprintAgentContext,
Expand Down Expand Up @@ -401,6 +402,129 @@ describe("LocalAiRuntime", () => {
).toBeUndefined();
});

it("routes stateless Pi runs through Pi and replays bounded history", async () => {
const piModel: PiModel = {
id: "gpt-test",
name: "GPT Test",
api: "openai-responses",
provider: "openai",
baseUrl: "https://example.test/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 100_000,
maxTokens: 10_000,
};
const adapter: LocalAiProviderAdapter = {
id: "openai-api",
enforcesSandbox: false,
resumesNativeSession: false,
getStatus: vi.fn(async () => ({
...LOCAL_AI_PROVIDER_DESCRIPTORS["openai-api"],
available: true,
authenticated: true,
checkedAt: new Date().toISOString(),
})),
prepareRun: vi.fn(async () => ({
executionEngine: "pi-agent-core" as const,
model: piModel,
apiKey: "test-key",
getNativeSessionId: () => "pi-session",
})),
dispose: vi.fn(async () => undefined),
};
const streamInvoker = vi.fn<RuntimeStreamInvoker>(() => {
throw new Error("AI SDK stream must not run for a Pi provider.");
});
const piStreamFactory = vi.fn(() => ({
toUIMessageStream: async function* () {
yield { type: "text-start" as const, id: "pi-text" };
yield {
type: "text-delta" as const,
id: "pi-text",
delta: "Pi",
};
yield { type: "text-end" as const, id: "pi-text" };
yield { type: "finish" as const, finishReason: "stop" as const };
},
finishReason: Promise.resolve("stop" as const),
usage: Promise.resolve({
inputTokens: 2,
outputTokens: 1,
totalTokens: 3,
}),
}));
const events: LocalAIStreamEvent[] = [];
const runtime = new LocalAiRuntime({
adapters: [adapter],
streamInvoker,
piStreamFactory,
sessionRepository: new InMemorySessionStateRepository(),
});

await runtime.startChat(
request({
providerId: "openai-api",
agent: { systemPrompt: "system" },
}),
(event) => events.push(event),
);
await runtime.startChat(
request({
requestId: "request-2",
turnId: "turn-2",
providerId: "openai-api",
operation: {
kind: "append",
message: { role: "user", content: "follow up" },
recoveryMessages: [
{ role: "user", content: "hello" },
{ role: "assistant", content: "Pi" },
{ role: "user", content: "follow up" },
],
},
agent: { systemPrompt: "system" },
}),
(event) => events.push(event),
);

expect(streamInvoker).not.toHaveBeenCalled();
expect(piStreamFactory).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
requestId: "request-1",
run: expect.objectContaining({
executionEngine: "pi-agent-core",
model: piModel,
}),
messages: [
{ role: "system", content: "system" },
{ role: "user", content: "hello" },
],
}),
);
expect(piStreamFactory).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
requestId: "request-2",
messages: [
{ role: "system", content: "system" },
{ role: "user", content: "hello" },
{ role: "assistant", content: "Pi" },
{ role: "user", content: "follow up" },
],
}),
);
expect(
vi.mocked(adapter.prepareRun).mock.calls[1]?.[2].session,
).toMatchObject({ nativeSessionId: "pi-session" });
expect(events.at(-1)).toMatchObject({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 2, outputTokens: 1, totalTokens: 3 },
});
});

it("enforces text-only policy before provider tool preparation", async () => {
const adapter = fakeAdapter("codex-cli");
const getToolGroups = vi.fn(async () => {
Expand Down
37 changes: 37 additions & 0 deletions packages/app/src/electron/ai/__tests__/workspace-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,43 @@ function hookInput(memberId: string | undefined): LocalAiTurnHookInput {
}

describe("withWorkspacePerception", () => {
it("preserves bound lifecycle hooks from a class instance", async () => {
class HookOwner {
readonly calls: string[] = [];

prepareDurableTurnHook() {
this.calls.push("prepare-durable");
return undefined;
}

replayDurableTurnHook() {
this.calls.push("replay-durable");
}

onTurnCompleted() {
this.calls.push("completed");
}

onTurnFailed() {
this.calls.push("failed");
}
}
const owner = new HookOwner();
const hooks = withWorkspacePerception(owner);

await hooks.prepareDurableTurnHook?.(undefined as never);
await hooks.replayDurableTurnHook?.(undefined as never);
await hooks.onTurnCompleted?.(undefined as never);
await hooks.onTurnFailed?.(undefined as never);

expect(owner.calls).toEqual([
"prepare-durable",
"replay-durable",
"completed",
"failed",
]);
});

it("appends eyes to the tools an existing hook already injected", async () => {
const existing: AgentTool = {
name: "memory_status",
Expand Down
13 changes: 12 additions & 1 deletion packages/app/src/electron/ai/agent-host-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,18 @@ export function withAgentHostTools(
getHost: () => AgentHost | undefined,
): LocalAiTurnHooks {
return {
...hooks,
prepareDurableTurnHook: hooks.prepareDurableTurnHook
? (input) => hooks.prepareDurableTurnHook!(input)
: undefined,
replayDurableTurnHook: hooks.replayDurableTurnHook
? (hook) => hooks.replayDurableTurnHook!(hook)
: undefined,
onTurnCompleted: hooks.onTurnCompleted
? (input) => hooks.onTurnCompleted!(input)
: undefined,
onTurnFailed: hooks.onTurnFailed
? (input) => hooks.onTurnFailed!(input)
: undefined,
prepareTurnContext: async (
input,
): Promise<PreparedLocalAiTurnContext | undefined> => {
Expand Down
Loading
Loading