Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
101 commits
Select commit Hold shift + click to select a range
26a05c5
docs(specs): private connections & typed slots design
tlgimenes May 27, 2026
4d74f93
docs(plans): phase 1 plan — schema migration for private connections
tlgimenes May 27, 2026
ed65cc5
feat(connections): schema for user-private connections + agent slots
tlgimenes May 27, 2026
c1124e4
docs(plans): phase 2 plan — slot resolver + runtime wiring
tlgimenes May 28, 2026
3b2c87e
feat(connections): surface slot rows on VirtualMCPEntity
tlgimenes May 28, 2026
c6edc68
fix(connections): preserve untouched field in VirtualMCPStorage.update
tlgimenes May 28, 2026
ee3d4cb
feat(connections): slot resolver module
tlgimenes May 28, 2026
3466435
feat(connections): wire slot resolver into virtual MCP client
tlgimenes May 28, 2026
95b992c
docs(plans): phase 3+4 plan — connection access surface + github pick…
tlgimenes May 28, 2026
4353417
feat(connections): surface access field through tools + per-user list…
tlgimenes May 28, 2026
d20526f
feat(connections): CONNECTION_RESOLVE_FOR_USER app-only tool
tlgimenes May 28, 2026
7b3b4a7
fix(github-import): use CONNECTION_RESOLVE_FOR_USER for picker
tlgimenes May 28, 2026
40f4dbd
chore: remove private-connections planning docs
tlgimenes May 28, 2026
eb5c331
fix(connections): address PR review feedback
tlgimenes May 28, 2026
491390c
test(connections): migrate slot/access tests off removed PGlite harness
tlgimenes May 28, 2026
856c7cc
feat(agents): always show "Import from GitHub", remove experimental flag
tlgimenes May 28, 2026
bdcbbb1
docs(connections): design for Personal/Shared connections split
tlgimenes May 28, 2026
61f03ba
docs(connections): implementation plan for Personal/Shared split
tlgimenes May 28, 2026
17dd311
feat(connections): add Personal/Shared access-tab helpers
tlgimenes May 28, 2026
2a7d00a
feat(connections): split settings page into All/Shared/Personal tabs
tlgimenes May 28, 2026
18b9930
fix(connections): hide install catalog on Shared/Personal tabs while …
tlgimenes May 28, 2026
9ca2679
feat(connections): split AddConnectionDialog into All/Shared/Personal…
tlgimenes May 28, 2026
f90c35c
test(connections): e2e for Personal/Shared access tabs
tlgimenes May 28, 2026
a36c601
test(connections): scope e2e card assertions to heading role
tlgimenes May 28, 2026
628b18e
fix(connections): accept shared/personal in connections route ?tab= p…
tlgimenes May 28, 2026
03a27dd
chore(connections): remove design/plan docs
tlgimenes May 28, 2026
4efbe7a
fix(connections): guard dialog tabs against injected well-known conne…
tlgimenes May 28, 2026
17acad6
fix(github-import): resolve connection by app_id, not slug
tlgimenes May 29, 2026
ead2b39
feat(connections): add deriveAppId helper for synthetic app_ids
tlgimenes May 29, 2026
29947f6
feat(connections): derive app_id on connection create
tlgimenes May 29, 2026
a213577
feat(connections): re-derive synthetic app_id on connection update
tlgimenes May 29, 2026
d3ebaef
test(connections): cover duplicate private connection friendly error
tlgimenes May 29, 2026
ae073f6
feat(agents): create virtual MCPs as org-scoped
tlgimenes May 29, 2026
4553f6f
feat(agents): reject private connections as concrete children
tlgimenes May 29, 2026
4c685ae
feat(migrations): org-scope connections and backfill synthetic app_ids
tlgimenes May 29, 2026
927102d
test(connections): use unique URLs so per-service app_id uniqueness h…
tlgimenes May 29, 2026
dc5fd64
refactor(connections): re-derive app_id on type change; test cross-us…
tlgimenes May 29, 2026
e9567e9
docs(connections): spec + plan for org-scoped agents and typed connec…
tlgimenes May 29, 2026
4fe5f30
fix(connections): seed well-known org connections as org-scoped
tlgimenes May 29, 2026
ba6ed4d
chore(connections): remove design/plan docs
tlgimenes May 29, 2026
744cef6
fix(github-import): attach GitHub connection as a slot, not a concret…
tlgimenes May 29, 2026
7d57769
fix(agents): route private connections to slots in bulk add-to-agent
tlgimenes May 29, 2026
b5eba25
feat(ui): add special (violet) Badge variant
tlgimenes May 29, 2026
54d67bd
feat(agents): add slotDisplayState helper for slot rendering
tlgimenes May 29, 2026
cf79305
feat(agents): include slots in the agent form schema
tlgimenes May 29, 2026
cc965f3
feat(agents): add SlotItem component for rendering typed slots
tlgimenes May 29, 2026
79c340b
feat(agents): render typed slots in agent settings connections
tlgimenes May 29, 2026
b4f0559
refactor(agents): fix slots schema comment; add special badge dark fo…
tlgimenes May 29, 2026
697f394
feat(agents): label slot connections 'Personal' to match connections tab
tlgimenes May 29, 2026
e450232
docs(agents): spec + plan for rendering slots in agent settings
tlgimenes May 29, 2026
5a8af49
chore(agents): remove slot-rendering design/plan docs
tlgimenes May 29, 2026
76577f6
feat(agents): show Personal chip or Connect button on slots, not both
tlgimenes May 29, 2026
52d9bab
feat(agents): add unresolvedSlots helper
tlgimenes May 29, 2026
ff72fb6
feat(agents): add useUnresolvedSlots batched resolution hook
tlgimenes May 29, 2026
3a6ac6a
feat(agents): add ConnectAgentGate panel
tlgimenes May 29, 2026
3fe80a9
feat(agents): gate chat on unresolved agent connections
tlgimenes May 29, 2026
3ce6186
refactor(agents): KEYS factory + refetch-on-focus for slot gate clearing
tlgimenes May 29, 2026
48acb9d
docs(agents): spec + plan for connect-to-use-agent gate
tlgimenes May 29, 2026
e351f03
remove .md files
tlgimenes May 29, 2026
e03d0eb
feat(agents): gate the whole agent view on unresolved connections
tlgimenes May 29, 2026
ceabfec
refactor(agents): drop chat-panel connect gate in favor of the view-l…
tlgimenes May 29, 2026
a7c0922
refactor(agents): remove Connect button from settings slot rows
tlgimenes May 29, 2026
befdef5
docs(agents): update SlotItem comment after dropping Connect button
tlgimenes May 29, 2026
bc0d981
style(agents): match connect gate wrapper to sibling inset-card states
tlgimenes May 29, 2026
b4a4357
docs(agents): spec + plan for hoisting the connect gate
tlgimenes May 29, 2026
562646a
fix(agents): suspend on slot resolution so panels don't flash before …
tlgimenes May 29, 2026
6ee2ebf
feat(chat): add slotAppDisplay registry-metadata mapper for the conne…
tlgimenes May 29, 2026
bf0559c
feat(chat): add useSlotAppDisplays suspending registry-metadata hook
tlgimenes May 29, 2026
2f08172
fix(chat): log registry lookup failures in useSlotAppDisplays
tlgimenes May 29, 2026
a37b462
feat(connections): extract shared connectApp inline-connect pipeline
tlgimenes May 29, 2026
3af0ccb
style(connections): clarify OAuth guard and empty-data update in conn…
tlgimenes May 29, 2026
fae7022
feat(chat): add useConnectApp hook for inline connect-gate connect
tlgimenes May 29, 2026
bfd6e95
refactor(chat): invalidate slot queries via KEYS prefix helpers
tlgimenes May 29, 2026
03574b0
feat(chat): add ConnectSlotRow with inline connect / deep-link fallback
tlgimenes May 29, 2026
f7579ef
feat(chat): render registry icon/name + inline connect in the connect…
tlgimenes May 29, 2026
5368f5b
refactor(connections): route dialog handleConnectAndAdd through share…
tlgimenes May 29, 2026
104e138
fix(chat): key slot display metadata per app_id to avoid multi-slot g…
tlgimenes May 29, 2026
d3e437b
feat(virtual-mcp): replace per-connection settings dialog with enable…
tlgimenes May 29, 2026
88245d9
refactor(connections): dedupe OAuth token persistence into a shared h…
tlgimenes May 29, 2026
a1e187b
refactor(connections): extract OAuth identity helpers and decorate ti…
tlgimenes May 30, 2026
c954a64
fix(proxy): pass viewer arg to connections.findById in tool-call erro…
tlgimenes May 30, 2026
a2edd77
docs(slots): spec for just-in-time connection gate for agents & subag…
tlgimenes May 31, 2026
9dabeae
docs(slots): implementation plan for just-in-time connection gate
tlgimenes May 31, 2026
29b5b78
refactor(slots): SlotUnresolvedError carries all app_ids + agent iden…
tlgimenes May 31, 2026
b39e1b8
feat(slots): collect all unresolved slots and throw once
tlgimenes May 31, 2026
d70e3a4
test(slots): add multi-slot integration test for collect-all behavior
tlgimenes May 31, 2026
f206ee9
feat(chat): register data-connect-required part type
tlgimenes May 31, 2026
cc3284a
feat(subtask): emit connect-required card when a subagent slot is unr…
tlgimenes May 31, 2026
b920cee
test(subtask): add unit test for SlotUnresolvedError catch path in ex…
tlgimenes May 31, 2026
605a2cc
feat(harness): emit connect-required card when the parent agent's slo…
tlgimenes May 31, 2026
d15e98e
feat(chat): keep data-connect-required parts in render order
tlgimenes May 31, 2026
caf115a
feat(chat): add ConnectCard rendered from data-connect-required
tlgimenes May 31, 2026
eede50b
feat(chat): render ConnectCard for data-connect-required parts
tlgimenes May 31, 2026
7ea042c
refactor(chat): remove upfront connect gate in favor of inline connec…
tlgimenes May 31, 2026
e073642
fix(web): remove dead unresolvedSlots key and update stale JSDoc
tlgimenes May 31, 2026
d3ec5dd
fix(decopilot): emit well-formed finish envelope for parent connect-gate
tlgimenes May 31, 2026
39c624e
fix(chat): prevent connect-card crash in monitoring route and remove …
tlgimenes May 31, 2026
7fbae45
test(e2e): connect card for unresolved parent agent slot
tlgimenes Jun 1, 2026
c45e22e
refactor(slots): unify slot-resolver imports on @/ alias for stable i…
tlgimenes Jun 1, 2026
c5fb59b
Merge origin/main into tlgimenes/list-github-tools
tlgimenes Jun 1, 2026
649c4f8
fix(e2e): use cluster sandbox kind in connect-card test (local-docker…
tlgimenes Jun 1, 2026
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
21 changes: 21 additions & 0 deletions apps/mesh/e2e/pages/settings-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,25 @@ export class SettingsConnectionsPage {
.last()
.click();
}

/** Click an access tab by its label (the button text also carries a count badge). */
async clickTab(label: "All" | "Shared" | "Personal"): Promise<void> {
await this.page
.getByRole("button", { name: new RegExp(`^${label}\\b`) })
.click();
}

/** Assert a connection card with the given title is visible. */
async expectConnectionVisible(title: string): Promise<void> {
await expect(
this.page.getByRole("heading", { name: title, exact: true }),
).toBeVisible();
}

/** Assert no connection card with the given title is present. */
async expectConnectionHidden(title: string): Promise<void> {
await expect(
this.page.getByRole("heading", { name: title, exact: true }),
).toHaveCount(0);
}
}
350 changes: 350 additions & 0 deletions apps/mesh/e2e/tests/connect-card.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,350 @@
/**
* E2E: just-in-time connection gate — the PARENT-agent connect-card path.
*
* When a Virtual MCP agent declares a typed slot (an `app_id` requirement)
* that the invoking user has no matching connection for, the run can't
* assemble its tools. The decopilot harness throws `SlotUnresolvedError`
* inside `assembleDecopilotTools` (apps/mesh/src/harnesses/decopilot/index.ts)
* — deterministically, BEFORE any LLM call — and emits a well-formed
* UI-message-stream envelope: `start`, a short terminal text part, a
* `data-connect-required` chunk (rendered as the ConnectCard in chat), and a
* `finish` chunk. Server-side, `resolveThreadStatus`
* (apps/mesh/src/api/routes/decopilot/status.ts) maps a response carrying a
* `data-connect-required` part to thread status `requires_action` (NOT
* `failed`).
*
* Why this is deterministic under a REAL LLM e2e: the parent gate fires during
* tool assembly, before the model runs, so the card does not depend on the
* model producing anything. We use a unique synthetic slot app_id
* (`e2e-missing-<ts>`) for which no connection exists and which cannot fall
* back to an org-shared one, so `resolveSlot` returns null every time.
*
* What this asserts (API/stream level — no browser/LLM dependency):
* 1. The run's SSE stream contains a `data-connect-required` part whose
* `data.appIds` includes the synthetic slot app_id and whose
* `data.agentTitle` equals the agent title.
* 2. The stream also contains a `finish` chunk — the regression guard for
* the critical bug where the parent path emitted no `finish` and the
* client hung in "streaming" forever.
* 3. After the run, the thread status is `requires_action` (NOT `failed`) —
* the regression guard for the false-failure bug.
*
* The subagent / parallel-subtask scenarios are intentionally NOT exercised
* here: they require the real LLM to choose the `subtask` tool, which is
* non-deterministic. That boundary already has unit coverage in
* apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.test.ts.
*/

