mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 03:08:10 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents use Connections (the Apps catalog) to act in services like
Notion, GitHub, Google Workspace and Railway
> - Each connector asked the user to answer setup questions before it
went to the provider. Most of the questions already had the correct
answer selected
> - ROADMAP.md lists "simpler setup" for Apps and Connections as ongoing
work. This change continues that work
> - This pull request removes the questions that Paperclip can answer
itself. It states the defaults in one line and moves the choices behind
"Change" and onto the Permissions tab
> - The benefit is that most connectors take one click in Paperclip and
then the provider's own consent screen
## Linked Issues or Issue Description
No public issue exists. This is the description, from the enhancement
template.
**What existing behavior does this improve?**
The setup flow for tool connectors in the Apps catalog.
**Subsystem affected**
Apps and Connections: `ui/src/features/connections`,
`ui/src/pages/apps`, the `packages/shared` app definitions, and the
OAuth routes in `server/src/routes/tool-access.ts`.
**Current behavior**
Every connector opened with an Access step. The step asked who can use
the connection and which agents get it, and both answers were already
selected. 18 connectors also asked "How do you want to connect?" when
Paperclip could rank the methods. The Google apps and Postman also asked
"What should Paperclip be able to do?" before sign-in. The four gateway
connectors (Zapier, Arcade, Composio, Executor) used a separate two-step
wizard. Asana was pinned to a customer-owned OAuth app, so the user had
to register an app in Asana's developer console. The "Set all" control
on the Permissions tab changed only one action. After the user approved
access, Railway's consent page showed "you can close this window" and
did not return to Paperclip.
**Proposed behavior**
One screen per connector, with one primary button. The screen states the
defaults in one sentence, for example "Connects for everyone in your
organization, available to all agents". A "Change" link opens one
Advanced panel. When the provider's metadata allows dynamic client
registration, Paperclip registers a client itself. Connecting lands on
the Permissions tab. On that tab, "Set all" changes every action in the
group.
**Reason and benefit**
The user makes fewer decisions before the connection exists. Most
choices are easier to make after the connection, on the Permissions tab,
where a change has an immediate effect.
**Breaking changes**
None. No schema or API change. Existing connections keep their settings.
## What Changed
- **No Access step.** `ConnectionSetupFlow` no longer has the Access
step. The flow shows the resolved default above the primary button and
on the completion screen. The access controls moved into one Advanced
panel. The panel opens automatically only when a setting in it is
required.
- **A default method for every app.** The flow always picks the ranked
default method. Alternate methods are in the Advanced panel. The Google
and Postman capability choice is not asked before sign-in. The
write-capable method is the default.
- **Gateway connectors.** `RemoteMcpProductionSetup` (Zapier, Arcade,
Composio, Executor) no longer has its own Access step. Its commit path
and the main commit path use one helper, `askFirstCatalogEntryIdsFor`,
for server-suggested defaults.
- **Dynamic registration from live metadata.**
`canRegisterOAuthClientDynamically` now allows registration when the
provider advertises a registration endpoint, even if the catalog entry
lists only customer-owned clients. The Asana and Linear definitions and
catalog text match live probes. Asana issues clients for loopback
callbacks only, so a hosted deployment still needs an Asana app.
- **Connection setup states.** New
`packages/shared/src/connection-setup-state.ts` sorts each method into
`instant`, `authorize`, `paste` or `register`. The gallery card verb
("Connect" or "Add key") comes from this resolver and the instance's
ownership availability.
- **Generic MCP.** The generic path no longer asks "Does it need a key?"
first. A credential challenge from the server shows the key field.
- **Permissions tab.** Each action row shows its risk level. Each group
has a "Set all" control. The control sends one change for the whole
group. Before, each row's save started from the same render, so the
saves overwrote each other. The Zapier/Arcade/Composio/Executor setup
screen had the same defect.
- **OAuth callback interstitial.** A cross-site browser navigation to
`/api/tools/oauth/callback` gets a small same-origin "Finishing your
connection…" page. That page repeats the request, and the repeat does
the code exchange. Railway's consent page replaces itself after about
two seconds, and the code exchange plus tool discovery takes longer than
that. The interstitial uses only a meta refresh, because the OAuth code
is single-use. Requests without `Sec-Fetch-Site: cross-site` take the
old path.
- **Linear registers through its MCP server.** Linear pins the console
endpoints at `linear.app`. Pinned endpoints now replace discovery only
when the method cannot register, or when the connection has an
operator-entered client. So a Linear connection now finds the
registration endpoint at `mcp.linear.app`.
- **Own-OAuth-app recovery stays on the one-click screen.** When the
method also accepts a customer-owned client, the client fields are in
the Advanced panel. The panel opens after a failed sign-in. "Try again"
resumes the draft with the operator's client.
- **E2E specs** follow the one-screen flow. The Access-step clicks are
removed, the specs open **Change** before they pick agents, and they
expect GitHub's **Add key** verb.
- **Default permissions do not change.** New connections still allow
every action. The user can set actions to Ask first or Off on the
Permissions tab.
## Verification
- `cd ui && npx vitest run src/pages/apps src/features/connections
--no-file-parallelism`
- `cd packages/shared && npx vitest run src/app-definitions.test.ts
src/connection-setup-state.test.ts`
- `cd server && npx vitest run src/__tests__/tool-access-service.test.ts
src/__tests__/remote-mcp-connectors.test.ts`
- `pnpm check:token-gates`
- New tests:
- `PermissionsPanel.group.test.tsx` checks that "Set all" sends one
change for the whole group. It fails on the old code.
- `action-permissions.test.ts` checks the group update.
- `connection-setup-state.test.ts` checks the four setup states.
- A server test checks that a cross-site callback gets the interstitial
and does not use the OAuth state, and that the same-origin repeat
completes the connection.
- Manual check on a hosted staging deployment. GitHub, Google Drive,
Composio, Notion, PostHog and Railway each connected from one screen and
returned to the Permissions tab. On Railway, "Set all" changed all 65
write actions, and the change remained after a reload.
- Visual changes: snapshot baselines are intentionally not updated. See
the `doc/design/DECISION-SHEET.md` entry "Per-change snapshot
verification demoted to dormant (Jul 13 2026)".
## Risks
- **Fewer confirmation clicks.** Organization-wide access is the
default, and the user does not confirm it on a separate step. This was
already the preselected answer. The flow shows the default before the
user clicks and again after the connection.
- **Google write scope.** Google apps now request the write-capable
scope by default. A narrower scope needs a new sign-in.
- **Dynamic registration from live metadata.** A provider can advertise
registration and then reject a redirect URI. Asana rejects hosted
callbacks, for example. In that case registration fails, and the
customer-owned client path remains available for recovery.
- **Callback interstitial.** The OAuth callback adds one same-origin
step for cross-site browser navigations. Browsers without `Sec-Fetch-*`
headers use the old direct path.
- Chat and bot connectors (Discord, Telegram, Microsoft Teams, iMessage)
do not change.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through
Claude Code with tool use (shell, file editing, browser automation) and
extended thinking. It wrote the code, the tests and this description. A
human product owner directed the work and tested it by hand.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: scotttong <squadbot000@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
621 lines
25 KiB
TypeScript
621 lines
25 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
|
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
|
import { createServer } from "node:http";
|
|
import { resolve } from "node:path";
|
|
import { writeFile } from "node:fs/promises";
|
|
import { listenOnFetchAllowedPort } from "../fetch-allowed-port";
|
|
|
|
// Each attempt starts the source CLI's test-drive without --data-dir. The model
|
|
// and MCP provider are deterministic fixtures; authorization/cards/wakes are real.
|
|
for (const journey of [
|
|
"safe",
|
|
"uncertain",
|
|
"safe_restart",
|
|
"ceo_lineage",
|
|
"legacy_unknown",
|
|
] as const)
|
|
test(`fresh execution recovery and current-request journey: ${journey}`, async ({
|
|
page,
|
|
}, info) => {
|
|
const root = resolve(import.meta.dirname, "../../..");
|
|
let processHandle: ChildProcess | undefined;
|
|
let logs = "";
|
|
let diagnosticState = async () => ({});
|
|
async function stopDrive() {
|
|
if (processHandle?.pid) {
|
|
const child = processHandle;
|
|
const stopped = new Promise<void>((done) =>
|
|
child.once("exit", () => done()),
|
|
);
|
|
try {
|
|
process.kill(-child.pid!, "SIGTERM");
|
|
} catch {}
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
await Promise.race([
|
|
stopped,
|
|
new Promise<void>((done) => {
|
|
timeout = setTimeout(() => {
|
|
try {
|
|
process.kill(-child.pid!, "SIGKILL");
|
|
} catch {}
|
|
done();
|
|
}, 15_000);
|
|
}),
|
|
]);
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
}
|
|
|
|
const calls: string[] = [];
|
|
let gmailReadCount = 0;
|
|
const fixture = createServer(async (req, res) => {
|
|
const chunks: Buffer[] = [];
|
|
for await (const part of req) chunks.push(Buffer.from(part));
|
|
const message = JSON.parse(Buffer.concat(chunks).toString() || "{}");
|
|
const gmail = req.url === "/gmail";
|
|
calls.push(`${gmail ? "gmail" : "notion"}:${message.method}`);
|
|
if (message.id === undefined) {
|
|
res.writeHead(202);
|
|
res.end();
|
|
return;
|
|
}
|
|
const result =
|
|
message.method === "initialize"
|
|
? {
|
|
protocolVersion: "2024-11-05",
|
|
capabilities: { tools: {} },
|
|
serverInfo: {
|
|
name: gmail ? "gmail-fixture" : "heliotrope",
|
|
version: "1",
|
|
},
|
|
}
|
|
: message.method === "tools/list"
|
|
? {
|
|
tools: [
|
|
{
|
|
name: gmail ? "gmail_read" : "archive_read",
|
|
description: gmail
|
|
? "Read gmail-fixture launch email decisions"
|
|
: "Read heliotrope launch decisions",
|
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {},
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
],
|
|
}
|
|
: message.method === "tools/call"
|
|
? {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: gmail
|
|
? ++gmailReadCount === 1 && journey !== "ceo_lineage"
|
|
? journey !== "uncertain"
|
|
? "RECOVERY-INJECT-SAFE"
|
|
: "RECOVERY-INJECT-UNKNOWN-WRITE"
|
|
: "GMAIL-73: The launch email confirms Friday approval. Source: https://example.invalid/mail/gmail-73"
|
|
: "HELIOTROPE-42: Launch in two stages; support handoff belongs to Mira. Source: https://example.invalid/launch/heliotrope-42",
|
|
},
|
|
],
|
|
}
|
|
: {};
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }));
|
|
});
|
|
try {
|
|
const port = await listenOnFetchAllowedPort(fixture);
|
|
const env = {
|
|
...process.env,
|
|
PAPERCLIP_RECOVERY_CEO_LINEAGE: journey === "ceo_lineage" ? "1" : "0",
|
|
IN_FEED_FIXTURE_KEY: "not-a-real-model-key",
|
|
NODE_ENV: "test",
|
|
PATH: `${root}/tests/e2e/fixtures/recovery-bin:${process.env.PATH}`,
|
|
};
|
|
delete env.DATABASE_URL;
|
|
delete env.DATABASE_MIGRATION_URL;
|
|
processHandle = spawn(
|
|
process.execPath,
|
|
[
|
|
"cli/node_modules/tsx/dist/cli.mjs",
|
|
"cli/src/index.ts",
|
|
"test-drive",
|
|
"--harness",
|
|
"codex",
|
|
"--api-key-env",
|
|
"IN_FEED_FIXTURE_KEY",
|
|
"--company-name",
|
|
"Execution recovery fixture",
|
|
"--no-browser",
|
|
],
|
|
{ cwd: root, env, detached: true, stdio: ["ignore", "pipe", "pipe"] },
|
|
);
|
|
processHandle.stdout!.on("data", (chunk) => {
|
|
logs += chunk.toString();
|
|
});
|
|
processHandle.stderr!.on("data", (chunk) => {
|
|
logs += chunk.toString();
|
|
});
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
logs.match(
|
|
/Paperclip is ready at (http:\/\/127\.0\.0\.1:\d+)/,
|
|
)?.[1],
|
|
{ timeout: 100_000 },
|
|
)
|
|
.toBeTruthy();
|
|
let base = logs.match(
|
|
/Paperclip is ready at (http:\/\/127\.0\.0\.1:\d+)/,
|
|
)![1]!;
|
|
const api = async (path: string, method = "GET", data?: unknown) => {
|
|
const response = await page.request.fetch(`${base}/api${path}`, {
|
|
method,
|
|
data,
|
|
});
|
|
expect(response.ok(), await response.text()).toBeTruthy();
|
|
return response.json();
|
|
};
|
|
const isSettledRun = (run: { status: string; errorCode?: string }) =>
|
|
run.status === "succeeded" ||
|
|
(run.status === "cancelled" &&
|
|
run.errorCode === "issue_not_in_progress");
|
|
const health = await api("/health");
|
|
expect(health).toMatchObject({
|
|
status: "ok",
|
|
deploymentMode: "local_trusted",
|
|
bootstrapStatus: "ready",
|
|
serverInfo: {
|
|
git: {
|
|
branchName: execFileSync("git", ["branch", "--show-current"], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
}).trim(),
|
|
},
|
|
},
|
|
});
|
|
const [company] = await api("/companies");
|
|
let [agent] = await api(`/companies/${company.id}/agents`);
|
|
expect(agent.adapterType).toBe("codex_local");
|
|
expect(await api(`/companies/${company.id}/issues`)).toEqual([]);
|
|
expect(await api(`/companies/${company.id}/heartbeat-runs`)).toEqual([]);
|
|
expect(
|
|
(await api(`/companies/${company.id}/tools/connections`)).connections,
|
|
).toEqual([]);
|
|
diagnosticState = async () => ({
|
|
base,
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
tasks: (await api(`/companies/${company.id}/issues`)).map(
|
|
(task: Record<string, unknown>) => ({
|
|
id: task.id,
|
|
status: task.status,
|
|
assigneeAgentId: task.assigneeAgentId,
|
|
}),
|
|
),
|
|
runs: (await api(`/companies/${company.id}/heartbeat-runs`)).map(
|
|
(run: Record<string, unknown>) => ({
|
|
id: run.id,
|
|
status: run.status,
|
|
runtimeMode: run.runtimeMode,
|
|
error: run.error,
|
|
errorCode: run.errorCode,
|
|
}),
|
|
),
|
|
});
|
|
const dataDir = logs
|
|
.match(/Data directory: ([^\n\r]+)/)![1]!
|
|
.replace(/\u001b\[[0-9;]*m/g, "")
|
|
.trim();
|
|
await writeFile(
|
|
info.outputPath("running-instance.json"),
|
|
JSON.stringify(
|
|
{ base, dataDir, companyId: company.id, agentId: agent.id },
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
const prefix = `/${company.issuePrefix}`;
|
|
await page.goto(base + prefix + "/dashboard");
|
|
await expect(page.getByText("No runs yet").first()).toBeVisible({ timeout: 30_000 });
|
|
if (journey === "legacy_unknown") {
|
|
agent = await api(`/companies/${company.id}/agents`, "POST", {
|
|
name: "Legacy executor", role: "engineer", adapterType: "process",
|
|
adapterConfig: { command: process.execPath, args: ["-e", "console.error('Deterministic provider failure; no recoverable session contract'); process.exit(1)"] },
|
|
});
|
|
await page.getByRole("link", { name: "Tasks", exact: true }).click();
|
|
await page.getByRole("button", { name: "New Task", exact: true }).last().click();
|
|
await page.getByPlaceholder("Task title").fill("Read the legacy fixture report");
|
|
await page.getByRole("button", { name: "Assignee", exact: true }).click();
|
|
await page.getByRole("button", { name: agent.name, exact: true }).click();
|
|
await page.getByRole("button", { name: "Create Task", exact: true }).click();
|
|
await page.getByRole("complementary").getByRole("link", { name: /Read the legacy fixture report/ }).click();
|
|
await expect.poll(async () => {
|
|
const tasks = await api(`/companies/${company.id}/issues`);
|
|
return tasks.find((issue: { title: string }) => issue.title === "Read the legacy fixture report")?.status;
|
|
}, { timeout: 60_000 }).toBe("blocked");
|
|
await expect(page.getByText("Blocked", { exact: true }).first()).toBeVisible({ timeout: 30_000 });
|
|
await expect(page.getByRole("button", { name: "Reconcile and continue" })).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "Try again", exact: true })).toHaveCount(0);
|
|
const composer = page.locator('[contenteditable="true"]').last();
|
|
await composer.fill("Independent draft remains usable");
|
|
await expect(composer).toContainText("Independent draft remains usable");
|
|
await page.screenshot({ path: info.outputPath("legacy-recovery-needed.png"), fullPage: true });
|
|
// Observe past the shared retry delay; absence of a successor is part
|
|
// of the fail-closed contract, not merely a momentary UI state.
|
|
await page.waitForTimeout(35_000);
|
|
const runs = await api(`/companies/${company.id}/heartbeat-runs`);
|
|
expect(runs).toHaveLength(1);
|
|
expect(await api(`/heartbeat-runs/${runs[0].id}`)).toMatchObject({ runtimeMode: "legacy", status: "failed" });
|
|
await page.reload();
|
|
await expect(page.getByRole("button", { name: "Reconcile and continue" })).toHaveCount(0);
|
|
await writeFile(info.outputPath("instance-and-runs.json"), JSON.stringify({ base, dataDir, health, dependency: "deterministic legacy process fixture", companyId: company.id, agentId: agent.id, runs }, null, 2));
|
|
return;
|
|
}
|
|
await page.goto(
|
|
base + prefix + "/company/settings/instance/experimental",
|
|
);
|
|
const nativeRunnerToggle = page.getByRole("switch", {
|
|
name: "Toggle Paperclip Runner experimental setting",
|
|
});
|
|
// Fresh instances may already enable the native runner. Configure the
|
|
// desired state instead of blindly toggling the current default off.
|
|
if (await nativeRunnerToggle.getAttribute("aria-checked") !== "true") {
|
|
await nativeRunnerToggle.click();
|
|
}
|
|
await expect(nativeRunnerToggle).toHaveAttribute("aria-checked", "true");
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await api("/instance/settings/experimental")).enableNativeRunner,
|
|
)
|
|
.toBe(true);
|
|
await page.goto(base + prefix + `/agents/${agent.id}/configuration`);
|
|
await page.getByRole("button", { name: "Codex", exact: true }).click();
|
|
await page.getByRole("button", { name: /Paperclip Runner/ }).click();
|
|
await page
|
|
.getByRole("button", { name: /^Save(?: changes)?$/ })
|
|
.first()
|
|
.click();
|
|
await expect
|
|
.poll(async () => (await api(`/agents/${agent.id}`)).adapterType)
|
|
.toBe("paperclip_runner");
|
|
const nativeAgent = await api(`/agents/${agent.id}`);
|
|
expect(nativeAgent.adapterConfig.env).toEqual(agent.adapterConfig.env);
|
|
if (journey !== "ceo_lineage") {
|
|
const { instructionsFilePath: _instructions, ...executorConfig } =
|
|
nativeAgent.adapterConfig;
|
|
agent = await api(`/companies/${company.id}/agents`, "POST", {
|
|
name: "Executor",
|
|
role: "engineer",
|
|
adapterType: "paperclip_runner",
|
|
adapterConfig: executorConfig,
|
|
});
|
|
}
|
|
const holder = await api(`/companies/${company.id}/agents`, "POST", {
|
|
name: "Archive holder",
|
|
role: "qa",
|
|
adapterType: "process",
|
|
adapterConfig: {
|
|
command: process.execPath,
|
|
args: ["-e", "process.exit(0)"],
|
|
},
|
|
});
|
|
const connections: Record<string, { id: string; name: string }> = {};
|
|
for (const service of ["notion", "gmail"]) {
|
|
await page.goto(base + prefix + "/apps");
|
|
const custom = page
|
|
.getByRole("list", { name: "Connector list" })
|
|
.getByRole("listitem")
|
|
.filter({ hasText: "Connect your own tool" });
|
|
await custom
|
|
.getByRole("button", { name: "Connect", exact: true })
|
|
.click();
|
|
await custom
|
|
.getByRole("button", { name: "Connect your own MCP server" })
|
|
.click();
|
|
await page
|
|
.getByPlaceholder("https://example.com/actions")
|
|
.fill(`http://127.0.0.1:${port}/${service}`);
|
|
await page
|
|
.getByRole("button", { name: "Continue", exact: true })
|
|
.click();
|
|
await page.getByRole("button", { name: "Change", exact: true }).click();
|
|
await page.getByRole("radio", { name: "Just agents I pick" }).click();
|
|
await page.getByRole("button", { name: /Select agents/ }).click();
|
|
await page.getByRole("checkbox", { name: /Archive holder/ }).check();
|
|
await page.keyboard.press("Escape");
|
|
await page.getByRole("button", { name: /Check link/i }).click();
|
|
await expect(
|
|
page.getByRole("heading", { name: /is ready/i }),
|
|
).toBeVisible({ timeout: 30_000 });
|
|
const connection = (
|
|
await api(`/companies/${company.id}/tools/connections`)
|
|
).connections.find(
|
|
(row: { id: string }) =>
|
|
!Object.values(connections).some(
|
|
(existing) => existing.id === row.id,
|
|
),
|
|
);
|
|
connections[service] = connection;
|
|
const installs = (
|
|
await api(`/tool-connections/${connection.id}/installs`)
|
|
).installs;
|
|
expect(installs).toEqual([
|
|
expect.objectContaining({ targetId: holder.id }),
|
|
]);
|
|
}
|
|
await page.getByRole("link", { name: "Tasks", exact: true }).click();
|
|
await page
|
|
.getByRole("button", { name: "New Task", exact: true })
|
|
.last()
|
|
.click();
|
|
await page
|
|
.getByPlaceholder("Task title")
|
|
.fill(
|
|
`${journey === "ceo_lineage" ? "CEO descendant fixture: " : ""}Find the heliotrope launch notes and summarize the decisions with a source link`,
|
|
);
|
|
await page.getByRole("button", { name: "Assignee", exact: true }).click();
|
|
await page.getByRole("button", { name: agent.name, exact: true }).click();
|
|
await page
|
|
.getByRole("button", { name: "Create Task", exact: true })
|
|
.click();
|
|
await page
|
|
.getByRole("complementary")
|
|
.getByRole("link", { name: /Find the heliotrope launch notes/ })
|
|
.click();
|
|
await expect(page).toHaveURL(/issues\/(?:[a-f0-9-]+|[A-Z]+-\d+)/);
|
|
const connectPending = async (service: "notion" | "gmail") => {
|
|
const connection = connections[service]!;
|
|
const card = page
|
|
.getByTestId("connection-intent-focus-target")
|
|
.filter({ hasText: connection.name });
|
|
await expect(
|
|
card.getByRole("button", { name: /Connect \/ Use existing/ }),
|
|
).toBeVisible({ timeout: 60_000 });
|
|
await page.screenshot({
|
|
path: info.outputPath(`${service}-pending.png`),
|
|
fullPage: true,
|
|
});
|
|
await card
|
|
.getByRole("button", { name: /Connect \/ Use existing/ })
|
|
.click();
|
|
await expect(page.getByRole("dialog")).toBeVisible();
|
|
await page
|
|
.getByRole("dialog")
|
|
.getByRole("button", { name: new RegExp(connection.name) })
|
|
.click();
|
|
await expect(
|
|
card.getByText(`${connection.name} connected`, { exact: true }),
|
|
).toBeVisible({ timeout: 30_000 });
|
|
};
|
|
if (journey === "ceo_lineage")
|
|
await expect(
|
|
page
|
|
.getByText("Provider child finished; root execution continues.", {
|
|
exact: true,
|
|
})
|
|
.first(),
|
|
).toBeVisible({ timeout: 60_000 });
|
|
await connectPending("notion");
|
|
await expect(
|
|
page.getByText(/HELIOTROPE-42: Launch in two stages/).first(),
|
|
).toBeVisible({ timeout: 60_000 });
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await api(`/companies/${company.id}/heartbeat-runs`)).every(
|
|
isSettledRun,
|
|
),
|
|
{ timeout: 60_000 },
|
|
)
|
|
.toBe(true);
|
|
const [task] = await api(`/companies/${company.id}/issues`);
|
|
const originalTitle = task.title;
|
|
await page
|
|
.getByRole("textbox")
|
|
.last()
|
|
.fill(
|
|
"Now summarize my Gmail emails about launch decisions, with the source link.",
|
|
);
|
|
await page.getByRole("button", { name: "Send", exact: true }).click();
|
|
await connectPending("gmail");
|
|
if (journey === "safe_restart") {
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await api(`/companies/${company.id}/heartbeat-runs`)).some(
|
|
(run: { status: string; scheduledRetryReason?: string }) =>
|
|
run.status === "scheduled_retry" &&
|
|
run.scheduledRetryReason === "native_safe_replacement",
|
|
),
|
|
{ timeout: 45_000 },
|
|
)
|
|
.toBe(true);
|
|
await page.screenshot({
|
|
path: info.outputPath("retry-before-restart.png"),
|
|
fullPage: true,
|
|
});
|
|
await stopDrive();
|
|
const startOffset = logs.length;
|
|
processHandle = spawn(
|
|
process.execPath,
|
|
[
|
|
"cli/node_modules/tsx/dist/cli.mjs",
|
|
"cli/src/index.ts",
|
|
"test-drive",
|
|
"--data-dir",
|
|
dataDir,
|
|
"--harness",
|
|
"codex",
|
|
"--api-key-env",
|
|
"IN_FEED_FIXTURE_KEY",
|
|
"--no-browser",
|
|
],
|
|
{ cwd: root, env, detached: true, stdio: ["ignore", "pipe", "pipe"] },
|
|
);
|
|
processHandle.stdout!.on("data", (chunk) => {
|
|
logs += chunk.toString();
|
|
});
|
|
processHandle.stderr!.on("data", (chunk) => {
|
|
logs += chunk.toString();
|
|
});
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
logs
|
|
.slice(startOffset)
|
|
.match(
|
|
/Paperclip is ready at (http:\/\/127\.0\.0\.1:\d+)/,
|
|
)?.[1],
|
|
{ timeout: 100_000 },
|
|
)
|
|
.toBeTruthy();
|
|
base = logs
|
|
.slice(startOffset)
|
|
.match(/Paperclip is ready at (http:\/\/127\.0\.0\.1:\d+)/)![1]!;
|
|
expect((await api("/health")).serverInfo.git.branchName).toBe(
|
|
health.serverInfo.git.branchName,
|
|
);
|
|
expect((await api("/companies"))[0].id).toBe(company.id);
|
|
await page.goto(base + prefix + `/issues/${task.id}`);
|
|
}
|
|
const composer = page.getByRole("textbox").last();
|
|
await composer.fill("An unsent draft stays available during recovery.");
|
|
await expect(composer).toBeEditable();
|
|
if (journey !== "uncertain") {
|
|
await expect(
|
|
page
|
|
.getByText(/GMAIL-73: The launch email confirms Friday approval/)
|
|
.first(),
|
|
).toBeVisible({ timeout: 100_000 });
|
|
expect(gmailReadCount).toBe(journey === "ceo_lineage" ? 1 : 2);
|
|
} else {
|
|
await expect.poll(async () => (await api(`/issues/${task.id}`)).status, { timeout: 100_000 }).toBe("blocked");
|
|
await expect(page.getByText("Blocked", { exact: true }).first()).toBeVisible({ timeout: 30_000 });
|
|
expect(gmailReadCount).toBe(1);
|
|
await expect(page.getByText(/GMAIL-73/)).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: /Reconcile and continue|Try again/ })).toHaveCount(0);
|
|
await expect(page.getByRole("dialog", { name: "Reconcile execution" })).toHaveCount(0);
|
|
const recovery = (await api(`/issues/${task.id}`)).activeRecoveryAction;
|
|
expect(recovery).toBeNull();
|
|
await page.screenshot({ path: info.outputPath("uncertain-automatic-no-replay.png"), fullPage: true });
|
|
// Past the retry delay, unknown effects still cannot be replayed.
|
|
await page.waitForTimeout(35_000);
|
|
expect(gmailReadCount).toBe(1);
|
|
}
|
|
await expect(composer).toHaveText(
|
|
"An unsent draft stays available during recovery.",
|
|
);
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await api(`/companies/${company.id}/heartbeat-runs`)).every(
|
|
(run: { status: string }) =>
|
|
!["running", "queued", "scheduled_retry"].includes(run.status),
|
|
),
|
|
{ timeout: 60_000 },
|
|
)
|
|
.toBe(true);
|
|
if (journey !== "uncertain") await expect(async () => {
|
|
const answer = page.getByText(/GMAIL-73: The launch email confirms Friday approval/).first();
|
|
// Refresh can replace the streamed row with its persisted transcript.
|
|
// Re-resolve the locator if that handoff detaches it during scrolling.
|
|
await answer.scrollIntoViewIfNeeded();
|
|
await expect(answer).toBeInViewport();
|
|
}).toPass({ timeout: 10_000 });
|
|
await page.screenshot({
|
|
path: info.outputPath(`${journey}-outcome.png`),
|
|
fullPage: true,
|
|
});
|
|
const finalRuns = await Promise.all(
|
|
(await api(`/companies/${company.id}/heartbeat-runs`)).map(
|
|
(run: { id: string }) => api(`/heartbeat-runs/${run.id}`),
|
|
),
|
|
);
|
|
const replacements = finalRuns.filter(
|
|
(run: { scheduledRetryReason?: string }) =>
|
|
run.scheduledRetryReason === "native_safe_replacement",
|
|
);
|
|
expect(replacements).toHaveLength(
|
|
journey === "safe" || journey === "safe_restart" ? 1 : 0,
|
|
);
|
|
expect(
|
|
finalRuns.every(
|
|
(run: { runtimeMode: string }) => run.runtimeMode === "native",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
finalRuns.some((run: { status: string }) => run.status === "running"),
|
|
).toBe(false);
|
|
expect((await api(`/issues/${task.id}`)).title).toBe(originalTitle);
|
|
await composer.fill("");
|
|
await page.reload();
|
|
await expect(page.getByText(/Due now/, { exact: true })).toHaveCount(0);
|
|
if (journey !== "uncertain") await expect(
|
|
page
|
|
.getByText(/GMAIL-73: The launch email confirms Friday approval/)
|
|
.first(),
|
|
).toBeVisible();
|
|
else {
|
|
expect((await api(`/issues/${task.id}`)).status).toBe("blocked");
|
|
await expect(page.getByRole("button", { name: "Reconcile and continue" })).toHaveCount(0);
|
|
}
|
|
if (journey !== "uncertain") await expect(async () => {
|
|
const answer = page.getByText(/GMAIL-73: The launch email confirms Friday approval/).first();
|
|
// Refresh can replace the streamed row with its persisted transcript.
|
|
// Re-resolve the locator if that handoff detaches it during scrolling.
|
|
await answer.scrollIntoViewIfNeeded();
|
|
await expect(answer).toBeInViewport();
|
|
}).toPass({ timeout: 10_000 });
|
|
await page.screenshot({
|
|
path: info.outputPath(`${journey}-after-refresh.png`),
|
|
fullPage: true,
|
|
});
|
|
const evidence = JSON.stringify(
|
|
{
|
|
base,
|
|
dataDir,
|
|
health,
|
|
journey,
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
taskId: task.id,
|
|
connections,
|
|
runs: finalRuns.map((run: Record<string, unknown>) => ({
|
|
id: run.id,
|
|
runtimeMode: run.runtimeMode,
|
|
status: run.status,
|
|
retryOfRunId: run.retryOfRunId,
|
|
errorCode: run.errorCode,
|
|
execution: run.execution,
|
|
})),
|
|
dependency: "fixture",
|
|
provider: "codex",
|
|
fixtureModel: "in-feed-fixture",
|
|
calls,
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
await writeFile(info.outputPath("instance-and-runs.json"), evidence);
|
|
await info.attach("instance-and-runs", {
|
|
body: evidence,
|
|
contentType: "application/json",
|
|
});
|
|
} finally {
|
|
await writeFile(
|
|
info.outputPath("diagnostics.json"),
|
|
JSON.stringify(await diagnosticState().catch(() => ({})), null, 2),
|
|
);
|
|
await writeFile(info.outputPath("test-drive.log"), logs);
|
|
await stopDrive();
|
|
fixture.closeAllConnections();
|
|
await new Promise<void>((done) => fixture.close(() => done()));
|
|
}
|
|
});
|