Merge branch 'master' of github.com-dotta:paperclipai/paperclip

* 'master' of github.com-dotta:paperclipai/paperclip:
  Tighten transcript label styling
  Fix env-sensitive worktree and runtime config tests
  Refine executed command row centering
  Tighten live run transcript streaming and stdout
  Center collapsed command group rows
  Refine collapsed command failure styling
  Tighten command transcript rows and dashboard card
  Polish transcript event widgets
  Refine transcript chrome and labels
  fix: remove paperclip property from OpenClaw Gateway agent params
  Add a run transcript UX fixture lab
  Humanize run transcripts across run detail and live surfaces
  fix(adapters/gemini-local): address PR review feedback
  fix(adapters/gemini-local): inject skills into ~/.gemini/ instead of tmpdir
  fix(adapters/gemini-local): downgrade missing API key to info level
  feat(adapters/gemini-local): add auth detection, turn-limit handling, sandbox, and approval modes
  fix(adapters/gemini-local): address PR review feedback for skills and formatting
  feat(adapters): add Gemini CLI local adapter support

# Conflicts:
#	cli/src/__tests__/worktree.test.ts
This commit is contained in:
Dotta
2026-03-11 17:04:30 -05:00
64 changed files with 4389 additions and 1157 deletions

View File

@@ -5,6 +5,10 @@ import {
sessionCodec as cursorSessionCodec,
isCursorUnknownSessionError,
} from "@paperclipai/adapter-cursor-local/server";
import {
sessionCodec as geminiSessionCodec,
isGeminiUnknownSessionError,
} from "@paperclipai/adapter-gemini-local/server";
import {
sessionCodec as opencodeSessionCodec,
isOpenCodeUnknownSessionError,
@@ -82,6 +86,24 @@ describe("adapter session codecs", () => {
});
expect(cursorSessionCodec.getDisplayId?.(serialized ?? null)).toBe("cursor-session-1");
});
it("normalizes gemini session params with cwd", () => {
const parsed = geminiSessionCodec.deserialize({
session_id: "gemini-session-1",
cwd: "/tmp/gemini",
});
expect(parsed).toEqual({
sessionId: "gemini-session-1",
cwd: "/tmp/gemini",
});
const serialized = geminiSessionCodec.serialize(parsed);
expect(serialized).toEqual({
sessionId: "gemini-session-1",
cwd: "/tmp/gemini",
});
expect(geminiSessionCodec.getDisplayId?.(serialized ?? null)).toBe("gemini-session-1");
});
});
describe("codex resume recovery detection", () => {
@@ -146,3 +168,26 @@ describe("cursor resume recovery detection", () => {
).toBe(false);
});
});
describe("gemini resume recovery detection", () => {
it("detects unknown session errors from gemini output", () => {
expect(
isGeminiUnknownSessionError(
"",
"unknown session id abc",
),
).toBe(true);
expect(
isGeminiUnknownSessionError(
"",
"checkpoint latest not found",
),
).toBe(true);
expect(
isGeminiUnknownSessionError(
"{\"type\":\"result\",\"subtype\":\"success\"}",
"",
),
).toBe(false);
});
});

View File

@@ -70,6 +70,7 @@ describe("codex_local ui stdout parser", () => {
kind: "tool_call",
ts,
name: "command_execution",
toolUseId: "item_2",
input: { id: "item_2", command: "/bin/zsh -lc ls" },
},
]);

View File