import type { APIRequestContext, Page } from "@playwright/test";
import { expect, test } from "../fixtures/test";
import { callSelfMcpTool } from "../fixtures/mcp-tools";

const BASE_URL = `http://localhost:${process.env.PORT ?? "3000"}`;

interface VirtualMcpItem {
id: string;
title: string;
slots: Array<{ slot_app_id: string }>;
}

/** Resolve the authed user's org id (the slug→id the fixture doesn't carry). */
async function resolveOrgId(
api: APIRequestContext,
orgSlug: string,
): Promise<string> {
const res = await api.get("/api/auth/organization/list");
if (!res.ok()) {
throw new Error(`organization/list → HTTP ${res.status()}`);
}
const body = (await res.json()) as
| Array<{ id: string; slug: string }>
| { data?: Array<{ id: string; slug: string }> };
const orgs = Array.isArray(body) ? body : (body.data ?? []);
const org = orgs.find((o) => o.slug === orgSlug);
if (!org?.id) {
throw new Error(`No org id found for slug ${orgSlug}`);
}
return org.id;
}

/**
* Make decopilot's per-request model resolution deterministic and
* network-free. `resolveTier(ctx, "smart")` takes a fast path when the org has
* an explicit `simple_mode.tiers.smart` slot pointing at an existing key — it
* returns that credentialId/modelId without ever calling the provider's
* `listModels` (which would hit the network). A dummy key is fine: the parent
* connect-gate throws during tool assembly, long before any real model call.
*
* The credential's providerId ("openrouter") maps to the "decopilot" harness
* (resolveHarnessId), so the run stays on the decopilot path where the gate
* lives — we deliberately do NOT pass harnessId: "claude-code" (a different
* harness that bypasses the gate).
*/
async function seedSmartTier(
api: APIRequestContext,
orgSlug: string,
orgId: string,
): Promise<void> {
const key = await callSelfMcpTool<{ id: string }>(
api,
orgSlug,
"AI_PROVIDER_KEY_CREATE",
{
providerId: "openrouter",
label: `connect-card-e2e-${Date.now()}`,
apiKey: "sk-or-e2e-dummy-key",
},
);
await callSelfMcpTool(api, orgSlug, "ORGANIZATION_SETTINGS_UPDATE", {
organizationId: orgId,
simple_mode: {
tiers: {
fast: null,
smart: {
keyId: key.id,
modelId: "anthropic/claude-sonnet-4.6",
title: "Smart (e2e)",
},
thinking: null,
image: null,
web_research: null,
},
},
});
}

