mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 21:03:51 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Managed deployments provision a platform-managed default environment
for agent runs; the UI shows this environment in selectors, the agent
form, run details, and the environments page
> - Those surfaces append the raw driver key to the environment name, so
users see labels like "Paperclip Computer (sandbox)", "Paperclip
Computer · sandbox", and fallback copy such as "Managed sandbox" and
"The sandbox has no ready authentication"
> - "sandbox" is infrastructure vocabulary, not the product name of the
environment; showing it next to the managed environment's name is
confusing and off-brand
> - This pull request renders platform-managed environments by name
alone and rewords the sandbox-phrased copy, while user-created
environments keep the driver suffix so mixed lists stay distinguishable
> - The benefit is that the default environment reads as one clear
product name everywhere, and self-hosted users lose nothing: their own
environments still show the driver
## Linked Issues or Issue Description
**What existing behavior does this improve?**
Display of the platform-managed default environment across the UI.
**Subsystem affected**
UI (environment selectors, agent config form, environments page, agents
page, run details) and the claude-local/codex-local adapter auth checks.
**Current behavior**
The agent form labels the inherited default environment as "Name
(sandbox)". Environment selectors and the environments list render "Name
· sandbox". The agents page describes the environment as "<provider>
sandbox provider". The agent form's fallback label is "Managed sandbox".
Adapter auth checks say "The sandbox has no ready authentication for
this adapter."
**Proposed behavior**
Platform-managed environment rows (`metadata.managedByPaperclip`) render
their name alone. The fallback label is "Paperclip Computer". The agents
page describes managed environments as "Managed by Paperclip". Run
details omit the driver suffix for sandbox-driver environments (the
adjacent Provider entry already identifies the mechanism). Adapter auth
checks say "This environment has no ready authentication for this
adapter."
**Reason and benefit**
The managed environment carries a product name. Appending the raw driver
key ("sandbox") to it is noise and contradicts the product naming.
User-created environments keep the driver suffix, so mixed lists stay
distinguishable.
**Breaking changes**
None. Message text of the auth check is not read programmatically; the
UI keys off `ADAPTER_AUTH_MISSING_CHECK_CODE`. Rows without the managed
marker render exactly as before.
## What Changed
- New `environmentDisplayLabel` helper in
`ui/src/lib/managed-sandbox-environment.ts`: managed rows → name alone;
other rows → "Name · driver".
- `AgentConfigForm`: inherited-default label uses the helper; fallback
copy "Managed sandbox" → "Paperclip Computer"; environment options use
the helper.
- `ProjectProperties`, `CompanyEnvironments`: environment selector
options use the helper; the environments-list row hides the driver
suffix on managed rows; the managed detail page's fallback description
no longer says "sandbox".
- `Agents` page: managed environments are described as "Managed by
Paperclip" instead of "<provider> sandbox provider".
- `CommentThread` run details: the driver suffix is omitted for
sandbox-driver environments.
- claude-local and codex-local adapters: auth-missing check message/hint
reworded from "sandbox" to "environment" (ACP and environment-test
paths); claude-local probe/effort/login hints reworded the same way.
- Run status lines: "Syncing workspace to sandbox", "Exporting git
changes from sandbox", "Starting adapter in sandbox", and friends now
say "environment"; "Finalizing sandbox workspace" → "Finalizing
workspace". Templated transfer-progress lines map the `sandbox`
transport key to "environment" for display (`runtime-progress.ts`).
- Agent form sign-in panel: "Sign in to the sandbox" → "Sign in to the
environment"; "Authenticated. The sandbox has credentials now." → "…The
environment has credentials now."
- Feature catalog + instance settings card: "Managed Sandbox Only" →
"Managed Environment Only" (setting key unchanged; the card keeps its
alphabetical slot).
- Server agents routes: execution-target failure and test-identity copy
no longer say "sandbox"; workspace-mode label "Cloud sandbox" → "Cloud
environment".
- Tests: new `environmentDisplayLabel` unit cases; new `AgentConfigForm`
render case asserting the managed default renders without "(sandbox)" or
"· sandbox"; status-line assertions updated across adapter-utils, server
heartbeat/live-run, and UI chat suites.
## Verification
- `pnpm --filter @paperclipai/ui typecheck` — clean.
- `pnpm --filter @paperclipai/adapter-claude-local typecheck` and
`--filter @paperclipai/adapter-codex-local typecheck` — clean.
- `vitest run` for `managed-sandbox-environment.test.ts`,
`AgentConfigForm.render.test.tsx`, `CompanyEnvironments.test.tsx`,
`Agents.test.tsx`, `CommentThread.test.tsx`, `NewAgent.test.tsx` — all
green (118 tests across the two runs).
## Risks
Low risk. Cosmetic label changes only; no data or API changes. Rows
without `metadata.managedByPaperclip` render exactly as before, so
self-hosted deployments with their own environments see no change. The
only self-hosted-visible wording changes are the adapter auth-check
message and the driver suffix omission on sandbox-driver rows in run
details.
## Model Used
- Claude (Anthropic) — claude-fable-5 (Claude Fable 5), Claude Code CLI,
extended thinking, tool use.
## 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 (no
docs reference these labels)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
282 lines
8.3 KiB
TypeScript
282 lines
8.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { createRuntimeProgressReporter } from "./runtime-progress.js";
|
|
|
|
const MB = 1024 * 1024;
|
|
|
|
function makeClock(start = 0) {
|
|
let value = start;
|
|
return {
|
|
now: () => value,
|
|
advance: (ms: number) => {
|
|
value += ms;
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("createRuntimeProgressReporter", () => {
|
|
it("formats the message with phase, label, direction, target, percent and MB", async () => {
|
|
const lines: string[] = [];
|
|
const reporter = createRuntimeProgressReporter({
|
|
sink: (line) => {
|
|
lines.push(line);
|
|
},
|
|
phase: "Syncing",
|
|
label: "workspace",
|
|
direction: "to",
|
|
target: "sandbox",
|
|
});
|
|
|
|
await reporter.report(12.6 * MB, 31.4 * MB);
|
|
|
|
expect(lines).toEqual(["[paperclip] Syncing workspace to environment: 40% (12.6/31.4 MB)\n"]);
|
|
});
|
|
|
|
it("omits the label when none is provided (e.g. git history)", async () => {
|
|
const lines: string[] = [];
|
|
const reporter = createRuntimeProgressReporter({
|
|
sink: (line) => {
|
|
lines.push(line);
|
|
},
|
|
phase: "Importing git history",
|
|
direction: "to",
|
|
target: "ssh",
|
|
});
|
|
|
|
await reporter.report(4 * MB, 4 * MB);
|
|
|
|
expect(lines).toEqual(["[paperclip] Importing git history to ssh: 100% (4.0/4.0 MB)\n"]);
|
|
});
|
|
|
|
it("suppresses intermediate emits that neither cross a step nor exceed the interval", async () => {
|
|
const clock = makeClock();
|
|
const lines: string[] = [];
|
|
const reporter = createRuntimeProgressReporter({
|
|
sink: (line) => {
|
|
lines.push(line);
|
|
},
|
|
phase: "Syncing",
|
|
label: "workspace",
|
|
direction: "to",
|
|
target: "sandbox",
|
|
now: clock.now,
|
|
});
|
|
|
|
// First report always emits (step 0 crossed).
|
|
await reporter.report(1 * MB, 100 * MB); // 1%
|
|
// Still within the same 10% step and under 2s -> suppressed.
|
|
await reporter.report(2 * MB, 100 * MB); // 2%
|
|
await reporter.report(5 * MB, 100 * MB); // 5%
|
|
|
|
expect(lines).toHaveLength(1);
|
|
expect(lines[0]).toBe("[paperclip] Syncing workspace to environment: 1% (1.0/100.0 MB)\n");
|
|
});
|
|
|
|
it("emits when the percentage crosses a 10% step", async () => {
|
|
const clock = makeClock();
|
|
const lines: string[] = [];
|
|
const reporter = createRuntimeProgressReporter({
|
|
sink: (line) => {
|
|
lines.push(line);
|
|
},
|
|
phase: "Syncing",
|
|
label: "workspace",
|
|
direction: "to",
|
|
target: "sandbox",
|
|
now: clock.now,
|
|
});
|
|
|
|
await reporter.report(1 * MB, 100 * MB); // 1% -> emit (step 0)
|
|
await reporter.report(15 * MB, 100 * MB); // 15% -> crosses into step 1 -> emit
|
|
|
|
expect(lines).toHaveLength(2);
|
|
expect(lines[1]).toBe("[paperclip] Syncing workspace to environment: 15% (15.0/100.0 MB)\n");
|
|
});
|
|
|
|
it("emits on the time threshold even without a step crossing", async () => {
|
|
const clock = makeClock();
|
|
const lines: string[] = [];
|
|
const reporter = createRuntimeProgressReporter({
|
|
sink: (line) => {
|
|
lines.push(line);
|
|
},
|
|
phase: "Syncing",
|
|
label: "workspace",
|
|
direction: "to",
|
|
target: "sandbox",
|
|
now: clock.now,
|
|
});
|
|
|
|
await reporter.report(1 * MB, 100 * MB); // emit
|
|
await reporter.report(2 * MB, 100 * MB); // suppressed (same step, no time elapsed)
|
|
clock.advance(2000);
|
|
await reporter.report(3 * MB, 100 * MB); // 3% same step, but 2s elapsed -> emit
|
|
|
|
expect(lines).toHaveLength(2);
|
|
expect(lines[1]).toBe("[paperclip] Syncing workspace to environment: 3% (3.0/100.0 MB)\n");
|
|
});
|
|
|
|
it("always emits the terminal 100% line via report reaching the total", async () => {
|
|
const clock = makeClock();
|
|
const lines: string[] = [];
|
|
const reporter = createRuntimeProgressReporter({
|
|
sink: (line) => {
|
|
lines.push(line);
|
|
},
|
|
phase: "Restoring",
|
|
label: "workspace",
|
|
direction: "from",
|
|
target: "sandbox",
|
|
now: clock.now,
|
|
});
|
|
|
|
await reporter.report(1 * MB, 100 * MB); // emit
|
|
await reporter.report(100 * MB, 100 * MB); // terminal -> always emit
|
|
|
|
expect(lines[lines.length - 1]).toBe(
|
|
"[paperclip] Restoring workspace from environment: 100% (100.0/100.0 MB)\n",
|
|
);
|
|
});
|
|
|
|
it("complete() emits the terminal 100% line even when intermediate emits were throttled", async () => {
|
|
const clock = makeClock();
|
|
const lines: string[] = [];
|
|
const reporter = createRuntimeProgressReporter({
|
|
sink: (line) => {
|
|
lines.push(line);
|
|
},
|
|
phase: "Syncing",
|
|
label: "workspace",
|
|
direction: "to",
|
|
target: "sandbox",
|
|
now: clock.now,
|
|
});
|
|
|
|
await reporter.report(1 * MB, 100 * MB); // emit
|
|
await reporter.report(5 * MB, 100 * MB); // suppressed
|
|
await reporter.complete();
|
|
|
|
expect(lines[lines.length - 1]).toBe(
|
|
"[paperclip] Syncing workspace to environment: 100% (100.0/100.0 MB)\n",
|
|
);
|
|
});
|
|
|
|
it("complete() is idempotent and does not double-emit after a terminal report", async () => {
|
|
const lines: string[] = [];
|
|
const reporter = createRuntimeProgressReporter({
|
|
sink: (line) => {
|
|
lines.push(line);
|
|
},
|
|
phase: "Syncing",
|
|
label: "workspace",
|
|
direction: "to",
|
|
target: "sandbox",
|
|
});
|
|
|
|
await reporter.report(100 * MB, 100 * MB); // terminal
|
|
await reporter.complete();
|
|
await reporter.complete();
|
|
|
|
expect(lines).toHaveLength(1);
|
|
});
|
|
|
|
it("reports MB-only (no percent) when the total is unknown, plus a completion line", async () => {
|
|
const clock = makeClock();
|
|
const lines: string[] = [];
|
|
const reporter = createRuntimeProgressReporter({
|
|
sink: (line) => {
|
|
lines.push(line);
|
|
},
|
|
phase: "Restoring",
|
|
label: "workspace",
|
|
direction: "from",
|
|
target: "ssh",
|
|
now: clock.now,
|
|
});
|
|
|
|
await reporter.report(2 * MB, null); // first emit
|
|
await reporter.report(4 * MB, null); // suppressed (no time elapsed)
|
|
clock.advance(2000);
|
|
await reporter.report(8 * MB, null); // time elapsed -> emit
|
|
await reporter.complete();
|
|
|
|
expect(lines).toEqual([
|
|
"[paperclip] Restoring workspace from ssh: 2.0 MB\n",
|
|
"[paperclip] Restoring workspace from ssh: 8.0 MB\n",
|
|
"[paperclip] Restoring workspace from ssh: 8.0 MB\n",
|
|
]);
|
|
});
|
|
|
|
it("fail() emits a terminal failure marker with the last percent instead of a dangling line", async () => {
|
|
const clock = makeClock();
|
|
const lines: string[] = [];
|
|
const reporter = createRuntimeProgressReporter({
|
|
sink: (line) => {
|
|
lines.push(line);
|
|
},
|
|
phase: "Syncing",
|
|
label: "workspace",
|
|
direction: "to",
|
|
target: "ssh",
|
|
now: clock.now,
|
|
});
|
|
|
|
await reporter.report(40 * MB, 100 * MB); // emit at 40%
|
|
await reporter.fail();
|
|
|
|
expect(lines).toEqual([
|
|
"[paperclip] Syncing workspace to ssh: 40% (40.0/100.0 MB)\n",
|
|
"[paperclip] Syncing workspace to ssh: failed at 40% (40.0/100.0 MB)\n",
|
|
]);
|
|
});
|
|
|
|
it("fail() falls back to an MB marker when the total is unknown", async () => {
|
|
const lines: string[] = [];
|
|
const reporter = createRuntimeProgressReporter({
|
|
sink: (line) => {
|
|
lines.push(line);
|
|
},
|
|
phase: "Restoring",
|
|
direction: "from",
|
|
target: "ssh",
|
|
});
|
|
|
|
await reporter.report(3 * MB, null);
|
|
await reporter.fail();
|
|
|
|
expect(lines.at(-1)).toBe("[paperclip] Restoring from ssh: failed after 3.0 MB\n");
|
|
});
|
|
|
|
it("fail() is suppressed after a terminal completion and complete() after a failure", async () => {
|
|
const lines: string[] = [];
|
|
const reporter = createRuntimeProgressReporter({
|
|
sink: (line) => {
|
|
lines.push(line);
|
|
},
|
|
phase: "Syncing",
|
|
label: "workspace",
|
|
direction: "to",
|
|
target: "ssh",
|
|
});
|
|
|
|
await reporter.report(100 * MB, 100 * MB); // terminal complete
|
|
await reporter.fail(); // suppressed — already completed
|
|
expect(lines).toHaveLength(1);
|
|
|
|
const failLines: string[] = [];
|
|
const failed = createRuntimeProgressReporter({
|
|
sink: (line) => {
|
|
failLines.push(line);
|
|
},
|
|
phase: "Syncing",
|
|
label: "workspace",
|
|
direction: "to",
|
|
target: "ssh",
|
|
});
|
|
await failed.report(20 * MB, 100 * MB);
|
|
await failed.fail();
|
|
await failed.complete(); // suppressed — already failed
|
|
expect(failLines).toHaveLength(2);
|
|
expect(failLines.at(-1)).toContain("failed at 20%");
|
|
});
|
|
});
|