Skip to content

Commit acdb7d0

Browse files
benjackwhiteclaudedmarticusgreptile-apps[bot]
authored
feat(agents): agent-applications console (port from agent_platform) (#2700)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: dmarticus <dylan@posthog.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent 8f09e68 commit acdb7d0

124 files changed

Lines changed: 13739 additions & 54 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/code/src/main/di/bindings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ import type {
4444
SLACK_INTEGRATION_SERVICE,
4545
} from "@posthog/core/integrations/identifiers";
4646
import type { SlackIntegrationService } from "@posthog/core/integrations/slack";
47+
import type { ApprovalLinkService } from "@posthog/core/links/approval-link";
4748
import type {
49+
APPROVAL_LINK_SERVICE,
4850
INBOX_LINK_SERVICE,
4951
NEW_TASK_LINK_SERVICE,
5052
SCOUT_LINK_SERVICE,
@@ -233,6 +235,7 @@ import type { WorkspaceServerService } from "../services/workspace-server/servic
233235
import type { rendererStore } from "../utils/store";
234236
import type {
235237
APP_LIFECYCLE_SERVICE as MAIN_APP_LIFECYCLE_SERVICE,
238+
APPROVAL_LINK_SERVICE as MAIN_APPROVAL_LINK_SERVICE,
236239
ARCHIVE_REPOSITORY as MAIN_ARCHIVE_REPOSITORY,
237240
AUTH_PREFERENCE_REPOSITORY as MAIN_AUTH_PREFERENCE_REPOSITORY,
238241
AUTH_SERVICE as MAIN_AUTH_SERVICE,
@@ -401,10 +404,12 @@ export interface MainBindings {
401404
[MAIN_INBOX_LINK_SERVICE]: InboxLinkService;
402405
[MAIN_SCOUT_LINK_SERVICE]: ScoutLinkService;
403406
[MAIN_NEW_TASK_LINK_SERVICE]: NewTaskLinkService;
407+
[MAIN_APPROVAL_LINK_SERVICE]: ApprovalLinkService;
404408
[TASK_LINK_SERVICE]: TaskLinkService;
405409
[INBOX_LINK_SERVICE]: InboxLinkService;
406410
[SCOUT_LINK_SERVICE]: ScoutLinkService;
407411
[NEW_TASK_LINK_SERVICE]: NewTaskLinkService;
412+
[APPROVAL_LINK_SERVICE]: ApprovalLinkService;
408413

409414
// Watcher registry
410415
[MAIN_WATCHER_REGISTRY_SERVICE]: WatcherRegistryService;

apps/code/src/main/di/container.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ import { GIT_DIFF_SOURCE } from "@posthog/core/git-pr/identifiers";
4646
import { handoffModule } from "@posthog/core/handoff/handoff.module";
4747
import { HANDOFF_HOST } from "@posthog/core/handoff/identifiers";
4848
import { integrationsModule } from "@posthog/core/integrations/integrations.module";
49+
import { ApprovalLinkService } from "@posthog/core/links/approval-link";
4950
import {
51+
APPROVAL_LINK_SERVICE,
5052
INBOX_LINK_SERVICE,
5153
NEW_TASK_LINK_SERVICE,
5254
SCOUT_LINK_SERVICE,
@@ -245,6 +247,7 @@ import { rendererStore } from "../utils/store";
245247
import type { MainBindings } from "./bindings";
246248
import {
247249
APP_LIFECYCLE_SERVICE as MAIN_APP_LIFECYCLE_SERVICE,
250+
APPROVAL_LINK_SERVICE as MAIN_APPROVAL_LINK_SERVICE,
248251
ARCHIVE_REPOSITORY as MAIN_ARCHIVE_REPOSITORY,
249252
AUTH_PREFERENCE_REPOSITORY as MAIN_AUTH_PREFERENCE_REPOSITORY,
250253
AUTH_SERVICE as MAIN_AUTH_SERVICE,
@@ -619,6 +622,10 @@ container.bind(MAIN_SCOUT_LINK_SERVICE).to(ScoutLinkService);
619622
container.bind(SCOUT_LINK_SERVICE).toService(MAIN_TOKENS.ScoutLinkService);
620623
container.bind(MAIN_NEW_TASK_LINK_SERVICE).to(NewTaskLinkService);
621624
container.bind(NEW_TASK_LINK_SERVICE).toService(MAIN_TOKENS.NewTaskLinkService);
625+
container.bind(MAIN_APPROVAL_LINK_SERVICE).to(ApprovalLinkService);
626+
container
627+
.bind(APPROVAL_LINK_SERVICE)
628+
.toService(MAIN_TOKENS.ApprovalLinkService);
622629
container.load(watcherRegistryModule);
623630
container
624631
.bind(MAIN_WATCHER_REGISTRY_SERVICE)

apps/code/src/main/di/tokens.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ export const SCOUT_LINK_SERVICE = Symbol.for(
103103
export const NEW_TASK_LINK_SERVICE = Symbol.for(
104104
"posthog.host.main.new-task-link.service",
105105
);
106+
export const APPROVAL_LINK_SERVICE = Symbol.for(
107+
"posthog.host.main.approval-link.service",
108+
);
106109
export const WATCHER_REGISTRY_SERVICE = Symbol.for(
107110
"posthog.host.main.watcher-registry.service",
108111
);
@@ -159,6 +162,7 @@ export const MAIN_TOKENS = Object.freeze({
159162
InboxLinkService: INBOX_LINK_SERVICE,
160163
ScoutLinkService: SCOUT_LINK_SERVICE,
161164
NewTaskLinkService: NEW_TASK_LINK_SERVICE,
165+
ApprovalLinkService: APPROVAL_LINK_SERVICE,
162166
WatcherRegistryService: WATCHER_REGISTRY_SERVICE,
163167
ProvisioningService: PROVISIONING_SERVICE,
164168
WorkspaceService: WORKSPACE_SERVICE,

apps/code/src/main/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
SLACK_INTEGRATION_SERVICE,
2323
} from "@posthog/core/integrations/identifiers";
2424
import type { SlackIntegrationService } from "@posthog/core/integrations/slack";
25+
import type { ApprovalLinkService } from "@posthog/core/links/approval-link";
2526
import type { InboxLinkService } from "@posthog/core/links/inbox-link";
2627
import type { NewTaskLinkService } from "@posthog/core/links/new-task-link";
2728
import type { ScoutLinkService } from "@posthog/core/links/scout-link";
@@ -227,6 +228,7 @@ async function initializeServices(): Promise<void> {
227228
container.get<InboxLinkService>(MAIN_TOKENS.InboxLinkService);
228229
container.get<ScoutLinkService>(MAIN_TOKENS.ScoutLinkService);
229230
container.get<NewTaskLinkService>(MAIN_TOKENS.NewTaskLinkService);
231+
container.get<ApprovalLinkService>(MAIN_TOKENS.ApprovalLinkService);
230232
container.get<GitHubIntegrationService>(GITHUB_INTEGRATION_SERVICE);
231233
container.get<SlackIntegrationService>(SLACK_INTEGRATION_SERVICE);
232234
container.get<ExternalAppsService>(MAIN_TOKENS.ExternalAppsService);

apps/code/src/renderer/desktop-contributions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { agentChatCoreModule } from "@posthog/core/agent-chat/agentChat.module";
12
import { billingCoreModule } from "@posthog/core/billing/billing.module";
23
import { inboxCoreModule } from "@posthog/core/inbox/inbox.module";
34
import { githubConnectModule } from "@posthog/core/integrations/githubConnect.module";
@@ -25,6 +26,7 @@ import { container } from "@renderer/di/container";
2526

2627
export function registerDesktopContributions(): void {
2728
for (const module of [
29+
agentChatCoreModule,
2830
agentUiModule,
2931
authUiModule,
3032
billingUiModule,

docs/DEEP-LINKS.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ PostHog Code registers custom URL schemes so the desktop app can be opened with
1010
| Development | `posthog-code-dev://` |
1111
| Legacy (production only) | `twig://`, `array://` |
1212

13-
All schemes route through the same dispatcher. The host portion of the URL selects the handler (`task`, `inbox`, `scout`, `new`, `plan`, `issue`, `callback`, `integration`, `slack-integration`, `mcp-oauth-complete`).
13+
All schemes route through the same dispatcher. The host portion of the URL selects the handler (`task`, `inbox`, `scout`, `approval`, `new`, `plan`, `issue`, `callback`, `integration`, `slack-integration`, `mcp-oauth-complete`).
1414

1515
If the app is not running, the OS launches it and the link is queued until the renderer is ready. If the app is minimised, it is restored and focused before the link is handled.
1616

@@ -116,6 +116,20 @@ posthog-code://scout/error-tracking
116116
posthog-code://scout/error-tracking?finding=abc123
117117
```
118118

119+
### `posthog-code://approval/<requestId>`
120+
121+
Open the agent fleet approvals inbox focused on a specific tool-approval request.
122+
Emitted by the agent-runner on a gated tool call so non-PostHog-Code clients
123+
(Slack, MCP) can land on the approval; the request id alone resolves it.
124+
125+
| Segment / Parameter | Required | Description |
126+
|---|---|---|
127+
| `<requestId>` | Yes | Agent tool-approval request id (e.g. `ar_...`). |
128+
129+
```
130+
posthog-code://approval/ar_abc123
131+
```
132+
119133
## OAuth callback links
120134

121135
These are issued by external services and consumed by the app. You should not need to construct them yourself, but they are documented for completeness.
@@ -180,6 +194,7 @@ In development the same payload is delivered to `http://localhost:8238/mcp-oauth
180194
| `task` | [packages/core/src/links/task-link.ts](../packages/core/src/links/task-link.ts) |
181195
| `inbox` | [packages/core/src/links/inbox-link.ts](../packages/core/src/links/inbox-link.ts) |
182196
| `scout` | [packages/core/src/links/scout-link.ts](../packages/core/src/links/scout-link.ts) |
197+
| `approval` | [packages/core/src/links/approval-link.ts](../packages/core/src/links/approval-link.ts) |
183198
| `new`, `plan`, `issue` | [packages/core/src/links/new-task-link.ts](../packages/core/src/links/new-task-link.ts) |
184199
| `callback` | [packages/core/src/oauth/oauth.ts](../packages/core/src/oauth/oauth.ts) |
185200
| `integration` | [packages/core/src/integrations/github.ts](../packages/core/src/integrations/github.ts) |

docs/LOCAL-DEVELOPMENT.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,36 @@ Open devtools in the dev build and type:
9595

9696
Source: `apps/code/src/renderer/features/inbox/devtools/inboxDemoConsole.ts`.
9797

98+
## Feature flags in local dev
99+
100+
Feature flags are read through posthog-js, configured by the `VITE_POSTHOG_*`
101+
vars in `.env`. By default these point at PostHog's internal analytics instance,
102+
so flags you create locally never resolve in the dev build (and flag-gated UI —
103+
e.g. the agent-platform surface behind the `agent-platform` flag — stays hidden).
104+
105+
To point the flags/analytics client at your local PostHog so locally-synced
106+
flags take effect:
107+
108+
```bash
109+
# In your PostHog repo: create + enable all frontend-defined flags locally
110+
python manage.py sync_feature_flags
111+
112+
# In this repo: rewrite VITE_POSTHOG_* to your local instance, then restart dev
113+
node scripts/use-local-posthog.mjs
114+
pnpm dev
115+
```
116+
117+
`node scripts/use-local-posthog.mjs` auto-reads the project API key from a
118+
sibling `../posthog` checkout (or pass it:
119+
`node scripts/use-local-posthog.mjs phc_xxx`, or set `POSTHOG_DIR`). This
120+
only affects the analytics/flags client — the data API still uses the **Dev**
121+
region you pick at login.
122+
123+
> One-off override without changing `.env`: the dev build exposes the client on
124+
> `window.posthog`, so you can run
125+
> `posthog.featureFlags.override({ "agent-platform": true })` in the renderer
126+
> console (clear with `posthog.featureFlags.override(false)`).
127+
98128
## Troubleshooting
99129

100130
### "Invalid client_id" error during OAuth
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
type AgentAnalyticsRaw,
4+
buildAgentAnalyticsQueries,
5+
EMPTY_AGENT_ANALYTICS,
6+
type HogQLGrid,
7+
shapeAgentAnalytics,
8+
} from "./agent-analytics";
9+
10+
const grid = (results: unknown[][]): HogQLGrid => ({ results, columns: [] });
11+
12+
// A 14-day daily series where every day is identical, so prior(7) === recent(7)
13+
// → zero deltas. Columns: [day, cost, sessions, errors, generations].
14+
function flatDaily(): unknown[][] {
15+
return Array.from({ length: 14 }, (_, i) => [
16+
`2026-06-${String(i + 1).padStart(2, "0")}T00:00:00`,
17+
2, // cost
18+
5, // sessions
19+
1, // errors
20+
10, // generations
21+
]);
22+
}
23+
24+
describe("buildAgentAnalyticsQueries", () => {
25+
it("scopes to agent-platform origin only when no application id", () => {
26+
const q = buildAgentAnalyticsQueries();
27+
expect(q.kpi).toContain("$ai_origin = 'agent_platform_runner'");
28+
expect(q.kpi).not.toContain("$agent_application_id =");
29+
expect(q.kpi).toContain("event = '$ai_generation'");
30+
expect(q.toolErrors).toContain("event = '$ai_span'");
31+
});
32+
33+
it("narrows to a single application id when given", () => {
34+
const id = "11111111-2222-3333-4444-555566667777";
35+
const q = buildAgentAnalyticsQueries(id);
36+
expect(q.kpi).toContain(`properties.$agent_application_id = '${id}'`);
37+
expect(q.byModel).toContain(`properties.$agent_application_id = '${id}'`);
38+
});
39+
40+
it("rejects a non-uuid application id", () => {
41+
expect(() => buildAgentAnalyticsQueries("app-uuid-123")).toThrow(
42+
/must be a UUID/,
43+
);
44+
});
45+
});
46+
47+
describe("shapeAgentAnalytics", () => {
48+
it("returns an empty board for empty grids", () => {
49+
const out = shapeAgentAnalytics({});
50+
expect(out.empty).toBe(true);
51+
expect(out.kpis).toEqual(EMPTY_AGENT_ANALYTICS.kpis);
52+
expect(out.byAgent).toEqual([]);
53+
expect(out.deltas).toEqual({
54+
spend: null,
55+
sessions: null,
56+
failureRatePoints: null,
57+
});
58+
});
59+
60+
it("derives KPIs incl. failure rate from generations", () => {
61+
const raw: Partial<AgentAnalyticsRaw> = {
62+
// cost, sessions, errors, generations, p95
63+
kpi: grid([[12.5, 8, 3, 12, 4.2]]),
64+
};
65+
const out = shapeAgentAnalytics(raw);
66+
expect(out.kpis.spendUsd).toBe(12.5);
67+
expect(out.kpis.sessions).toBe(8);
68+
expect(out.kpis.failureRate).toBeCloseTo(3 / 12);
69+
expect(out.kpis.p95LatencyS).toBe(4.2);
70+
expect(out.empty).toBe(false);
71+
});
72+
73+
it("coerces numeric strings (HogQL returns decimals as strings)", () => {
74+
const out = shapeAgentAnalytics({
75+
kpi: grid([["1.50", "4", "0", "4", "2"]]),
76+
});
77+
expect(out.kpis.spendUsd).toBe(1.5);
78+
expect(out.kpis.sessions).toBe(4);
79+
expect(out.kpis.failureRate).toBe(0);
80+
});
81+
82+
it("builds a 14-day daily series with zero deltas for a flat trend", () => {
83+
const out = shapeAgentAnalytics({ daily: grid(flatDaily()) });
84+
expect(out.daily.labels).toHaveLength(14);
85+
expect(out.daily.spend).toHaveLength(14);
86+
expect(out.daily.failureRate.every((r) => r === 0.1)).toBe(true);
87+
// prior 7 === recent 7 → 0% change, and failure-rate delta is 0pp.
88+
expect(out.deltas.spend).toBe(0);
89+
expect(out.deltas.sessions).toBe(0);
90+
expect(out.deltas.failureRatePoints).toBe(0);
91+
});
92+
93+
it("computes a positive spend delta when recent exceeds prior", () => {
94+
// 7 days at cost 1, then 7 days at cost 3 → +200%.
95+
const days = Array.from({ length: 14 }, (_, i) => [
96+
`2026-06-${String(i + 1).padStart(2, "0")}T00:00:00`,
97+
i < 7 ? 1 : 3,
98+
1,
99+
0,
100+
1,
101+
]);
102+
const out = shapeAgentAnalytics({ daily: grid(days) });
103+
expect(out.deltas.spend).toBeCloseTo(200);
104+
});
105+
106+
it("maps per-agent rows and resolves names via the id→name map", () => {
107+
const raw: Partial<AgentAnalyticsRaw> = {
108+
// agent_id, sessions, generations, cost, tokens, errors, p95
109+
perAgent: grid([
110+
["11111111-2222-3333-4444-555566667777", 5, 10, 4, 2000, 2, 1.5],
111+
["aaaa", 1, 4, 0.5, 100, 0, 0.2],
112+
]),
113+
};
114+
const names = new Map([
115+
["11111111-2222-3333-4444-555566667777", "Support Bot"],
116+
]);
117+
const out = shapeAgentAnalytics(raw, names);
118+
expect(out.byAgent[0]).toMatchObject({
119+
name: "Support Bot",
120+
sessions: 5,
121+
spendUsd: 4,
122+
tokens: 2000,
123+
p95LatencyS: 1.5,
124+
});
125+
expect(out.byAgent[0].failureRate).toBeCloseTo(2 / 10);
126+
// Unknown id falls back to a short id.
127+
expect(out.byAgent[1].name).toBe("aaaa");
128+
});
129+
130+
it("maps model spend and tool error rates", () => {
131+
const out = shapeAgentAnalytics({
132+
byModel: grid([["claude-opus-4-8", 9.99, 42]]),
133+
toolErrors: grid([
134+
["search", 20, 4],
135+
["fetch", 5, 0],
136+
]),
137+
});
138+
expect(out.byModel[0]).toEqual({
139+
model: "claude-opus-4-8",
140+
spendUsd: 9.99,
141+
calls: 42,
142+
});
143+
expect(out.toolErrors[0].errorRate).toBeCloseTo(4 / 20);
144+
expect(out.toolErrors[1].errorRate).toBe(0);
145+
});
146+
147+
it("ignores non-array rows defensively", () => {
148+
const out = shapeAgentAnalytics({
149+
kpi: grid([[1, 1, 0, 1, 1]]),
150+
perAgent: {
151+
results: [null, "oops"] as unknown as unknown[][],
152+
columns: [],
153+
},
154+
});
155+
expect(out.byAgent).toEqual([]);
156+
expect(out.empty).toBe(false);
157+
});
158+
});

0 commit comments

Comments
 (0)