/** Create an agent with one unresolved typed slot; assert the slot persisted. */
async function createAgentWithUnresolvedSlot(
api: APIRequestContext,
orgSlug: string,
slotAppId: string,
): Promise<{ agentId: string; agentTitle: string }> {
const agentTitle = `Connect Card E2E Agent ${Date.now()}`;
const created = await callSelfMcpTool<{ item: VirtualMcpItem }>(
api,
orgSlug,
"COLLECTION_VIRTUAL_MCP_CREATE",
{
data: {
title: agentTitle,
connections: [],
status: "active",
pinned: false,
slots: [{ slot_app_id: slotAppId }],
},
},
);
const agentId = created.item.id;

// Read the agent back so the test fails loudly if the slot-create shape was
// wrong (e.g. a future schema change drops slots silently).
const fetched = await callSelfMcpTool<{ item: VirtualMcpItem }>(
api,
orgSlug,
"COLLECTION_VIRTUAL_MCP_GET",
{ id: agentId },
);
expect(fetched.item.slots.map((s) => s.slot_app_id)).toContain(slotAppId);

return { agentId, agentTitle: created.item.title };
}

/** Pull the cookie header off the Playwright context for raw streaming fetch. */
async function cookieHeader(page: Page): Promise<string> {
const cookies = await page.context().cookies(BASE_URL);
return cookies.map((c) => `${c.name}=${c.value}`).join("; ");
}

