Skip to content
Open
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
47 changes: 29 additions & 18 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,17 @@ import {
WORKTREE_REPOSITORY as MAIN_WORKTREE_REPOSITORY,
} from "./tokens";

async function cancelTaskSessions(
agentService: AgentService,
piSessionService: PiSessionService,
taskId: string,
): Promise<void> {
await Promise.all([
agentService.cancelSessionsByTaskId(taskId),
piSessionService.stop(taskId),
]);
}

export const container = new TypedContainer<MainBindings>({
defaultScope: "Singleton",
});
Expand Down Expand Up @@ -395,12 +406,12 @@ container.bind(MCP_PROXY_AUTH).toDynamicValue((ctx) => {
});
container.load(archiveModule);
container.bind(ARCHIVE_SESSION_CANCELLER).toDynamicValue((ctx) => ({
cancelSessionsByTaskId: async (taskId: string) => {
await Promise.all([
ctx.get<AgentService>(AGENT_SERVICE).cancelSessionsByTaskId(taskId),
ctx.get<PiSessionService>(PI_SESSION_SERVICE).stop(taskId),
]);
},
cancelSessionsByTaskId: (taskId: string) =>
cancelTaskSessions(
ctx.get<AgentService>(AGENT_SERVICE),
ctx.get<PiSessionService>(PI_SESSION_SERVICE),
taskId,
),
}));
container.bind(ARCHIVE_FILE_WATCHER).toDynamicValue((ctx) => ({
stopWatching: async (worktreePath: string) => {
Expand All @@ -411,12 +422,12 @@ container.bind(ARCHIVE_FILE_WATCHER).toDynamicValue((ctx) => ({
}));
container.load(suspensionModule);
container.bind(SUSPENSION_SESSION_CANCELLER).toDynamicValue((ctx) => ({
cancelSessionsByTaskId: async (taskId: string) => {
await Promise.all([
ctx.get<AgentService>(AGENT_SERVICE).cancelSessionsByTaskId(taskId),
ctx.get<PiSessionService>(PI_SESSION_SERVICE).stop(taskId),
]);
},
cancelSessionsByTaskId: (taskId: string) =>
cancelTaskSessions(
ctx.get<AgentService>(AGENT_SERVICE),
ctx.get<PiSessionService>(PI_SESSION_SERVICE),
taskId,
),
}));
container.bind(SUSPENSION_FILE_WATCHER).toDynamicValue((ctx) => ({
stopWatching: async (worktreePath: string) => {
Expand Down Expand Up @@ -687,12 +698,12 @@ container.load(workspaceModule);
container.bind(WORKSPACE_AGENT).toDynamicValue((ctx): WorkspaceAgent => {
const agent = ctx.get<AgentService>(AGENT_SERVICE);
return {
cancelSessionsByTaskId: async (taskId) => {
await Promise.all([
agent.cancelSessionsByTaskId(taskId),
ctx.get<PiSessionService>(PI_SESSION_SERVICE).stop(taskId),
]);
},
cancelSessionsByTaskId: (taskId) =>
cancelTaskSessions(
agent,
ctx.get<PiSessionService>(PI_SESSION_SERVICE),
taskId,
),
onAgentFileActivity: (handler) =>
agent.on(AgentServiceEvent.AgentFileActivity, handler),
};
Expand Down
17 changes: 9 additions & 8 deletions apps/code/src/renderer/platform-adapters/trpc-pi-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,27 @@ import type {
PiRunInput,
PiRunner,
} from "@posthog/core/pi-runtime/piRunner";
import { resolveService } from "@posthog/di/container";
import {
HOST_TRPC_CLIENT,
type HostTrpcClient,
} from "@posthog/host-router/client";
import { inject, injectable } from "inversify";

function hostClient(): HostTrpcClient {
return resolveService<HostTrpcClient>(HOST_TRPC_CLIENT);
}

@injectable()
export class TrpcPiRunner implements PiRunner {
constructor(
@inject(HOST_TRPC_CLIENT) private readonly hostClient: HostTrpcClient,
) {}

async create(input: PiRunInput): Promise<void> {
await hostClient().piSession.start.mutate(input);
await this.hostClient.piSession.start.mutate(input);
}

resume(input: PiResumeInput): Promise<void> {
return hostClient().piSession.resume.mutate(input);
return this.hostClient.piSession.resume.mutate(input);
}

stop(taskId: string): Promise<void> {
return hostClient().piSession.stop.mutate({ taskId });
return this.hostClient.piSession.stop.mutate({ taskId });
}
}
2 changes: 2 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"clean": "node ../../scripts/rimraf.mjs .turbo"
},
"dependencies": {
"@earendil-works/pi-ai": "catalog:",
"@earendil-works/pi-coding-agent": "catalog:",
"@json-render/core": "^0.19.0",
"@modelcontextprotocol/ext-apps": "^1.1.2",
"@modelcontextprotocol/sdk": "^1.12.1",
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/pi-runtime/conversation/toolKind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { AgentToolKind } from "@posthog/shared";

export type PiToolName =
| "read"
| "bash"
| "edit"
| "write"
| "grep"
| "find"
| "ls";

export const TOOL_KIND_BY_NAME: Record<PiToolName, AgentToolKind> = {
read: "read",
edit: "edit",
write: "edit",
bash: "execute",
grep: "search",
find: "search",
ls: "read",
};
22 changes: 22 additions & 0 deletions packages/core/src/pi-runtime/conversation/toolTranslator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type {
AgentToolCallContent,
AgentToolCallLocation,
} from "@posthog/shared";

export interface PiToolTranslatorInput {
toolCallId: string;
arguments: unknown;
resultContent?: (TextContent | ImageContent)[];
details?: unknown;
isError?: boolean;
}

export interface PiToolTranslatorOutput {
locations?: AgentToolCallLocation[];
content?: AgentToolCallContent[];
}

export type PiToolTranslator = (
input: PiToolTranslatorInput,
) => PiToolTranslatorOutput;
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { bashTranslator } from "./bashTranslator";

describe("bashTranslator", () => {
it("surfaces command output as text content on success", () => {
const result = bashTranslator({
toolCallId: "tool-1",
arguments: { command: "echo hi" },
resultContent: [{ type: "text", text: "hi\n" }],
details: { truncation: undefined, fullOutputPath: undefined },
isError: false,
});

expect(result).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "hi\n" },
},
],
});
});

it("surfaces stderr-style output for a failed command", () => {
const result = bashTranslator({
toolCallId: "tool-2",
arguments: { command: "false" },
resultContent: [
{ type: "text", text: "command failed with exit code 1" },
],
isError: true,
});

expect(result).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "command failed with exit code 1" },
},
],
});
});

it("returns no content when the result has no text blocks", () => {
const result = bashTranslator({
toolCallId: "tool-3",
arguments: { command: "echo hi" },
resultContent: [],
});

expect(result).toEqual({});
});

it("joins multiple text blocks and ignores image blocks", () => {
const result = bashTranslator({
toolCallId: "tool-4",
arguments: { command: "cat file.txt" },
resultContent: [
{ type: "text", text: "line one\n" },
{ type: "image", data: "abc", mimeType: "image/png" },
{ type: "text", text: "line two\n" },
],
});

expect(result).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "line one\nline two\n" },
},
],
});
});