@@ -165,6 +165,7 @@ describe("cursor ui stdout parser", () => {
kind: "tool_call",
ts,
name: "shellToolCall",
toolUseId: "call_shell_1",
input: { command: longCommand },
},
]);
@@ -254,7 +255,7 @@ describe("cursor ui stdout parser", () => {
}),
ts,
),
).toEqual([{ kind: "tool_call", ts, name: "readToolCall", input: { path: "README.md" } }]);
).toEqual([{ kind: "tool_call", ts, name: "readToolCall", toolUseId: "call_1", input: { path: "README.md" } }]);
expect(
parseCursorStdoutLine(

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { testEnvironment } from "@paperclipai/adapter-gemini-local/server";
async function writeFakeGeminiCommand(binDir: string, argsCapturePath: string): Promise<string> {
const commandPath = path.join(binDir, "gemini");
const script = `#!/usr/bin/env node
const fs = require("node:fs");
const outPath = process.env.PAPERCLIP_TEST_ARGS_PATH;
if (outPath) {
fs.writeFileSync(outPath, JSON.stringify(process.argv.slice(2)), "utf8");
}
console.log(JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "hello" }] },
}));
console.log(JSON.stringify({
type: "result",
subtype: "success",
result: "hello",
}));
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
return commandPath;
}
describe("gemini_local environment diagnostics", () => {
it("creates a missing working directory when cwd is absolute", async () => {
const cwd = path.join(
os.tmpdir(),
`paperclip-gemini-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
"workspace",
);
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
const result = await testEnvironment({
companyId: "company-1",
adapterType: "gemini_local",
config: {
command: process.execPath,
cwd,
},
});
expect(result.checks.some((check) => check.code === "gemini_cwd_valid")).toBe(true);
expect(result.checks.some((check) => check.level === "error")).toBe(false);
const stats = await fs.stat(cwd);
expect(stats.isDirectory()).toBe(true);
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
});
it("passes model and yolo flags to the hello probe", async () => {
const root = path.join(
os.tmpdir(),
`paperclip-gemini-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
const binDir = path.join(root, "bin");
const cwd = path.join(root, "workspace");
const argsCapturePath = path.join(root, "args.json");
await fs.mkdir(binDir, { recursive: true });
await writeFakeGeminiCommand(binDir, argsCapturePath);
const result = await testEnvironment({
companyId: "company-1",
adapterType: "gemini_local",
config: {
command: "gemini",
cwd,
model: "gemini-2.5-pro",
yolo: true,
env: {
GEMINI_API_KEY: "test-key",
PAPERCLIP_TEST_ARGS_PATH: argsCapturePath,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
},
},
});
expect(result.status).not.toBe("fail");
const args = JSON.parse(await fs.readFile(argsCapturePath, "utf8")) as string[];
expect(args).toContain("--model");
expect(args).toContain("gemini-2.5-pro");
expect(args).toContain("--approval-mode");
expect(args).toContain("yolo");
await fs.rm(root, { recursive: true, force: true });
});
});

View File

@@ -0,0 +1,158 @@
import { describe, expect, it, vi } from "vitest";
import { isGeminiUnknownSessionError, parseGeminiJsonl } from "@paperclipai/adapter-gemini-local/server";
import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui";
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
describe("gemini_local parser", () => {
it("extracts session, summary, usage, cost, and terminal error message", () => {
const stdout = [
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
JSON.stringify({
type: "assistant",
message: {
content: [{ type: "output_text", text: "hello" }],
},
}),
JSON.stringify({
type: "result",
subtype: "success",
session_id: "gemini-session-1",
usage: {
promptTokenCount: 12,
cachedContentTokenCount: 3,
candidatesTokenCount: 7,
},
total_cost_usd: 0.00123,
result: "done",
}),
JSON.stringify({ type: "error", message: "model access denied" }),
].join("\n");
const parsed = parseGeminiJsonl(stdout);
expect(parsed.sessionId).toBe("gemini-session-1");
expect(parsed.summary).toBe("hello");
expect(parsed.usage).toEqual({
inputTokens: 12,
cachedInputTokens: 3,
outputTokens: 7,
});
expect(parsed.costUsd).toBeCloseTo(0.00123, 6);
expect(parsed.errorMessage).toBe("model access denied");
});
});
describe("gemini_local stale session detection", () => {
it("treats missing session messages as an unknown session error", () => {
expect(isGeminiUnknownSessionError("", "unknown session id abc")).toBe(true);
expect(isGeminiUnknownSessionError("", "checkpoint latest not found")).toBe(true);
});
});
describe("gemini_local ui stdout parser", () => {
it("parses assistant, thinking, and result events", () => {
const ts = "2026-03-08T00:00:00.000Z";
expect(
parseGeminiStdoutLine(
JSON.stringify({
type: "assistant",
message: {
content: [
{ type: "output_text", text: "I checked the repo." },
{ type: "thinking", text: "Reviewing adapter registry" },
{ type: "tool_call", name: "shell", input: { command: "ls -1" } },
{ type: "tool_result", tool_use_id: "tool_1", output: "AGENTS.md\n", status: "ok" },
],
},
}),
ts,
),
).toEqual([
{ kind: "assistant", ts, text: "I checked the repo." },
{ kind: "thinking", ts, text: "Reviewing adapter registry" },
{ kind: "tool_call", ts, name: "shell", input: { command: "ls -1" } },
{ kind: "tool_result", ts, toolUseId: "tool_1", content: "AGENTS.md\n", isError: false },
]);
expect(
parseGeminiStdoutLine(
JSON.stringify({
type: "result",
subtype: "success",
result: "Done",
usage: {
promptTokenCount: 10,
candidatesTokenCount: 5,
cachedContentTokenCount: 2,
},
total_cost_usd: 0.00042,
is_error: false,
}),
ts,
),
).toEqual([
{
kind: "result",
ts,
text: "Done",
inputTokens: 10,
outputTokens: 5,
cachedTokens: 2,
costUsd: 0.00042,
subtype: "success",
isError: false,
errors: [],
},
]);
});
});
function stripAnsi(value: string): string {
return value.replace(/\x1b\[[0-9;]*m/g, "");
}
describe("gemini_local cli formatter", () => {
it("prints init, assistant, result, and error events", () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
let joined = "";
try {
printGeminiStreamEvent(
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
false,
);
printGeminiStreamEvent(
JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "hello" }] },
}),
false,
);
printGeminiStreamEvent(
JSON.stringify({
type: "result",
subtype: "success",
usage: {
promptTokenCount: 10,
candidatesTokenCount: 5,
cachedContentTokenCount: 2,
},
total_cost_usd: 0.00042,
}),
false,
);
printGeminiStreamEvent(
JSON.stringify({ type: "error", message: "boom" }),
false,
);
joined = spy.mock.calls.map((call) => stripAnsi(call.join(" "))).join("\n");
} finally {
spy.mockRestore();
}
expect(joined).toContain("Gemini init");
expect(joined).toContain("assistant: hello");
expect(joined).toContain("tokens: in=10 out=5 cached=2 cost=$0.000420");
expect(joined).toContain("error: boom");
});
});

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execute } from "@paperclipai/adapter-gemini-local/server";
async function writeFakeGeminiCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
const fs = require("node:fs");
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
const payload = {
argv: process.argv.slice(2),
paperclipEnvKeys: Object.keys(process.env)
.filter((key) => key.startsWith("PAPERCLIP_"))
.sort(),
};
if (capturePath) {
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
}
console.log(JSON.stringify({
type: "system",
subtype: "init",
session_id: "gemini-session-1",
model: "gemini-2.5-pro",
}));
console.log(JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "hello" }] },
}));
console.log(JSON.stringify({
type: "result",
subtype: "success",
session_id: "gemini-session-1",
result: "ok",
}));
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
}
type CapturePayload = {
argv: string[];
paperclipEnvKeys: string[];
};
describe("gemini execute", () => {
it("passes prompt as final argument and injects paperclip env vars", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
const capturePath = path.join(root, "capture.json");
await fs.mkdir(workspace, { recursive: true });
await writeFakeGeminiCommand(commandPath);
const previousHome = process.env.HOME;
process.env.HOME = root;
let invocationPrompt = "";
try {
const result = await execute({
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Gemini Coder",
adapterType: "gemini_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
model: "gemini-2.5-pro",
yolo: true,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
onMeta: async (meta) => {
invocationPrompt = meta.prompt ?? "";
},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.argv).toContain("--output-format");
expect(capture.argv).toContain("stream-json");
expect(capture.argv).toContain("--approval-mode");
expect(capture.argv).toContain("yolo");
expect(capture.argv.at(-1)).toContain("Follow the paperclip heartbeat.");
expect(capture.argv.at(-1)).toContain("Paperclip runtime note:");
expect(capture.paperclipEnvKeys).toEqual(
expect.arrayContaining([
"PAPERCLIP_AGENT_ID",
"PAPERCLIP_API_KEY",
"PAPERCLIP_API_URL",
"PAPERCLIP_COMPANY_ID",
"PAPERCLIP_RUN_ID",
]),
);
expect(invocationPrompt).toContain("Paperclip runtime note:");
expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
});

View File

@@ -456,33 +456,6 @@ describe("openclaw gateway adapter execute", () => {
expect(String(payload?.message ?? "")).toContain("wake now");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
expect(payload?.paperclip).toEqual(
expect.objectContaining({
runId: "run-123",
companyId: "company-123",
agentId: "agent-123",
taskId: "task-123",
issueId: "issue-123",
workspace: expect.objectContaining({
cwd: "/tmp/worktrees/pap-123",
strategy: "git_worktree",
}),
workspaces: [
expect.objectContaining({
id: "workspace-1",
cwd: "/tmp/project",
}),
],
workspaceRuntime: expect.objectContaining({
services: [
expect.objectContaining({
name: "preview",
lifecycle: "ephemeral",
}),
],
}),
}),
);
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
} finally {

View File

@@ -103,6 +103,7 @@ describe("opencode_local ui stdout parser", () => {
kind: "tool_call",
ts,
name: "bash",
toolUseId: "call_1",
input: { command: "ls -1" },
},
{