interface StreamCapture {
raw: string;
parts: Array<{ type: string; data?: Record<string, unknown> }>;
}

/**
* Tail the per-thread `/stream` SSE endpoint until a `finish` chunk arrives
* (or the timeout fires), collecting every parsed AI-SDK chunk.
*
* The endpoint uses `deliverPolicy: "new"` for an idle thread and purges the
* JetStream buffer on terminal events, so we MUST be subscribed before the run
* pumps its chunks. Callers start this (without awaiting) BEFORE POSTing the
* message and await it after.
*
* Wire format is the AI SDK's JSON-to-SSE transform: each chunk is a
* `data: {json}\n\n` event; `: keepalive\n\n` comment lines are interleaved by
* the server's keepalive wrapper and ignored here.
*/
async function tailStreamUntilFinish(
page: Page,
orgSlug: string,
threadId: string,
timeoutMs = 20_000,
): Promise<StreamCapture> {
const cookie = await cookieHeader(page);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);

const parts: StreamCapture["parts"] = [];
let raw = "";

try {
const res = await fetch(
`${BASE_URL}/api/${orgSlug}/decopilot/threads/${threadId}/stream`,
{
headers: { cookie, accept: "text/event-stream" },
signal: controller.signal,
},
);
// 204 = no JetStream tail available (NATS not wired). Surface it rather
// than reading a null body — the caller decides how to fail.
if (res.status === 204) {
throw new Error("/stream → HTTP 204 (no JetStream tail available)");
}
if (!res.ok || !res.body) {
throw new Error(`/stream → HTTP ${res.status} (body=${!!res.body})`);
}

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let sawFinish = false;

while (!sawFinish) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
raw += text;
buffer += text;

// SSE events are separated by a blank line (\n\n).
let sep: number;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
for (const line of event.split("\n")) {
if (!line.startsWith("data:")) continue; // skip ": keepalive" comments
const payload = line.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
const chunk = JSON.parse(payload) as {
type?: string;
data?: Record<string, unknown>;
};
if (typeof chunk.type === "string") {
parts.push({ type: chunk.type, data: chunk.data });
if (chunk.type === "finish") sawFinish = true;
}
} catch {
// Partial / non-JSON line — ignore; the next read completes it.
}
}
}
}
reader.releaseLock();
} catch (err) {
// The timeout aborts `reader.read()` with an AbortError. That's an
// expected end-of-wait, not a test failure here — return whatever we
// captured and let the assertions report the precise gap (with `raw`).
if (!(err instanceof Error && err.name === "AbortError")) {
throw err;
}
} finally {
clearTimeout(timer);
controller.abort();
}