it("returns no content when resultContent is missing", () => {
const result = bashTranslator({
toolCallId: "tool-5",
arguments: { command: "echo hi" },
});

expect(result).toEqual({});
});
});
22 changes: 22 additions & 0 deletions packages/core/src/pi-runtime/conversation/tools/bashTranslator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { AgentToolCallContent } from "@posthog/shared";
import type { PiToolTranslator } from "../toolTranslator";

export const bashTranslator: PiToolTranslator = ({ resultContent }) => {
const outputText = resultContent
?.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");

if (!outputText) {
return {};
}

const content: AgentToolCallContent[] = [
{
type: "content",
content: { type: "text", text: outputText },
},
];

return { content };
};
102 changes: 102 additions & 0 deletions packages/core/src/pi-runtime/conversation/tools/editTranslator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { editTranslator } from "./editTranslator";

describe("editTranslator", () => {
it("produces a diff from the edit arguments and a location for the path", () => {
const output = editTranslator({
toolCallId: "1",
arguments: {
path: "src/foo.ts",
edits: [{ oldText: "const a = 1;", newText: "const a = 2;" }],
},
details: {
diff: "- const a = 1;\n+ const a = 2;",
patch: "@@ -1 +1 @@\n-const a = 1;\n+const a = 2;",
firstChangedLine: 3,
},
});

expect(output.locations).toEqual([{ path: "src/foo.ts", line: 3 }]);
expect(output.content).toEqual([
{
type: "diff",
path: "src/foo.ts",
oldText: "const a = 1;",
newText: "const a = 2;",
},
]);
});

it("joins multiple edits into a single diff", () => {
const output = editTranslator({
toolCallId: "1",
arguments: {
path: "src/foo.ts",
edits: [
{ oldText: "const a = 1;", newText: "const a = 2;" },
{ oldText: "const b = 1;", newText: "const b = 2;" },
],
},
details: {
diff: "irrelevant",
patch: "irrelevant",
},
});

expect(output.content).toEqual([
{
type: "diff",
path: "src/foo.ts",
oldText: "const a = 1;\nconst b = 1;",
newText: "const a = 2;\nconst b = 2;",
},
]);
});

it("falls back to the details diff string when edits are missing", () => {
const output = editTranslator({
toolCallId: "1",
arguments: { path: "src/foo.ts" },
details: {
diff: "- const a = 1;\n+ const a = 2;",
patch: "@@ -1 +1 @@",
},
});

expect(output.locations).toEqual([{ path: "src/foo.ts", line: undefined }]);
expect(output.content).toEqual([
{
type: "diff",
path: "src/foo.ts",
newText: "- const a = 1;\n+ const a = 2;",
},
]);
});

it("falls back to result text content on error with no details", () => {
const output = editTranslator({
toolCallId: "1",
arguments: { path: "src/foo.ts" },
resultContent: [{ type: "text", text: "permission denied" }],
isError: true,
});

expect(output.locations).toEqual([{ path: "src/foo.ts", line: undefined }]);
expect(output.content).toEqual([
{
type: "content",
content: { type: "text", text: "permission denied" },
},
]);
});

it("returns no locations or content when arguments are missing entirely", () => {
const output = editTranslator({
toolCallId: "1",
arguments: undefined,
});

expect(output.locations).toBeUndefined();
expect(output.content).toBeUndefined();
});
});
Loading
Loading