return { raw, parts };
}

test("parent agent with an unresolved slot emits a connect-required card + finish, and the thread requires_action (never fails)", async ({
authedPage,
}) => {
const { page, orgSlug } = authedPage;
const api = page.context().request;

const orgId = await resolveOrgId(api, orgSlug);
await seedSmartTier(api, orgSlug, orgId);

// Synthetic, never-connected app_id → resolveSlot returns null every time.
const slotAppId = `e2e-missing-${Date.now()}`;
const { agentId, agentTitle } = await createAgentWithUnresolvedSlot(
api,
orgSlug,
slotAppId,
);

const thread = await callSelfMcpTool<{ item: { id: string } }>(
api,
orgSlug,
"COLLECTION_THREADS_CREATE",
{ data: { virtual_mcp_id: agentId, title: "Connect Card E2E Thread" } },
);
const threadId = thread.item.id;

// Subscribe to the stream BEFORE posting so deliverPolicy:"new" catches the
// run's chunks live (the buffer is purged on terminal events).
const streamPromise = tailStreamUntilFinish(page, orgSlug, threadId);

// POST the user message. The decopilot run is enqueued and returns 202; the
// gate fires asynchronously during dispatch and pumps the card chunks to the
// per-thread JetStream subject that the tail above is reading.
const post = await api.post(
`/api/${orgSlug}/decopilot/threads/${threadId}/messages`,
{
data: {
messages: [{ role: "user", parts: [{ type: "text", text: "hi" }] }],
agent: { id: agentId },
branch: "ephemeral",
// Pin the cluster sandbox so dispatch-target resolution stays
// in-cluster (loopback) and never needs an online user-desktop link
// daemon (which this env / CI has none of). Without pinning, the
// default can resolve to "user-desktop" and 409. "cluster" and
// "user-desktop" are the only valid kinds (local-docker was removed in
// the local-docker-sandbox drop). The gate under test lives in the
// decopilot harness; we intentionally do NOT set harnessId (it derives
// "decopilot" from the openrouter credential), so the parent
// connect-gate path still runs.
sandboxProviderKind: "cluster",
},
headers: { "content-type": "application/json" },
},
);
if (post.status() !== 202) {
// Surface the server's error body to make a tier/config regression obvious
// instead of failing later on an empty stream.
const body = await post.text().catch(() => "<unreadable>");
throw new Error(
`POST /messages expected 202, got ${post.status()}: ${body}`,
);
}

const capture = await streamPromise;

// 1. The connect-required card part carries the missing app id + agent title.
const connect = capture.parts.find((p) => p.type === "data-connect-required");
expect(
connect,
`expected a data-connect-required part; got types: ${capture.parts
.map((p) => p.type)
.join(", ")} | raw: ${capture.raw.slice(0, 2000)}`,
).toBeTruthy();
expect(connect?.data?.appIds).toContain(slotAppId);
expect(connect?.data?.agentTitle).toBe(agentTitle);

// 2. The stream emitted a `finish` chunk (no-finish bug regression guard).
expect(capture.parts.some((p) => p.type === "finish")).toBe(true);

// 3. The thread resolves to requires_action — NOT failed (false-failure
// regression guard). The reactor persists this on the FINISH event, so
// poll until the terminal status lands.
await expect
.poll(
async () => {
const got = await callSelfMcpTool<{
item: { status: string } | null;
}>(api, orgSlug, "COLLECTION_THREADS_GET", { id: threadId });
return got.item?.status ?? null;
},
{ timeout: 15_000, intervals: [250, 500, 1000] },
)
.toBe("requires_action");
});
Loading
Loading