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:
@@ -37,6 +37,7 @@
|
|||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
|
"@paperclipai/adapter-gemini-local": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
copyGitHooksToWorktreeGitDir,
|
copyGitHooksToWorktreeGitDir,
|
||||||
copySeededSecretsKey,
|
copySeededSecretsKey,
|
||||||
@@ -23,6 +23,20 @@ import {
|
|||||||
} from "../commands/worktree-lib.js";
|
} from "../commands/worktree-lib.js";
|
||||||
import type { PaperclipConfig } from "../config/schema.js";
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
|
|
||||||
|
const ORIGINAL_CWD = process.cwd();
|
||||||
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.chdir(ORIGINAL_CWD);
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (!(key in ORIGINAL_ENV)) delete process.env[key];
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function buildSourceConfig(): PaperclipConfig {
|
function buildSourceConfig(): PaperclipConfig {
|
||||||
return {
|
return {
|
||||||
$meta: {
|
$meta: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
|||||||
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
||||||
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||||
|
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
|
||||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
||||||
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
||||||
@@ -33,6 +34,11 @@ const cursorLocalCLIAdapter: CLIAdapterModule = {
|
|||||||
formatStdoutEvent: printCursorStreamEvent,
|
formatStdoutEvent: printCursorStreamEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const geminiLocalCLIAdapter: CLIAdapterModule = {
|
||||||
|
type: "gemini_local",
|
||||||
|
formatStdoutEvent: printGeminiStreamEvent,
|
||||||
|
};
|
||||||
|
|
||||||
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||||
type: "openclaw_gateway",
|
type: "openclaw_gateway",
|
||||||
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||||
@@ -45,6 +51,7 @@ const adaptersByType = new Map<string, CLIAdapterModule>(
|
|||||||
openCodeLocalCLIAdapter,
|
openCodeLocalCLIAdapter,
|
||||||
piLocalCLIAdapter,
|
piLocalCLIAdapter,
|
||||||
cursorLocalCLIAdapter,
|
cursorLocalCLIAdapter,
|
||||||
|
geminiLocalCLIAdapter,
|
||||||
openclawGatewayCLIAdapter,
|
openclawGatewayCLIAdapter,
|
||||||
processCLIAdapter,
|
processCLIAdapter,
|
||||||
httpCLIAdapter,
|
httpCLIAdapter,
|
||||||
|
|||||||
@@ -119,6 +119,14 @@ function nonEmpty(value: string | null | undefined): string | null {
|
|||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCurrentSourceConfigPath(sourceConfigPath: string): boolean {
|
||||||
|
const currentConfigPath = process.env.PAPERCLIP_CONFIG;
|
||||||
|
if (!currentConfigPath || currentConfigPath.trim().length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveWorktreeMakeName(name: string): string {
|
function resolveWorktreeMakeName(name: string): string {
|
||||||
const value = nonEmpty(name);
|
const value = nonEmpty(name);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -440,9 +448,10 @@ export function copySeededSecretsKey(input: {
|
|||||||
|
|
||||||
mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true });
|
mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true });
|
||||||
|
|
||||||
|
const allowProcessEnvFallback = isCurrentSourceConfigPath(input.sourceConfigPath);
|
||||||
const sourceInlineMasterKey =
|
const sourceInlineMasterKey =
|
||||||
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ??
|
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ??
|
||||||
nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY);
|
(allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY) : null);
|
||||||
if (sourceInlineMasterKey) {
|
if (sourceInlineMasterKey) {
|
||||||
writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, {
|
writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
@@ -458,7 +467,7 @@ export function copySeededSecretsKey(input: {
|
|||||||
|
|
||||||
const sourceKeyFileOverride =
|
const sourceKeyFileOverride =
|
||||||
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ??
|
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ??
|
||||||
nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE);
|
(allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE) : null);
|
||||||
const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath;
|
const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath;
|
||||||
const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath);
|
const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath);
|
||||||
|
|
||||||
|
|||||||
@@ -162,4 +162,3 @@ export async function promptServer(opts?: {
|
|||||||
auth,
|
auth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
docs/adapters/gemini-local.md
Normal file
45
docs/adapters/gemini-local.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: Gemini Local
|
||||||
|
summary: Gemini CLI local adapter setup and configuration
|
||||||
|
---
|
||||||
|
|
||||||
|
The `gemini_local` adapter runs Google's Gemini CLI locally. It supports session persistence with `--resume`, skills injection, and structured `stream-json` output parsing.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Gemini CLI installed (`gemini` command available)
|
||||||
|
- `GEMINI_API_KEY` or `GOOGLE_API_KEY` set, or local Gemini CLI auth configured
|
||||||
|
|
||||||
|
## Configuration Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) |
|
||||||
|
| `model` | string | No | Gemini model to use. Defaults to `auto`. |
|
||||||
|
| `promptTemplate` | string | No | Prompt used for all runs |
|
||||||
|
| `instructionsFilePath` | string | No | Markdown instructions file prepended to the prompt |
|
||||||
|
| `env` | object | No | Environment variables (supports secret refs) |
|
||||||
|
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
|
||||||
|
| `graceSec` | number | No | Grace period before force-kill |
|
||||||
|
| `yolo` | boolean | No | Pass `--approval-mode yolo` for unattended operation |
|
||||||
|
|
||||||
|
## Session Persistence
|
||||||
|
|
||||||
|
The adapter persists Gemini session IDs between heartbeats. On the next wake, it resumes the existing conversation with `--resume` so the agent retains context.
|
||||||
|
|
||||||
|
Session resume is cwd-aware: if the working directory changed since the last run, a fresh session starts instead.
|
||||||
|
|
||||||
|
If resume fails with an unknown session error, the adapter automatically retries with a fresh session.
|
||||||
|
|
||||||
|
## Skills Injection
|
||||||
|
|
||||||
|
The adapter symlinks Paperclip skills into the Gemini global skills directory (`~/.gemini/skills`). Existing user skills are not overwritten.
|
||||||
|
|
||||||
|
## Environment Test
|
||||||
|
|
||||||
|
Use the "Test Environment" button in the UI to validate the adapter config. It checks:
|
||||||
|
|
||||||
|
- Gemini CLI is installed and accessible
|
||||||
|
- Working directory is absolute and available (auto-created if missing and permitted)
|
||||||
|
- API key/auth hints (`GEMINI_API_KEY` or `GOOGLE_API_KEY`)
|
||||||
|
- A live hello probe (`gemini --output-format json "Respond with hello."`) to verify CLI readiness
|
||||||
@@ -20,6 +20,7 @@ When a heartbeat fires, Paperclip:
|
|||||||
|---------|----------|-------------|
|
|---------|----------|-------------|
|
||||||
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
||||||
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
|
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
|
||||||
|
| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally |
|
||||||
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
|
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
|
||||||
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
|
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
|
||||||
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
||||||
@@ -54,7 +55,7 @@ Three registries consume these modules:
|
|||||||
|
|
||||||
## Choosing an Adapter
|
## Choosing an Adapter
|
||||||
|
|
||||||
- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local`
|
- **Need a coding agent?** Use `claude_local`, `codex_local`, `gemini_local`, or `opencode_local`
|
||||||
- **Need to run a script or command?** Use `process`
|
- **Need to run a script or command?** Use `process`
|
||||||
- **Need to call an external service?** Use `http`
|
- **Need to call an external service?** Use `http`
|
||||||
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export type TranscriptEntry =
|
|||||||
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
|
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
|
||||||
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
|
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
|
||||||
| { kind: "user"; ts: string; text: string }
|
| { kind: "user"; ts: string; text: string }
|
||||||
| { kind: "tool_call"; ts: string; name: string; input: unknown }
|
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
|
||||||
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
|
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
|
||||||
| { kind: "init"; ts: string; model: string; sessionId: string }
|
| { kind: "init"; ts: string; model: string; sessionId: string }
|
||||||
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
|
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry
|
|||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
ts,
|
ts,
|
||||||
name: typeof block.name === "string" ? block.name : "unknown",
|
name: typeof block.name === "string" ? block.name : "unknown",
|
||||||
|
toolUseId:
|
||||||
|
typeof block.id === "string"
|
||||||
|
? block.id
|
||||||
|
: typeof block.tool_use_id === "string"
|
||||||
|
? block.tool_use_id
|
||||||
|
: undefined,
|
||||||
input: block.input ?? {},
|
input: block.input ?? {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ function parseCommandExecutionItem(
|
|||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
ts,
|
ts,
|
||||||
name: "command_execution",
|
name: "command_execution",
|
||||||
|
toolUseId: id || command || "command_execution",
|
||||||
input: {
|
input: {
|
||||||
id,
|
id,
|
||||||
command,
|
command,
|
||||||
@@ -148,6 +149,7 @@ function parseCodexItem(
|
|||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
ts,
|
ts,
|
||||||
name: asString(item.name, "unknown"),
|
name: asString(item.name, "unknown"),
|
||||||
|
toolUseId: asString(item.id),
|
||||||
input: item.input ?? {},
|
input: item.input ?? {},
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,12 @@ function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry
|
|||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
ts,
|
ts,
|
||||||
name,
|
name,
|
||||||
|
toolUseId:
|
||||||
|
asString(part.tool_use_id) ||
|
||||||
|
asString(part.toolUseId) ||
|
||||||
|
asString(part.call_id) ||
|
||||||
|
asString(part.id) ||
|
||||||
|
undefined,
|
||||||
input,
|
input,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -199,6 +205,7 @@ function parseCursorToolCallEvent(event: Record<string, unknown>, ts: string): T
|
|||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
ts,
|
ts,
|
||||||
name: toolName,
|
name: toolName,
|
||||||
|
toolUseId: callId,
|
||||||
input,
|
input,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|||||||
51
packages/adapters/gemini-local/package.json
Normal file
51
packages/adapters/gemini-local/package.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "@paperclipai/adapter-gemini-local",
|
||||||
|
"version": "0.2.7",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./server": "./src/server/index.ts",
|
||||||
|
"./ui": "./src/ui/index.ts",
|
||||||
|
"./cli": "./src/cli/index.ts"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./server": {
|
||||||
|
"types": "./dist/server/index.d.ts",
|
||||||
|
"import": "./dist/server/index.js"
|
||||||
|
},
|
||||||
|
"./ui": {
|
||||||
|
"types": "./dist/ui/index.d.ts",
|
||||||
|
"import": "./dist/ui/index.js"
|
||||||
|
},
|
||||||
|
"./cli": {
|
||||||
|
"types": "./dist/cli/index.d.ts",
|
||||||
|
"import": "./dist/cli/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"skills"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
|
"picocolors": "^1.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
208
packages/adapters/gemini-local/src/cli/format-event.ts
Normal file
208
packages/adapters/gemini-local/src/cli/format-event.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import pc from "picocolors";
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown, fallback = ""): string {
|
||||||
|
return typeof value === "string" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumber(value: unknown, fallback = 0): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyUnknown(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
const rec = asRecord(value);
|
||||||
|
if (!rec) return "";
|
||||||
|
const msg =
|
||||||
|
(typeof rec.message === "string" && rec.message) ||
|
||||||
|
(typeof rec.error === "string" && rec.error) ||
|
||||||
|
(typeof rec.code === "string" && rec.code) ||
|
||||||
|
"";
|
||||||
|
if (msg) return msg;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(rec);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTextMessage(prefix: string, colorize: (text: string) => string, messageRaw: unknown): void {
|
||||||
|
if (typeof messageRaw === "string") {
|
||||||
|
const text = messageRaw.trim();
|
||||||
|
if (text) console.log(colorize(`${prefix}: ${text}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = asRecord(messageRaw);
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
const directText = asString(message.text).trim();
|
||||||
|
if (directText) console.log(colorize(`${prefix}: ${directText}`));
|
||||||
|
|
||||||
|
const content = Array.isArray(message.content) ? message.content : [];
|
||||||
|
for (const partRaw of content) {
|
||||||
|
const part = asRecord(partRaw);
|
||||||
|
if (!part) continue;
|
||||||
|
const type = asString(part.type).trim();
|
||||||
|
|
||||||
|
if (type === "output_text" || type === "text" || type === "content") {
|
||||||
|
const text = asString(part.text).trim() || asString(part.content).trim();
|
||||||
|
if (text) console.log(colorize(`${prefix}: ${text}`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "thinking") {
|
||||||
|
const text = asString(part.text).trim();
|
||||||
|
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tool_call") {
|
||||||
|
const name = asString(part.name, asString(part.tool, "tool"));
|
||||||
|
console.log(pc.yellow(`tool_call: ${name}`));
|
||||||
|
const input = part.input ?? part.arguments ?? part.args;
|
||||||
|
if (input !== undefined) console.log(pc.gray(stringifyUnknown(input)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tool_result" || type === "tool_response") {
|
||||||
|
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||||
|
const contentText =
|
||||||
|
asString(part.output) ||
|
||||||
|
asString(part.text) ||
|
||||||
|
asString(part.result) ||
|
||||||
|
stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response);
|
||||||
|
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||||
|
if (contentText) console.log((isError ? pc.red : pc.gray)(contentText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage(parsed: Record<string, unknown>) {
|
||||||
|
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
|
||||||
|
const usageMetadata = asRecord(usage?.usageMetadata);
|
||||||
|
const source = usageMetadata ?? usage ?? {};
|
||||||
|
const input = asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount)));
|
||||||
|
const output = asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount)));
|
||||||
|
const cached = asNumber(
|
||||||
|
source.cached_input_tokens,
|
||||||
|
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
|
||||||
|
);
|
||||||
|
const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost)));
|
||||||
|
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printGeminiStreamEvent(raw: string, _debug: boolean): void {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) return;
|
||||||
|
|
||||||
|
let parsed: Record<string, unknown> | null = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
console.log(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = asString(parsed.type);
|
||||||
|
|
||||||
|
if (type === "system") {
|
||||||
|
const subtype = asString(parsed.subtype);
|
||||||
|
if (subtype === "init") {
|
||||||
|
const sessionId =
|
||||||
|
asString(parsed.session_id) ||
|
||||||
|
asString(parsed.sessionId) ||
|
||||||
|
asString(parsed.sessionID) ||
|
||||||
|
asString(parsed.checkpoint_id);
|
||||||
|
const model = asString(parsed.model);
|
||||||
|
const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
console.log(pc.blue(`Gemini init${details ? ` (${details})` : ""}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (subtype === "error") {
|
||||||
|
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||||
|
if (text) console.log(pc.red(`error: ${text}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(pc.blue(`system: ${subtype || "event"}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "assistant") {
|
||||||
|
printTextMessage("assistant", pc.green, parsed.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "user") {
|
||||||
|
printTextMessage("user", pc.gray, parsed.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "thinking") {
|
||||||
|
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||||
|
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tool_call") {
|
||||||
|
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
||||||
|
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
|
||||||
|
const [toolName] = toolCall ? Object.keys(toolCall) : [];
|
||||||
|
if (!toolCall || !toolName) {
|
||||||
|
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||||
|
if (subtype === "started" || subtype === "start") {
|
||||||
|
console.log(pc.yellow(`tool_call: ${toolName}`));
|
||||||
|
console.log(pc.gray(stringifyUnknown(payload.args ?? payload.input ?? payload.arguments ?? payload)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||||
|
const isError =
|
||||||
|
parsed.is_error === true ||
|
||||||
|
payload.is_error === true ||
|
||||||
|
payload.error !== undefined ||
|
||||||
|
asString(payload.status).toLowerCase() === "error";
|
||||||
|
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||||
|
console.log((isError ? pc.red : pc.gray)(stringifyUnknown(payload.result ?? payload.output ?? payload.error)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "result") {
|
||||||
|
printUsage(parsed);
|
||||||
|
const subtype = asString(parsed.subtype, "result");
|
||||||
|
const isError = parsed.is_error === true;
|
||||||
|
if (subtype || isError) {
|
||||||
|
console.log((isError ? pc.red : pc.blue)(`result: subtype=${subtype} is_error=${isError ? "true" : "false"}`));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "error") {
|
||||||
|
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||||
|
if (text) console.log(pc.red(`error: ${text}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
1
packages/adapters/gemini-local/src/cli/index.ts
Normal file
1
packages/adapters/gemini-local/src/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { printGeminiStreamEvent } from "./format-event.js";
|
||||||
48
packages/adapters/gemini-local/src/index.ts
Normal file
48
packages/adapters/gemini-local/src/index.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export const type = "gemini_local";
|
||||||
|
export const label = "Gemini CLI (local)";
|
||||||
|
export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
|
||||||
|
|
||||||
|
export const models = [
|
||||||
|
{ id: DEFAULT_GEMINI_LOCAL_MODEL, label: "Auto" },
|
||||||
|
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
||||||
|
{ id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
||||||
|
{ id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
|
||||||
|
{ id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
||||||
|
{ id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const agentConfigurationDoc = `# gemini_local agent configuration
|
||||||
|
|
||||||
|
Adapter: gemini_local
|
||||||
|
|
||||||
|
Use when:
|
||||||
|
- You want Paperclip to run the Gemini CLI locally on the host machine
|
||||||
|
- You want Gemini chat sessions resumed across heartbeats with --resume
|
||||||
|
- You want Paperclip skills injected locally without polluting the global environment
|
||||||
|
|
||||||
|
Don't use when:
|
||||||
|
- You need webhook-style external invocation (use http or openclaw_gateway)
|
||||||
|
- You only need a one-shot script without an AI coding agent loop (use process)
|
||||||
|
- Gemini CLI is not installed on the machine that runs Paperclip
|
||||||
|
|
||||||
|
Core fields:
|
||||||
|
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||||
|
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||||
|
- promptTemplate (string, optional): run prompt template
|
||||||
|
- model (string, optional): Gemini model id. Defaults to auto.
|
||||||
|
- approvalMode (string, optional): "default", "auto_edit", or "yolo" (default: "default")
|
||||||
|
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
|
||||||
|
- command (string, optional): defaults to "gemini"
|
||||||
|
- extraArgs (string[], optional): additional CLI args
|
||||||
|
- env (object, optional): KEY=VALUE environment variables
|
||||||
|
|
||||||
|
Operational fields:
|
||||||
|
- timeoutSec (number, optional): run timeout in seconds
|
||||||
|
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Runs use positional prompt arguments, not stdin.
|
||||||
|
- Sessions resume with --resume when stored session cwd matches the current cwd.
|
||||||
|
- Paperclip auto-injects local skills into \`~/.gemini/skills/\` via symlinks, so the CLI can discover both credentials and skills in their natural location.
|
||||||
|
- Authentication can use GEMINI_API_KEY / GOOGLE_API_KEY or local Gemini CLI login.
|
||||||
|
`;
|
||||||
421
packages/adapters/gemini-local/src/server/execute.ts
Normal file
421
packages/adapters/gemini-local/src/server/execute.ts
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import type { Dirent } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||||
|
import {
|
||||||
|
asBoolean,
|
||||||
|
asNumber,
|
||||||
|
asString,
|
||||||
|
asStringArray,
|
||||||
|
buildPaperclipEnv,
|
||||||
|
ensureAbsoluteDirectory,
|
||||||
|
ensureCommandResolvable,
|
||||||
|
ensurePathInEnv,
|
||||||
|
parseObject,
|
||||||
|
redactEnvForLogs,
|
||||||
|
renderTemplate,
|
||||||
|
runChildProcess,
|
||||||
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||||
|
import {
|
||||||
|
describeGeminiFailure,
|
||||||
|
detectGeminiAuthRequired,
|
||||||
|
isGeminiTurnLimitResult,
|
||||||
|
isGeminiUnknownSessionError,
|
||||||
|
parseGeminiJsonl,
|
||||||
|
} from "./parse.js";
|
||||||
|
import { firstNonEmptyLine } from "./utils.js";
|
||||||
|
|
||||||
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||||
|
path.resolve(__moduleDir, "../../skills"),
|
||||||
|
path.resolve(__moduleDir, "../../../../../skills"),
|
||||||
|
];
|
||||||
|
|
||||||
|
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||||
|
const raw = env[key];
|
||||||
|
return typeof raw === "string" && raw.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||||
|
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
|
||||||
|
? "api"
|
||||||
|
: "subscription";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPaperclipEnvNote(env: Record<string, string>): string {
|
||||||
|
const paperclipKeys = Object.keys(env)
|
||||||
|
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||||
|
.sort();
|
||||||
|
if (paperclipKeys.length === 0) return "";
|
||||||
|
return [
|
||||||
|
"Paperclip runtime note:",
|
||||||
|
`The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
|
||||||
|
"Do not assume these variables are missing without checking your shell environment.",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
||||||
|
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
||||||
|
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||||
|
if (isDir) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function geminiSkillsHome(): string {
|
||||||
|
return path.join(os.homedir(), ".gemini", "skills");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Paperclip skills directly into `~/.gemini/skills/` via symlinks.
|
||||||
|
* This avoids needing GEMINI_CLI_HOME overrides, so the CLI naturally finds
|
||||||
|
* both its auth credentials and the injected skills in the real home directory.
|
||||||
|
*/
|
||||||
|
async function ensureGeminiSkillsInjected(
|
||||||
|
onLog: AdapterExecutionContext["onLog"],
|
||||||
|
): Promise<void> {
|
||||||
|
const skillsDir = await resolvePaperclipSkillsDir();
|
||||||
|
if (!skillsDir) return;
|
||||||
|
|
||||||
|
const skillsHome = geminiSkillsHome();
|
||||||
|
try {
|
||||||
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries: Dirent[];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||||
|
} catch (err) {
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const source = path.join(skillsDir, entry.name);
|
||||||
|
const target = path.join(skillsHome, entry.name);
|
||||||
|
const existing = await fs.lstat(target).catch(() => null);
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.symlink(source, target);
|
||||||
|
await onLog("stderr", `[paperclip] Linked Gemini skill: ${entry.name}\n`);
|
||||||
|
} catch (err) {
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
|
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||||
|
|
||||||
|
const promptTemplate = asString(
|
||||||
|
config.promptTemplate,
|
||||||
|
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||||
|
);
|
||||||
|
const command = asString(config.command, "gemini");
|
||||||
|
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
||||||
|
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
|
||||||
|
const sandbox = asBoolean(config.sandbox, false);
|
||||||
|
|
||||||
|
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||||
|
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||||
|
const workspaceSource = asString(workspaceContext.source, "");
|
||||||
|
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||||
|
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||||
|
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||||
|
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||||
|
? context.paperclipWorkspaces.filter(
|
||||||
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const configuredCwd = asString(config.cwd, "");
|
||||||
|
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||||
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||||
|
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||||
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||||
|
await ensureGeminiSkillsInjected(onLog);
|
||||||
|
|
||||||
|
const envConfig = parseObject(config.env);
|
||||||
|
const hasExplicitApiKey =
|
||||||
|
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||||
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||||
|
env.PAPERCLIP_RUN_ID = runId;
|
||||||
|
const wakeTaskId =
|
||||||
|
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||||
|
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||||
|
null;
|
||||||
|
const wakeReason =
|
||||||
|
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||||
|
? context.wakeReason.trim()
|
||||||
|
: null;
|
||||||
|
const wakeCommentId =
|
||||||
|
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||||
|
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||||
|
null;
|
||||||
|
const approvalId =
|
||||||
|
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||||
|
? context.approvalId.trim()
|
||||||
|
: null;
|
||||||
|
const approvalStatus =
|
||||||
|
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||||
|
? context.approvalStatus.trim()
|
||||||
|
: null;
|
||||||
|
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||||
|
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||||
|
: [];
|
||||||
|
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||||
|
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||||
|
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||||
|
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||||
|
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||||
|
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||||
|
if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||||
|
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||||
|
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||||
|
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||||
|
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||||
|
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(envConfig)) {
|
||||||
|
if (typeof value === "string") env[key] = value;
|
||||||
|
}
|
||||||
|
if (!hasExplicitApiKey && authToken) {
|
||||||
|
env.PAPERCLIP_API_KEY = authToken;
|
||||||
|
}
|
||||||
|
const billingType = resolveGeminiBillingType(env);
|
||||||
|
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||||
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
|
|
||||||
|
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||||
|
const graceSec = asNumber(config.graceSec, 20);
|
||||||
|
const extraArgs = (() => {
|
||||||
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||||
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
|
return asStringArray(config.args);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||||
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||||
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||||
|
const canResumeSession =
|
||||||
|
runtimeSessionId.length > 0 &&
|
||||||
|
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||||
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||||
|
if (runtimeSessionId && !canResumeSession) {
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||||
|
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||||
|
let instructionsPrefix = "";
|
||||||
|
if (instructionsFilePath) {
|
||||||
|
try {
|
||||||
|
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||||
|
instructionsPrefix =
|
||||||
|
`${instructionsContents}\n\n` +
|
||||||
|
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||||
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const commandNotes = (() => {
|
||||||
|
const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."];
|
||||||
|
if (approvalMode !== "default") notes.push(`Added --approval-mode ${approvalMode} for unattended execution.`);
|
||||||
|
if (!instructionsFilePath) return notes;
|
||||||
|
if (instructionsPrefix.length > 0) {
|
||||||
|
notes.push(
|
||||||
|
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||||
|
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
||||||
|
);
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
notes.push(
|
||||||
|
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||||
|
);
|
||||||
|
return notes;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||||
|
agentId: agent.id,
|
||||||
|
companyId: agent.companyId,
|
||||||
|
runId,
|
||||||
|
company: { id: agent.companyId },
|
||||||
|
agent,
|
||||||
|
run: { id: runId, source: "on_demand" },
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||||
|
const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`;
|
||||||
|
|
||||||
|
const buildArgs = (resumeSessionId: string | null) => {
|
||||||
|
const args = ["--output-format", "stream-json"];
|
||||||
|
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||||
|
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
||||||
|
if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
|
||||||
|
if (sandbox) {
|
||||||
|
args.push("--sandbox");
|
||||||
|
} else {
|
||||||
|
args.push("--sandbox=none");
|
||||||
|
}
|
||||||
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
|
args.push(prompt);
|
||||||
|
return args;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAttempt = async (resumeSessionId: string | null) => {
|
||||||
|
const args = buildArgs(resumeSessionId);
|
||||||
|
if (onMeta) {
|
||||||
|
await onMeta({
|
||||||
|
adapterType: "gemini_local",
|
||||||
|
command,
|
||||||
|
cwd,
|
||||||
|
commandNotes,
|
||||||
|
commandArgs: args.map((value, index) => (
|
||||||
|
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
|
||||||
|
)),
|
||||||
|
env: redactEnvForLogs(env),
|
||||||
|
prompt,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = await runChildProcess(runId, command, args, {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutSec,
|
||||||
|
graceSec,
|
||||||
|
onLog,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
proc,
|
||||||
|
parsed: parseGeminiJsonl(proc.stdout),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const toResult = (
|
||||||
|
attempt: {
|
||||||
|
proc: {
|
||||||
|
exitCode: number | null;
|
||||||
|
signal: string | null;
|
||||||
|
timedOut: boolean;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
parsed: ReturnType<typeof parseGeminiJsonl>;
|
||||||
|
},
|
||||||
|
clearSessionOnMissingSession = false,
|
||||||
|
isRetry = false,
|
||||||
|
): AdapterExecutionResult => {
|
||||||
|
const authMeta = detectGeminiAuthRequired({
|
||||||
|
parsed: attempt.parsed.resultEvent,
|
||||||
|
stdout: attempt.proc.stdout,
|
||||||
|
stderr: attempt.proc.stderr,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (attempt.proc.timedOut) {
|
||||||
|
return {
|
||||||
|
exitCode: attempt.proc.exitCode,
|
||||||
|
signal: attempt.proc.signal,
|
||||||
|
timedOut: true,
|
||||||
|
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||||
|
errorCode: authMeta.requiresAuth ? "gemini_auth_required" : null,
|
||||||
|
clearSession: clearSessionOnMissingSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode);
|
||||||
|
|
||||||
|
// On retry, don't fall back to old session ID — the old session was stale
|
||||||
|
const canFallbackToRuntimeSession = !isRetry;
|
||||||
|
const resolvedSessionId = attempt.parsed.sessionId
|
||||||
|
?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
|
||||||
|
const resolvedSessionParams = resolvedSessionId
|
||||||
|
? ({
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
cwd,
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||||
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||||
|
} as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||||
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||||
|
const structuredFailure = attempt.parsed.resultEvent
|
||||||
|
? describeGeminiFailure(attempt.parsed.resultEvent)
|
||||||
|
: null;
|
||||||
|
const fallbackErrorMessage =
|
||||||
|
parsedError ||
|
||||||
|
structuredFailure ||
|
||||||
|
stderrLine ||
|
||||||
|
`Gemini exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: attempt.proc.exitCode,
|
||||||
|
signal: attempt.proc.signal,
|
||||||
|
timedOut: false,
|
||||||
|
errorMessage: (attempt.proc.exitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
||||||
|
errorCode: (attempt.proc.exitCode ?? 0) !== 0 && authMeta.requiresAuth ? "gemini_auth_required" : null,
|
||||||
|
usage: attempt.parsed.usage,
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
sessionParams: resolvedSessionParams,
|
||||||
|
sessionDisplayId: resolvedSessionId,
|
||||||
|
provider: "google",
|
||||||
|
model,
|
||||||
|
billingType,
|
||||||
|
costUsd: attempt.parsed.costUsd,
|
||||||
|
resultJson: attempt.parsed.resultEvent ?? {
|
||||||
|
stdout: attempt.proc.stdout,
|
||||||
|
stderr: attempt.proc.stderr,
|
||||||
|
},
|
||||||
|
summary: attempt.parsed.summary,
|
||||||
|
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const initial = await runAttempt(sessionId);
|
||||||
|
if (
|
||||||
|
sessionId &&
|
||||||
|
!initial.proc.timedOut &&
|
||||||
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||||
|
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||||
|
) {
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||||
|
);
|
||||||
|
const retry = await runAttempt(null);
|
||||||
|
return toResult(retry, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toResult(initial);
|
||||||
|
}
|
||||||
70
packages/adapters/gemini-local/src/server/index.ts
Normal file
70
packages/adapters/gemini-local/src/server/index.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export { execute } from "./execute.js";
|
||||||
|
export { testEnvironment } from "./test.js";
|
||||||
|
export {
|
||||||
|
parseGeminiJsonl,
|
||||||
|
isGeminiUnknownSessionError,
|
||||||
|
describeGeminiFailure,
|
||||||
|
detectGeminiAuthRequired,
|
||||||
|
isGeminiTurnLimitResult,
|
||||||
|
} from "./parse.js";
|
||||||
|
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
|
function readNonEmptyString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionCodec: AdapterSessionCodec = {
|
||||||
|
deserialize(raw: unknown) {
|
||||||
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
const sessionId =
|
||||||
|
readNonEmptyString(record.sessionId) ??
|
||||||
|
readNonEmptyString(record.session_id) ??
|
||||||
|
readNonEmptyString(record.sessionID);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const cwd =
|
||||||
|
readNonEmptyString(record.cwd) ??
|
||||||
|
readNonEmptyString(record.workdir) ??
|
||||||
|
readNonEmptyString(record.folder);
|
||||||
|
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||||
|
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||||
|
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
...(cwd ? { cwd } : {}),
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
...(repoUrl ? { repoUrl } : {}),
|
||||||
|
...(repoRef ? { repoRef } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
serialize(params: Record<string, unknown> | null) {
|
||||||
|
if (!params) return null;
|
||||||
|
const sessionId =
|
||||||
|
readNonEmptyString(params.sessionId) ??
|
||||||
|
readNonEmptyString(params.session_id) ??
|
||||||
|
readNonEmptyString(params.sessionID);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const cwd =
|
||||||
|
readNonEmptyString(params.cwd) ??
|
||||||
|
readNonEmptyString(params.workdir) ??
|
||||||
|
readNonEmptyString(params.folder);
|
||||||
|
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||||
|
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||||
|
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
...(cwd ? { cwd } : {}),
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
...(repoUrl ? { repoUrl } : {}),
|
||||||
|
...(repoRef ? { repoRef } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getDisplayId(params: Record<string, unknown> | null) {
|
||||||
|
if (!params) return null;
|
||||||
|
return (
|
||||||
|
readNonEmptyString(params.sessionId) ??
|
||||||
|
readNonEmptyString(params.session_id) ??
|
||||||
|
readNonEmptyString(params.sessionID)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
242
packages/adapters/gemini-local/src/server/parse.ts
Normal file
242
packages/adapters/gemini-local/src/server/parse.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
|
||||||
|
function collectMessageText(message: unknown): string[] {
|
||||||
|
if (typeof message === "string") {
|
||||||
|
const trimmed = message.trim();
|
||||||
|
return trimmed ? [trimmed] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = parseObject(message);
|
||||||
|
const direct = asString(record.text, "").trim();
|
||||||
|
const lines: string[] = direct ? [direct] : [];
|
||||||
|
const content = Array.isArray(record.content) ? record.content : [];
|
||||||
|
|
||||||
|
for (const partRaw of content) {
|
||||||
|
const part = parseObject(partRaw);
|
||||||
|
const type = asString(part.type, "").trim();
|
||||||
|
if (type === "output_text" || type === "text" || type === "content") {
|
||||||
|
const text = asString(part.text, "").trim() || asString(part.content, "").trim();
|
||||||
|
if (text) lines.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionId(event: Record<string, unknown>): string | null {
|
||||||
|
return (
|
||||||
|
asString(event.session_id, "").trim() ||
|
||||||
|
asString(event.sessionId, "").trim() ||
|
||||||
|
asString(event.sessionID, "").trim() ||
|
||||||
|
asString(event.checkpoint_id, "").trim() ||
|
||||||
|
asString(event.thread_id, "").trim() ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asErrorText(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
const rec = parseObject(value);
|
||||||
|
const message =
|
||||||
|
asString(rec.message, "") ||
|
||||||
|
asString(rec.error, "") ||
|
||||||
|
asString(rec.code, "") ||
|
||||||
|
asString(rec.detail, "");
|
||||||
|
if (message) return message;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(rec);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function accumulateUsage(
|
||||||
|
target: { inputTokens: number; cachedInputTokens: number; outputTokens: number },
|
||||||
|
usageRaw: unknown,
|
||||||
|
) {
|
||||||
|
const usage = parseObject(usageRaw);
|
||||||
|
const usageMetadata = parseObject(usage.usageMetadata);
|
||||||
|
const source = Object.keys(usageMetadata).length > 0 ? usageMetadata : usage;
|
||||||
|
|
||||||
|
target.inputTokens += asNumber(
|
||||||
|
source.input_tokens,
|
||||||
|
asNumber(source.inputTokens, asNumber(source.promptTokenCount, 0)),
|
||||||
|
);
|
||||||
|
target.cachedInputTokens += asNumber(
|
||||||
|
source.cached_input_tokens,
|
||||||
|
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)),
|
||||||
|
);
|
||||||
|
target.outputTokens += asNumber(
|
||||||
|
source.output_tokens,
|
||||||
|
asNumber(source.outputTokens, asNumber(source.candidatesTokenCount, 0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGeminiJsonl(stdout: string) {
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
const messages: string[] = [];
|
||||||
|
let errorMessage: string | null = null;
|
||||||
|
let costUsd: number | null = null;
|
||||||
|
let resultEvent: Record<string, unknown> | null = null;
|
||||||
|
const usage = {
|
||||||
|
inputTokens: 0,
|
||||||
|
cachedInputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
const event = parseJson(line);
|
||||||
|
if (!event) continue;
|
||||||
|
|
||||||
|
const foundSessionId = readSessionId(event);
|
||||||
|
if (foundSessionId) sessionId = foundSessionId;
|
||||||
|
|
||||||
|
const type = asString(event.type, "").trim();
|
||||||
|
|
||||||
|
if (type === "assistant") {
|
||||||
|
messages.push(...collectMessageText(event.message));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "result") {
|
||||||
|
resultEvent = event;
|
||||||
|
accumulateUsage(usage, event.usage ?? event.usageMetadata);
|
||||||
|
const resultText =
|
||||||
|
asString(event.result, "").trim() ||
|
||||||
|
asString(event.text, "").trim() ||
|
||||||
|
asString(event.response, "").trim();
|
||||||
|
if (resultText && messages.length === 0) messages.push(resultText);
|
||||||
|
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
|
||||||
|
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
|
||||||
|
if (isError) {
|
||||||
|
const text = asErrorText(event.error ?? event.message ?? event.result).trim();
|
||||||
|
if (text) errorMessage = text;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "error") {
|
||||||
|
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
|
||||||
|
if (text) errorMessage = text;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "system") {
|
||||||
|
const subtype = asString(event.subtype, "").trim().toLowerCase();
|
||||||
|
if (subtype === "error") {
|
||||||
|
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
|
||||||
|
if (text) errorMessage = text;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "text") {
|
||||||
|
const part = parseObject(event.part);
|
||||||
|
const text = asString(part.text, "").trim();
|
||||||
|
if (text) messages.push(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "step_finish" || event.usage || event.usageMetadata) {
|
||||||
|
accumulateUsage(usage, event.usage ?? event.usageMetadata);
|
||||||
|
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
summary: messages.join("\n\n").trim(),
|
||||||
|
usage,
|
||||||
|
costUsd,
|
||||||
|
errorMessage,
|
||||||
|
resultEvent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGeminiUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||||
|
const haystack = `${stdout}\n${stderr}`
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume/i.test(
|
||||||
|
haystack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGeminiErrorMessages(parsed: Record<string, unknown>): string[] {
|
||||||
|
const messages: string[] = [];
|
||||||
|
const errorMsg = asString(parsed.error, "").trim();
|
||||||
|
if (errorMsg) messages.push(errorMsg);
|
||||||
|
|
||||||
|
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
const msg = entry.trim();
|
||||||
|
if (msg) messages.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
||||||
|
const obj = entry as Record<string, unknown>;
|
||||||
|
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
|
||||||
|
if (msg) {
|
||||||
|
messages.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
messages.push(JSON.stringify(obj));
|
||||||
|
} catch {
|
||||||
|
// skip non-serializable entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeGeminiFailure(parsed: Record<string, unknown>): string | null {
|
||||||
|
const status = asString(parsed.status, "");
|
||||||
|
const errors = extractGeminiErrorMessages(parsed);
|
||||||
|
|
||||||
|
const detail = errors[0] ?? "";
|
||||||
|
const parts = ["Gemini run failed"];
|
||||||
|
if (status) parts.push(`status=${status}`);
|
||||||
|
if (detail) parts.push(detail);
|
||||||
|
return parts.length > 1 ? parts.join(": ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i;
|
||||||
|
|
||||||
|
export function detectGeminiAuthRequired(input: {
|
||||||
|
parsed: Record<string, unknown> | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}): { requiresAuth: boolean } {
|
||||||
|
const errors = extractGeminiErrorMessages(input.parsed ?? {});
|
||||||
|
const messages = [...errors, input.stdout, input.stderr]
|
||||||
|
.join("\n")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const requiresAuth = messages.some((line) => GEMINI_AUTH_REQUIRED_RE.test(line));
|
||||||
|
return { requiresAuth };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGeminiTurnLimitResult(
|
||||||
|
parsed: Record<string, unknown> | null | undefined,
|
||||||
|
exitCode?: number | null,
|
||||||
|
): boolean {
|
||||||
|
if (exitCode === 53) return true;
|
||||||
|
if (!parsed) return false;
|
||||||
|
|
||||||
|
const status = asString(parsed.status, "").trim().toLowerCase();
|
||||||
|
if (status === "turn_limit" || status === "max_turns") return true;
|
||||||
|
|
||||||
|
const error = asString(parsed.error, "").trim();
|
||||||
|
return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error);
|
||||||
|
}
|
||||||
223
packages/adapters/gemini-local/src/server/test.ts
Normal file
223
packages/adapters/gemini-local/src/server/test.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import type {
|
||||||
|
AdapterEnvironmentCheck,
|
||||||
|
AdapterEnvironmentTestContext,
|
||||||
|
AdapterEnvironmentTestResult,
|
||||||
|
} from "@paperclipai/adapter-utils";
|
||||||
|
import {
|
||||||
|
asBoolean,
|
||||||
|
asString,
|
||||||
|
asStringArray,
|
||||||
|
ensureAbsoluteDirectory,
|
||||||
|
ensureCommandResolvable,
|
||||||
|
ensurePathInEnv,
|
||||||
|
parseObject,
|
||||||
|
runChildProcess,
|
||||||
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||||
|
import { detectGeminiAuthRequired, parseGeminiJsonl } from "./parse.js";
|
||||||
|
import { firstNonEmptyLine } from "./utils.js";
|
||||||
|
|
||||||
|
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||||
|
if (checks.some((check) => check.level === "error")) return "fail";
|
||||||
|
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||||
|
return "pass";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonEmpty(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandLooksLike(command: string, expected: string): boolean {
|
||||||
|
const base = path.basename(command).toLowerCase();
|
||||||
|
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||||
|
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||||
|
if (!raw) return null;
|
||||||
|
const clean = raw.replace(/\s+/g, " ").trim();
|
||||||
|
const max = 240;
|
||||||
|
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testEnvironment(
|
||||||
|
ctx: AdapterEnvironmentTestContext,
|
||||||
|
): Promise<AdapterEnvironmentTestResult> {
|
||||||
|
const checks: AdapterEnvironmentCheck[] = [];
|
||||||
|
const config = parseObject(ctx.config);
|
||||||
|
const command = asString(config.command, "gemini");
|
||||||
|
const cwd = asString(config.cwd, process.cwd());
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||||
|
checks.push({
|
||||||
|
code: "gemini_cwd_valid",
|
||||||
|
level: "info",
|
||||||
|
message: `Working directory is valid: ${cwd}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
checks.push({
|
||||||
|
code: "gemini_cwd_invalid",
|
||||||
|
level: "error",
|
||||||
|
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||||
|
detail: cwd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const envConfig = parseObject(config.env);
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(envConfig)) {
|
||||||
|
if (typeof value === "string") env[key] = value;
|
||||||
|
}
|
||||||
|
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||||
|
try {
|
||||||
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
|
checks.push({
|
||||||
|
code: "gemini_command_resolvable",
|
||||||
|
level: "info",
|
||||||
|
message: `Command is executable: ${command}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
checks.push({
|
||||||
|
code: "gemini_command_unresolvable",
|
||||||
|
level: "error",
|
||||||
|
message: err instanceof Error ? err.message : "Command is not executable",
|
||||||
|
detail: command,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const configGeminiApiKey = env.GEMINI_API_KEY;
|
||||||
|
const hostGeminiApiKey = process.env.GEMINI_API_KEY;
|
||||||
|
const configGoogleApiKey = env.GOOGLE_API_KEY;
|
||||||
|
const hostGoogleApiKey = process.env.GOOGLE_API_KEY;
|
||||||
|
const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true";
|
||||||
|
if (
|
||||||
|
isNonEmpty(configGeminiApiKey) ||
|
||||||
|
isNonEmpty(hostGeminiApiKey) ||
|
||||||
|
isNonEmpty(configGoogleApiKey) ||
|
||||||
|
isNonEmpty(hostGoogleApiKey) ||
|
||||||
|
hasGca
|
||||||
|
) {
|
||||||
|
const source = hasGca
|
||||||
|
? "Google account login (GCA)"
|
||||||
|
: isNonEmpty(configGeminiApiKey) || isNonEmpty(configGoogleApiKey)
|
||||||
|
? "adapter config env"
|
||||||
|
: "server environment";
|
||||||
|
checks.push({
|
||||||
|
code: "gemini_api_key_present",
|
||||||
|
level: "info",
|
||||||
|
message: "Gemini API credentials are set for CLI authentication.",
|
||||||
|
detail: `Detected in ${source}.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "gemini_api_key_missing",
|
||||||
|
level: "info",
|
||||||
|
message: "No explicit API key detected. Gemini CLI may still authenticate via `gemini auth login` (OAuth).",
|
||||||
|
hint: "If the hello probe fails with an auth error, set GEMINI_API_KEY or GOOGLE_API_KEY in adapter env, or run `gemini auth login`.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const canRunProbe =
|
||||||
|
checks.every((check) => check.code !== "gemini_cwd_invalid" && check.code !== "gemini_command_unresolvable");
|
||||||
|
if (canRunProbe) {
|
||||||
|
if (!commandLooksLike(command, "gemini")) {
|
||||||
|
checks.push({
|
||||||
|
code: "gemini_hello_probe_skipped_custom_command",
|
||||||
|
level: "info",
|
||||||
|
message: "Skipped hello probe because command is not `gemini`.",
|
||||||
|
detail: command,
|
||||||
|
hint: "Use the `gemini` CLI command to run the automatic installation and auth probe.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
||||||
|
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
|
||||||
|
const sandbox = asBoolean(config.sandbox, false);
|
||||||
|
const extraArgs = (() => {
|
||||||
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||||
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
|
return asStringArray(config.args);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const args = ["--output-format", "stream-json"];
|
||||||
|
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
||||||
|
if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
|
||||||
|
if (sandbox) {
|
||||||
|
args.push("--sandbox");
|
||||||
|
} else {
|
||||||
|
args.push("--sandbox=none");
|
||||||
|
}
|
||||||
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
|
args.push("Respond with hello.");
|
||||||
|
|
||||||
|
const probe = await runChildProcess(
|
||||||
|
`gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutSec: 45,
|
||||||
|
graceSec: 5,
|
||||||
|
onLog: async () => { },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const parsed = parseGeminiJsonl(probe.stdout);
|
||||||
|
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||||
|
const authMeta = detectGeminiAuthRequired({
|
||||||
|
parsed: parsed.resultEvent,
|
||||||
|
stdout: probe.stdout,
|
||||||
|
stderr: probe.stderr,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (probe.timedOut) {
|
||||||
|
checks.push({
|
||||||
|
code: "gemini_hello_probe_timed_out",
|
||||||
|
level: "warn",
|
||||||
|
message: "Gemini hello probe timed out.",
|
||||||
|
hint: "Retry the probe. If this persists, verify Gemini can run `Respond with hello.` from this directory manually.",
|
||||||
|
});
|
||||||
|
} else if ((probe.exitCode ?? 1) === 0) {
|
||||||
|
const summary = parsed.summary.trim();
|
||||||
|
const hasHello = /\bhello\b/i.test(summary);
|
||||||
|
checks.push({
|
||||||
|
code: hasHello ? "gemini_hello_probe_passed" : "gemini_hello_probe_unexpected_output",
|
||||||
|
level: hasHello ? "info" : "warn",
|
||||||
|
message: hasHello
|
||||||
|
? "Gemini hello probe succeeded."
|
||||||
|
: "Gemini probe ran but did not return `hello` as expected.",
|
||||||
|
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||||
|
...(hasHello
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
hint: "Try `gemini --output-format json \"Respond with hello.\"` manually to inspect full output.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else if (authMeta.requiresAuth) {
|
||||||
|
checks.push({
|
||||||
|
code: "gemini_hello_probe_auth_required",
|
||||||
|
level: "warn",
|
||||||
|
message: "Gemini CLI is installed, but authentication is not ready.",
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
hint: "Run `gemini auth` or configure GEMINI_API_KEY / GOOGLE_API_KEY in adapter env/shell, then retry the probe.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "gemini_hello_probe_failed",
|
||||||
|
level: "error",
|
||||||
|
message: "Gemini hello probe failed.",
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
hint: "Run `gemini --output-format json \"Respond with hello.\"` manually in this working directory to debug.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
adapterType: ctx.adapterType,
|
||||||
|
status: summarizeStatus(checks),
|
||||||
|
checks,
|
||||||
|
testedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
8
packages/adapters/gemini-local/src/server/utils.ts
Normal file
8
packages/adapters/gemini-local/src/server/utils.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export function firstNonEmptyLine(text: string): string {
|
||||||
|
return (
|
||||||
|
text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean) ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
75
packages/adapters/gemini-local/src/ui/build-config.ts
Normal file
75
packages/adapters/gemini-local/src/ui/build-config.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||||
|
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||||
|
|
||||||
|
function parseCommaArgs(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvVars(text: string): Record<string, string> {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
const value = trimmed.slice(eq + 1);
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||||
|
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||||
|
const env: Record<string, unknown> = {};
|
||||||
|
for (const [key, raw] of Object.entries(bindings)) {
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
env[key] = { type: "plain", value: raw };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||||
|
const rec = raw as Record<string, unknown>;
|
||||||
|
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||||
|
env[key] = { type: "plain", value: rec.value };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||||
|
env[key] = {
|
||||||
|
type: "secret_ref",
|
||||||
|
secretId: rec.secretId,
|
||||||
|
...(typeof rec.version === "number" || rec.version === "latest"
|
||||||
|
? { version: rec.version }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||||
|
const ac: Record<string, unknown> = {};
|
||||||
|
if (v.cwd) ac.cwd = v.cwd;
|
||||||
|
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||||
|
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||||
|
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
|
||||||
|
ac.timeoutSec = 0;
|
||||||
|
ac.graceSec = 15;
|
||||||
|
const env = parseEnvBindings(v.envBindings);
|
||||||
|
const legacy = parseEnvVars(v.envVars);
|
||||||
|
for (const [key, value] of Object.entries(legacy)) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||||
|
env[key] = { type: "plain", value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(env).length > 0) ac.env = env;
|
||||||
|
if (v.dangerouslyBypassSandbox) ac.approvalMode = "yolo";
|
||||||
|
ac.sandbox = !v.dangerouslyBypassSandbox;
|
||||||
|
if (v.command) ac.command = v.command;
|
||||||
|
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||||
|
return ac;
|
||||||
|
}
|
||||||
2
packages/adapters/gemini-local/src/ui/index.ts
Normal file
2
packages/adapters/gemini-local/src/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { parseGeminiStdoutLine } from "./parse-stdout.js";
|
||||||
|
export { buildGeminiLocalConfig } from "./build-config.js";
|
||||||
274
packages/adapters/gemini-local/src/ui/parse-stdout.ts
Normal file
274
packages/adapters/gemini-local/src/ui/parse-stdout.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
|
function safeJsonParse(text: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown, fallback = ""): string {
|
||||||
|
return typeof value === "string" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumber(value: unknown, fallback = 0): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyUnknown(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
const rec = asRecord(value);
|
||||||
|
if (!rec) return "";
|
||||||
|
const msg =
|
||||||
|
(typeof rec.message === "string" && rec.message) ||
|
||||||
|
(typeof rec.error === "string" && rec.error) ||
|
||||||
|
(typeof rec.code === "string" && rec.code) ||
|
||||||
|
"";
|
||||||
|
if (msg) return msg;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(rec);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTextEntries(messageRaw: unknown, ts: string, kind: "assistant" | "user"): TranscriptEntry[] {
|
||||||
|
if (typeof messageRaw === "string") {
|
||||||
|
const text = messageRaw.trim();
|
||||||
|
return text ? [{ kind, ts, text }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = asRecord(messageRaw);
|
||||||
|
if (!message) return [];
|
||||||
|
|
||||||
|
const entries: TranscriptEntry[] = [];
|
||||||
|
const directText = asString(message.text).trim();
|
||||||
|
if (directText) entries.push({ kind, ts, text: directText });
|
||||||
|
|
||||||
|
const content = Array.isArray(message.content) ? message.content : [];
|
||||||
|
for (const partRaw of content) {
|
||||||
|
const part = asRecord(partRaw);
|
||||||
|
if (!part) continue;
|
||||||
|
const type = asString(part.type).trim();
|
||||||
|
if (type !== "output_text" && type !== "text" && type !== "content") continue;
|
||||||
|
const text = asString(part.text).trim() || asString(part.content).trim();
|
||||||
|
if (text) entries.push({ kind, ts, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
||||||
|
if (typeof messageRaw === "string") {
|
||||||
|
const text = messageRaw.trim();
|
||||||
|
return text ? [{ kind: "assistant", ts, text }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = asRecord(messageRaw);
|
||||||
|
if (!message) return [];
|
||||||
|
|
||||||
|
const entries: TranscriptEntry[] = [];
|
||||||
|
const directText = asString(message.text).trim();
|
||||||
|
if (directText) entries.push({ kind: "assistant", ts, text: directText });
|
||||||
|
|
||||||
|
const content = Array.isArray(message.content) ? message.content : [];
|
||||||
|
for (const partRaw of content) {
|
||||||
|
const part = asRecord(partRaw);
|
||||||
|
if (!part) continue;
|
||||||
|
const type = asString(part.type).trim();
|
||||||
|
|
||||||
|
if (type === "output_text" || type === "text" || type === "content") {
|
||||||
|
const text = asString(part.text).trim() || asString(part.content).trim();
|
||||||
|
if (text) entries.push({ kind: "assistant", ts, text });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "thinking") {
|
||||||
|
const text = asString(part.text).trim();
|
||||||
|
if (text) entries.push({ kind: "thinking", ts, text });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tool_call") {
|
||||||
|
const name = asString(part.name, asString(part.tool, "tool"));
|
||||||
|
entries.push({
|
||||||
|
kind: "tool_call",
|
||||||
|
ts,
|
||||||
|
name,
|
||||||
|
input: part.input ?? part.arguments ?? part.args ?? {},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tool_result" || type === "tool_response") {
|
||||||
|
const toolUseId =
|
||||||
|
asString(part.tool_use_id) ||
|
||||||
|
asString(part.toolUseId) ||
|
||||||
|
asString(part.call_id) ||
|
||||||
|
asString(part.id) ||
|
||||||
|
"tool_result";
|
||||||
|
const contentText =
|
||||||
|
asString(part.output) ||
|
||||||
|
asString(part.text) ||
|
||||||
|
asString(part.result) ||
|
||||||
|
stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response);
|
||||||
|
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||||
|
entries.push({
|
||||||
|
kind: "tool_result",
|
||||||
|
ts,
|
||||||
|
toolUseId,
|
||||||
|
content: contentText,
|
||||||
|
isError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTopLevelToolEvent(parsed: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||||
|
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
||||||
|
const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "tool_call")));
|
||||||
|
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
|
||||||
|
if (!toolCall) {
|
||||||
|
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [toolName] = Object.keys(toolCall);
|
||||||
|
if (!toolName) {
|
||||||
|
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
||||||
|
}
|
||||||
|
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||||
|
|
||||||
|
if (subtype === "started" || subtype === "start") {
|
||||||
|
return [{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts,
|
||||||
|
name: toolName,
|
||||||
|
input: payload.args ?? payload.input ?? payload.arguments ?? payload,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||||
|
const result = payload.result ?? payload.output ?? payload.error;
|
||||||
|
const isError =
|
||||||
|
parsed.is_error === true ||
|
||||||
|
payload.is_error === true ||
|
||||||
|
payload.error !== undefined ||
|
||||||
|
asString(payload.status).toLowerCase() === "error";
|
||||||
|
return [{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts,
|
||||||
|
toolUseId: callId,
|
||||||
|
content: result !== undefined ? stringifyUnknown(result) : `${toolName} completed`,
|
||||||
|
isError,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}` }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionId(parsed: Record<string, unknown>): string {
|
||||||
|
return (
|
||||||
|
asString(parsed.session_id) ||
|
||||||
|
asString(parsed.sessionId) ||
|
||||||
|
asString(parsed.sessionID) ||
|
||||||
|
asString(parsed.checkpoint_id) ||
|
||||||
|
asString(parsed.thread_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUsage(parsed: Record<string, unknown>) {
|
||||||
|
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
|
||||||
|
const usageMetadata = asRecord(usage?.usageMetadata);
|
||||||
|
const source = usageMetadata ?? usage ?? {};
|
||||||
|
return {
|
||||||
|
inputTokens: asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount))),
|
||||||
|
outputTokens: asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))),
|
||||||
|
cachedTokens: asNumber(
|
||||||
|
source.cached_input_tokens,
|
||||||
|
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
|
const parsed = asRecord(safeJsonParse(line));
|
||||||
|
if (!parsed) {
|
||||||
|
return [{ kind: "stdout", ts, text: line }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = asString(parsed.type);
|
||||||
|
|
||||||
|
if (type === "system") {
|
||||||
|
const subtype = asString(parsed.subtype);
|
||||||
|
if (subtype === "init") {
|
||||||
|
const sessionId = readSessionId(parsed);
|
||||||
|
return [{ kind: "init", ts, model: asString(parsed.model, "gemini"), sessionId }];
|
||||||
|
}
|
||||||
|
if (subtype === "error") {
|
||||||
|
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||||
|
return [{ kind: "stderr", ts, text: text || "error" }];
|
||||||
|
}
|
||||||
|
return [{ kind: "system", ts, text: `system: ${subtype || "event"}` }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "assistant") {
|
||||||
|
return parseAssistantMessage(parsed.message, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "user") {
|
||||||
|
return collectTextEntries(parsed.message, ts, "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "thinking") {
|
||||||
|
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||||
|
return text ? [{ kind: "thinking", ts, text }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tool_call") {
|
||||||
|
return parseTopLevelToolEvent(parsed, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "result") {
|
||||||
|
const usage = readUsage(parsed);
|
||||||
|
const errors = parsed.is_error === true
|
||||||
|
? [errorText(parsed.error ?? parsed.message ?? parsed.result)].filter(Boolean)
|
||||||
|
: [];
|
||||||
|
return [{
|
||||||
|
kind: "result",
|
||||||
|
ts,
|
||||||
|
text: asString(parsed.result) || asString(parsed.text) || asString(parsed.response),
|
||||||
|
inputTokens: usage.inputTokens,
|
||||||
|
outputTokens: usage.outputTokens,
|
||||||
|
cachedTokens: usage.cachedTokens,
|
||||||
|
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
|
||||||
|
subtype: asString(parsed.subtype, "result"),
|
||||||
|
isError: parsed.is_error === true,
|
||||||
|
errors,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "error") {
|
||||||
|
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||||
|
return [{ kind: "stderr", ts, text: text || "error" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ kind: "stdout", ts, text: line }];
|
||||||
|
}
|
||||||
8
packages/adapters/gemini-local/tsconfig.json
Normal file
8
packages/adapters/gemini-local/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -1069,7 +1069,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
|
|
||||||
const agentParams: Record<string, unknown> = {
|
const agentParams: Record<string, unknown> = {
|
||||||
...payloadTemplate,
|
...payloadTemplate,
|
||||||
paperclip: paperclipPayload,
|
|
||||||
message,
|
message,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
idempotencyKey: ctx.runId,
|
idempotencyKey: ctx.runId,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ function parseToolUse(parsed: Record<string, unknown>, ts: string): TranscriptEn
|
|||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
ts,
|
ts,
|
||||||
name: toolName,
|
name: toolName,
|
||||||
|
toolUseId: asString(part.callID) || asString(part.id) || undefined,
|
||||||
input,
|
input,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ describe("resolveDatabaseTarget", () => {
|
|||||||
const projectDir = path.join(tempDir, "repo");
|
const projectDir = path.join(tempDir, "repo");
|
||||||
fs.mkdirSync(projectDir, { recursive: true });
|
fs.mkdirSync(projectDir, { recursive: true });
|
||||||
process.chdir(projectDir);
|
process.chdir(projectDir);
|
||||||
|
delete process.env.PAPERCLIP_CONFIG;
|
||||||
writeJson(path.join(projectDir, ".paperclip", "config.json"), {
|
writeJson(path.join(projectDir, ".paperclip", "config.json"), {
|
||||||
database: { mode: "embedded-postgres", embeddedPostgresPort: 54329 },
|
database: { mode: "embedded-postgres", embeddedPostgresPort: 54329 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const AGENT_ADAPTER_TYPES = [
|
|||||||
"http",
|
"http",
|
||||||
"claude_local",
|
"claude_local",
|
||||||
"codex_local",
|
"codex_local",
|
||||||
|
"gemini_local",
|
||||||
"opencode_local",
|
"opencode_local",
|
||||||
"pi_local",
|
"pi_local",
|
||||||
"cursor",
|
"cursor",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
|
"@paperclipai/adapter-gemini-local": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
sessionCodec as cursorSessionCodec,
|
sessionCodec as cursorSessionCodec,
|
||||||
isCursorUnknownSessionError,
|
isCursorUnknownSessionError,
|
||||||
} from "@paperclipai/adapter-cursor-local/server";
|
} from "@paperclipai/adapter-cursor-local/server";
|
||||||
|
import {
|
||||||
|
sessionCodec as geminiSessionCodec,
|
||||||
|
isGeminiUnknownSessionError,
|
||||||
|
} from "@paperclipai/adapter-gemini-local/server";
|
||||||
import {
|
import {
|
||||||
sessionCodec as opencodeSessionCodec,
|
sessionCodec as opencodeSessionCodec,
|
||||||
isOpenCodeUnknownSessionError,
|
isOpenCodeUnknownSessionError,
|
||||||
@@ -82,6 +86,24 @@ describe("adapter session codecs", () => {
|
|||||||
});
|
});
|
||||||
expect(cursorSessionCodec.getDisplayId?.(serialized ?? null)).toBe("cursor-session-1");
|
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", () => {
|
describe("codex resume recovery detection", () => {
|
||||||
@@ -146,3 +168,26 @@ describe("cursor resume recovery detection", () => {
|
|||||||
).toBe(false);
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ describe("codex_local ui stdout parser", () => {
|
|||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
ts,
|
ts,
|
||||||
name: "command_execution",
|
name: "command_execution",
|
||||||
|
toolUseId: "item_2",
|
||||||
input: { id: "item_2", command: "/bin/zsh -lc ls" },
|
input: { id: "item_2", command: "/bin/zsh -lc ls" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ describe("cursor ui stdout parser", () => {
|
|||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
ts,
|
ts,
|
||||||
name: "shellToolCall",
|
name: "shellToolCall",
|
||||||
|
toolUseId: "call_shell_1",
|
||||||
input: { command: longCommand },
|
input: { command: longCommand },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -254,7 +255,7 @@ describe("cursor ui stdout parser", () => {
|
|||||||
}),
|
}),
|
||||||
ts,
|
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(
|
expect(
|
||||||
parseCursorStdoutLine(
|
parseCursorStdoutLine(
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
158
server/src/__tests__/gemini-local-adapter.test.ts
Normal file
158
server/src/__tests__/gemini-local-adapter.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
124
server/src/__tests__/gemini-local-execute.test.ts
Normal file
124
server/src/__tests__/gemini-local-execute.test.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -456,33 +456,6 @@ describe("openclaw gateway adapter execute", () => {
|
|||||||
expect(String(payload?.message ?? "")).toContain("wake now");
|
expect(String(payload?.message ?? "")).toContain("wake now");
|
||||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
||||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-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);
|
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ describe("opencode_local ui stdout parser", () => {
|
|||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
ts,
|
ts,
|
||||||
name: "bash",
|
name: "bash",
|
||||||
|
toolUseId: "call_1",
|
||||||
input: { command: "ls -1" },
|
input: { command: "ls -1" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ import {
|
|||||||
sessionCodec as cursorSessionCodec,
|
sessionCodec as cursorSessionCodec,
|
||||||
} from "@paperclipai/adapter-cursor-local/server";
|
} from "@paperclipai/adapter-cursor-local/server";
|
||||||
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
|
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
|
||||||
|
import {
|
||||||
|
execute as geminiExecute,
|
||||||
|
testEnvironment as geminiTestEnvironment,
|
||||||
|
sessionCodec as geminiSessionCodec,
|
||||||
|
} from "@paperclipai/adapter-gemini-local/server";
|
||||||
|
import { agentConfigurationDoc as geminiAgentConfigurationDoc, models as geminiModels } from "@paperclipai/adapter-gemini-local";
|
||||||
import {
|
import {
|
||||||
execute as openCodeExecute,
|
execute as openCodeExecute,
|
||||||
testEnvironment as openCodeTestEnvironment,
|
testEnvironment as openCodeTestEnvironment,
|
||||||
@@ -80,6 +86,16 @@ const cursorLocalAdapter: ServerAdapterModule = {
|
|||||||
agentConfigurationDoc: cursorAgentConfigurationDoc,
|
agentConfigurationDoc: cursorAgentConfigurationDoc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const geminiLocalAdapter: ServerAdapterModule = {
|
||||||
|
type: "gemini_local",
|
||||||
|
execute: geminiExecute,
|
||||||
|
testEnvironment: geminiTestEnvironment,
|
||||||
|
sessionCodec: geminiSessionCodec,
|
||||||
|
models: geminiModels,
|
||||||
|
supportsLocalAgentJwt: true,
|
||||||
|
agentConfigurationDoc: geminiAgentConfigurationDoc,
|
||||||
|
};
|
||||||
|
|
||||||
const openclawGatewayAdapter: ServerAdapterModule = {
|
const openclawGatewayAdapter: ServerAdapterModule = {
|
||||||
type: "openclaw_gateway",
|
type: "openclaw_gateway",
|
||||||
execute: openclawGatewayExecute,
|
execute: openclawGatewayExecute,
|
||||||
@@ -118,6 +134,7 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
|
|||||||
openCodeLocalAdapter,
|
openCodeLocalAdapter,
|
||||||
piLocalAdapter,
|
piLocalAdapter,
|
||||||
cursorLocalAdapter,
|
cursorLocalAdapter,
|
||||||
|
geminiLocalAdapter,
|
||||||
openclawGatewayAdapter,
|
openclawGatewayAdapter,
|
||||||
processAdapter,
|
processAdapter,
|
||||||
httpAdapter,
|
httpAdapter,
|
||||||
|
|||||||
@@ -37,12 +37,14 @@ import {
|
|||||||
DEFAULT_CODEX_LOCAL_MODEL,
|
DEFAULT_CODEX_LOCAL_MODEL,
|
||||||
} from "@paperclipai/adapter-codex-local";
|
} from "@paperclipai/adapter-codex-local";
|
||||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||||
|
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
|
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
|
||||||
|
|
||||||
export function agentRoutes(db: Db) {
|
export function agentRoutes(db: Db) {
|
||||||
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
||||||
claude_local: "instructionsFilePath",
|
claude_local: "instructionsFilePath",
|
||||||
codex_local: "instructionsFilePath",
|
codex_local: "instructionsFilePath",
|
||||||
|
gemini_local: "instructionsFilePath",
|
||||||
opencode_local: "instructionsFilePath",
|
opencode_local: "instructionsFilePath",
|
||||||
cursor: "instructionsFilePath",
|
cursor: "instructionsFilePath",
|
||||||
};
|
};
|
||||||
@@ -232,6 +234,10 @@ export function agentRoutes(db: Db) {
|
|||||||
}
|
}
|
||||||
return ensureGatewayDeviceKey(adapterType, next);
|
return ensureGatewayDeviceKey(adapterType, next);
|
||||||
}
|
}
|
||||||
|
if (adapterType === "gemini_local" && !asNonEmptyString(next.model)) {
|
||||||
|
next.model = DEFAULT_GEMINI_LOCAL_MODEL;
|
||||||
|
return ensureGatewayDeviceKey(adapterType, next);
|
||||||
|
}
|
||||||
// OpenCode requires explicit model selection — no default
|
// OpenCode requires explicit model selection — no default
|
||||||
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
||||||
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
|
|||||||
{ path: ["timeoutSec"], value: 0 },
|
{ path: ["timeoutSec"], value: 0 },
|
||||||
{ path: ["graceSec"], value: 15 },
|
{ path: ["graceSec"], value: 15 },
|
||||||
],
|
],
|
||||||
|
gemini_local: [
|
||||||
|
{ path: ["timeoutSec"], value: 0 },
|
||||||
|
{ path: ["graceSec"], value: 15 },
|
||||||
|
],
|
||||||
opencode_local: [
|
opencode_local: [
|
||||||
{ path: ["timeoutSec"], value: 0 },
|
{ path: ["timeoutSec"], value: 0 },
|
||||||
{ path: ["graceSec"], value: 15 },
|
{ path: ["graceSec"], value: 15 },
|
||||||
|
|||||||
@@ -1368,12 +1368,13 @@ export function heartbeatService(db: Db) {
|
|||||||
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
||||||
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, chunk);
|
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, chunk);
|
||||||
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, chunk);
|
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, chunk);
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
|
||||||
if (handle) {
|
if (handle) {
|
||||||
await runLogStore.append(handle, {
|
await runLogStore.append(handle, {
|
||||||
stream,
|
stream,
|
||||||
chunk,
|
chunk,
|
||||||
ts: new Date().toISOString(),
|
ts,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1388,6 +1389,7 @@ export function heartbeatService(db: Db) {
|
|||||||
payload: {
|
payload: {
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
agentId: run.agentId,
|
agentId: run.agentId,
|
||||||
|
ts,
|
||||||
stream,
|
stream,
|
||||||
chunk: payloadChunk,
|
chunk: payloadChunk,
|
||||||
truncated: payloadChunk.length !== chunk.length,
|
truncated: payloadChunk.length !== chunk.length,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
|
"@paperclipai/adapter-gemini-local": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { Activity } from "./pages/Activity";
|
|||||||
import { Inbox } from "./pages/Inbox";
|
import { Inbox } from "./pages/Inbox";
|
||||||
import { CompanySettings } from "./pages/CompanySettings";
|
import { CompanySettings } from "./pages/CompanySettings";
|
||||||
import { DesignGuide } from "./pages/DesignGuide";
|
import { DesignGuide } from "./pages/DesignGuide";
|
||||||
|
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
|
||||||
import { OrgChart } from "./pages/OrgChart";
|
import { OrgChart } from "./pages/OrgChart";
|
||||||
import { NewAgent } from "./pages/NewAgent";
|
import { NewAgent } from "./pages/NewAgent";
|
||||||
import { AuthPage } from "./pages/Auth";
|
import { AuthPage } from "./pages/Auth";
|
||||||
@@ -145,6 +146,7 @@ function boardRoutes() {
|
|||||||
<Route path="inbox/all" element={<Inbox />} />
|
<Route path="inbox/all" element={<Inbox />} />
|
||||||
<Route path="inbox/new" element={<Navigate to="/inbox/recent" replace />} />
|
<Route path="inbox/new" element={<Navigate to="/inbox/recent" replace />} />
|
||||||
<Route path="design-guide" element={<DesignGuide />} />
|
<Route path="design-guide" element={<DesignGuide />} />
|
||||||
|
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
|
||||||
<Route path="*" element={<NotFoundPage scope="board" />} />
|
<Route path="*" element={<NotFoundPage scope="board" />} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -246,6 +248,7 @@ export function App() {
|
|||||||
<Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} />
|
<Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} />
|
<Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||||
|
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path=":companyPrefix" element={<Layout />}>
|
<Route path=":companyPrefix" element={<Layout />}>
|
||||||
{boardRoutes()}
|
{boardRoutes()}
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
64
ui/src/adapters/gemini-local/config-fields.tsx
Normal file
64
ui/src/adapters/gemini-local/config-fields.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { AdapterConfigFieldsProps } from "../types";
|
||||||
|
import {
|
||||||
|
DraftInput,
|
||||||
|
Field,
|
||||||
|
ToggleField,
|
||||||
|
} from "../../components/agent-config-primitives";
|
||||||
|
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||||
|
const instructionsFileHint =
|
||||||
|
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Prepended to the Gemini prompt at runtime.";
|
||||||
|
|
||||||
|
export function GeminiLocalConfigFields({
|
||||||
|
isCreate,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
config,
|
||||||
|
eff,
|
||||||
|
mark,
|
||||||
|
}: AdapterConfigFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DraftInput
|
||||||
|
value={
|
||||||
|
isCreate
|
||||||
|
? values!.instructionsFilePath ?? ""
|
||||||
|
: eff(
|
||||||
|
"adapterConfig",
|
||||||
|
"instructionsFilePath",
|
||||||
|
String(config.instructionsFilePath ?? ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ instructionsFilePath: v })
|
||||||
|
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||||
|
}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="/absolute/path/to/AGENTS.md"
|
||||||
|
/>
|
||||||
|
<ChoosePathButton />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<ToggleField
|
||||||
|
label="Yolo mode"
|
||||||
|
hint="Run Gemini with --approval-mode yolo for unattended operation."
|
||||||
|
checked={
|
||||||
|
isCreate
|
||||||
|
? values!.dangerouslyBypassSandbox
|
||||||
|
: eff("adapterConfig", "yolo", config.yolo === true)
|
||||||
|
}
|
||||||
|
onChange={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ dangerouslyBypassSandbox: v })
|
||||||
|
: mark("adapterConfig", "yolo", v)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
ui/src/adapters/gemini-local/index.ts
Normal file
12
ui/src/adapters/gemini-local/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { UIAdapterModule } from "../types";
|
||||||
|
import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui";
|
||||||
|
import { GeminiLocalConfigFields } from "./config-fields";
|
||||||
|
import { buildGeminiLocalConfig } from "@paperclipai/adapter-gemini-local/ui";
|
||||||
|
|
||||||
|
export const geminiLocalUIAdapter: UIAdapterModule = {
|
||||||
|
type: "gemini_local",
|
||||||
|
label: "Gemini CLI (local)",
|
||||||
|
parseStdoutLine: parseGeminiStdoutLine,
|
||||||
|
ConfigFields: GeminiLocalConfigFields,
|
||||||
|
buildAdapterConfig: buildGeminiLocalConfig,
|
||||||
|
};
|
||||||
@@ -6,3 +6,4 @@ export type {
|
|||||||
UIAdapterModule,
|
UIAdapterModule,
|
||||||
AdapterConfigFieldsProps,
|
AdapterConfigFieldsProps,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
export type { RunLogChunk } from "./transcript";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { UIAdapterModule } from "./types";
|
|||||||
import { claudeLocalUIAdapter } from "./claude-local";
|
import { claudeLocalUIAdapter } from "./claude-local";
|
||||||
import { codexLocalUIAdapter } from "./codex-local";
|
import { codexLocalUIAdapter } from "./codex-local";
|
||||||
import { cursorLocalUIAdapter } from "./cursor";
|
import { cursorLocalUIAdapter } from "./cursor";
|
||||||
|
import { geminiLocalUIAdapter } from "./gemini-local";
|
||||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||||
import { piLocalUIAdapter } from "./pi-local";
|
import { piLocalUIAdapter } from "./pi-local";
|
||||||
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||||
@@ -12,6 +13,7 @@ const adaptersByType = new Map<string, UIAdapterModule>(
|
|||||||
[
|
[
|
||||||
claudeLocalUIAdapter,
|
claudeLocalUIAdapter,
|
||||||
codexLocalUIAdapter,
|
codexLocalUIAdapter,
|
||||||
|
geminiLocalUIAdapter,
|
||||||
openCodeLocalUIAdapter,
|
openCodeLocalUIAdapter,
|
||||||
piLocalUIAdapter,
|
piLocalUIAdapter,
|
||||||
cursorLocalUIAdapter,
|
cursorLocalUIAdapter,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { TranscriptEntry, StdoutLineParser } from "./types";
|
import type { TranscriptEntry, StdoutLineParser } from "./types";
|
||||||
|
|
||||||
type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
||||||
|
|
||||||
function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
|
export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
|
||||||
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
|
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
|
||||||
const last = entries[entries.length - 1];
|
const last = entries[entries.length - 1];
|
||||||
if (last && last.kind === entry.kind && last.delta) {
|
if (last && last.kind === entry.kind && last.delta) {
|
||||||
@@ -14,6 +14,12 @@ function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntr
|
|||||||
entries.push(entry);
|
entries.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: TranscriptEntry[]) {
|
||||||
|
for (const entry of incoming) {
|
||||||
|
appendTranscriptEntry(entries, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser): TranscriptEntry[] {
|
export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser): TranscriptEntry[] {
|
||||||
const entries: TranscriptEntry[] = [];
|
const entries: TranscriptEntry[] = [];
|
||||||
let stdoutBuffer = "";
|
let stdoutBuffer = "";
|
||||||
@@ -34,18 +40,14 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser)
|
|||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
for (const entry of parser(trimmed, chunk.ts)) {
|
appendTranscriptEntries(entries, parser(trimmed, chunk.ts));
|
||||||
appendTranscriptEntry(entries, entry);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const trailing = stdoutBuffer.trim();
|
const trailing = stdoutBuffer.trim();
|
||||||
if (trailing) {
|
if (trailing) {
|
||||||
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
|
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
|
||||||
for (const entry of parser(trailing, ts)) {
|
appendTranscriptEntries(entries, parser(trailing, ts));
|
||||||
appendTranscriptEntry(entries, entry);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
|
|||||||
@@ -1,191 +1,19 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
import { useMemo } from "react";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { Issue, LiveEvent } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { getUIAdapter } from "../adapters";
|
|
||||||
import type { TranscriptEntry } from "../adapters";
|
import type { TranscriptEntry } from "../adapters";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn, relativeTime } from "../lib/utils";
|
import { cn, relativeTime } from "../lib/utils";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
|
import { RunTranscriptView } from "./transcript/RunTranscriptView";
|
||||||
|
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
|
||||||
|
|
||||||
type FeedTone = "info" | "warn" | "error" | "assistant" | "tool";
|
|
||||||
|
|
||||||
interface FeedItem {
|
|
||||||
id: string;
|
|
||||||
ts: string;
|
|
||||||
runId: string;
|
|
||||||
agentId: string;
|
|
||||||
agentName: string;
|
|
||||||
text: string;
|
|
||||||
tone: FeedTone;
|
|
||||||
dedupeKey: string;
|
|
||||||
streamingKind?: "assistant" | "thinking";
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_FEED_ITEMS = 40;
|
|
||||||
const MAX_FEED_TEXT_LENGTH = 220;
|
|
||||||
const MAX_STREAMING_TEXT_LENGTH = 4000;
|
|
||||||
const MIN_DASHBOARD_RUNS = 4;
|
const MIN_DASHBOARD_RUNS = 4;
|
||||||
|
|
||||||
function readString(value: unknown): string | null {
|
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeEntry(entry: TranscriptEntry): { text: string; tone: FeedTone } | null {
|
|
||||||
if (entry.kind === "assistant") {
|
|
||||||
const text = entry.text.trim();
|
|
||||||
return text ? { text, tone: "assistant" } : null;
|
|
||||||
}
|
|
||||||
if (entry.kind === "thinking") {
|
|
||||||
const text = entry.text.trim();
|
|
||||||
return text ? { text: `[thinking] ${text}`, tone: "info" } : null;
|
|
||||||
}
|
|
||||||
if (entry.kind === "tool_call") {
|
|
||||||
return { text: `tool ${entry.name}`, tone: "tool" };
|
|
||||||
}
|
|
||||||
if (entry.kind === "tool_result") {
|
|
||||||
const base = entry.content.trim();
|
|
||||||
return {
|
|
||||||
text: entry.isError ? `tool error: ${base}` : `tool result: ${base}`,
|
|
||||||
tone: entry.isError ? "error" : "tool",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (entry.kind === "stderr") {
|
|
||||||
const text = entry.text.trim();
|
|
||||||
return text ? { text, tone: "error" } : null;
|
|
||||||
}
|
|
||||||
if (entry.kind === "system") {
|
|
||||||
const text = entry.text.trim();
|
|
||||||
return text ? { text, tone: "warn" } : null;
|
|
||||||
}
|
|
||||||
if (entry.kind === "stdout") {
|
|
||||||
const text = entry.text.trim();
|
|
||||||
return text ? { text, tone: "info" } : null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFeedItem(
|
|
||||||
run: LiveRunForIssue,
|
|
||||||
ts: string,
|
|
||||||
text: string,
|
|
||||||
tone: FeedTone,
|
|
||||||
nextId: number,
|
|
||||||
options?: {
|
|
||||||
streamingKind?: "assistant" | "thinking";
|
|
||||||
preserveWhitespace?: boolean;
|
|
||||||
},
|
|
||||||
): FeedItem | null {
|
|
||||||
if (!text.trim()) return null;
|
|
||||||
const base = options?.preserveWhitespace ? text : text.trim();
|
|
||||||
const maxLength = options?.streamingKind ? MAX_STREAMING_TEXT_LENGTH : MAX_FEED_TEXT_LENGTH;
|
|
||||||
const normalized = base.length > maxLength ? base.slice(-maxLength) : base;
|
|
||||||
return {
|
|
||||||
id: `${run.id}:${nextId}`,
|
|
||||||
ts,
|
|
||||||
runId: run.id,
|
|
||||||
agentId: run.agentId,
|
|
||||||
agentName: run.agentName,
|
|
||||||
text: normalized,
|
|
||||||
tone,
|
|
||||||
dedupeKey: `feed:${run.id}:${ts}:${tone}:${normalized}`,
|
|
||||||
streamingKind: options?.streamingKind,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStdoutChunk(
|
|
||||||
run: LiveRunForIssue,
|
|
||||||
chunk: string,
|
|
||||||
ts: string,
|
|
||||||
pendingByRun: Map<string, string>,
|
|
||||||
nextIdRef: MutableRefObject<number>,
|
|
||||||
): FeedItem[] {
|
|
||||||
const pendingKey = `${run.id}:stdout`;
|
|
||||||
const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`;
|
|
||||||
const split = combined.split(/\r?\n/);
|
|
||||||
pendingByRun.set(pendingKey, split.pop() ?? "");
|
|
||||||
const adapter = getUIAdapter(run.adapterType);
|
|
||||||
|
|
||||||
const summarized: Array<{ text: string; tone: FeedTone; streamingKind?: "assistant" | "thinking" }> = [];
|
|
||||||
const appendSummary = (entry: TranscriptEntry) => {
|
|
||||||
if (entry.kind === "assistant" && entry.delta) {
|
|
||||||
const text = entry.text;
|
|
||||||
if (!text.trim()) return;
|
|
||||||
const last = summarized[summarized.length - 1];
|
|
||||||
if (last && last.streamingKind === "assistant") {
|
|
||||||
last.text += text;
|
|
||||||
} else {
|
|
||||||
summarized.push({ text, tone: "assistant", streamingKind: "assistant" });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.kind === "thinking" && entry.delta) {
|
|
||||||
const text = entry.text;
|
|
||||||
if (!text.trim()) return;
|
|
||||||
const last = summarized[summarized.length - 1];
|
|
||||||
if (last && last.streamingKind === "thinking") {
|
|
||||||
last.text += text;
|
|
||||||
} else {
|
|
||||||
summarized.push({ text: `[thinking] ${text}`, tone: "info", streamingKind: "thinking" });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = summarizeEntry(entry);
|
|
||||||
if (!summary) return;
|
|
||||||
summarized.push({ text: summary.text, tone: summary.tone });
|
|
||||||
};
|
|
||||||
|
|
||||||
const items: FeedItem[] = [];
|
|
||||||
for (const line of split.slice(-8)) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed) continue;
|
|
||||||
const parsed = adapter.parseStdoutLine(trimmed, ts);
|
|
||||||
if (parsed.length === 0) {
|
|
||||||
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
|
|
||||||
if (fallback) items.push(fallback);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const entry of parsed) {
|
|
||||||
appendSummary(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const summary of summarized) {
|
|
||||||
const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++, {
|
|
||||||
streamingKind: summary.streamingKind,
|
|
||||||
preserveWhitespace: !!summary.streamingKind,
|
|
||||||
});
|
|
||||||
if (item) items.push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStderrChunk(
|
|
||||||
run: LiveRunForIssue,
|
|
||||||
chunk: string,
|
|
||||||
ts: string,
|
|
||||||
pendingByRun: Map<string, string>,
|
|
||||||
nextIdRef: MutableRefObject<number>,
|
|
||||||
): FeedItem[] {
|
|
||||||
const pendingKey = `${run.id}:stderr`;
|
|
||||||
const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`;
|
|
||||||
const split = combined.split(/\r?\n/);
|
|
||||||
pendingByRun.set(pendingKey, split.pop() ?? "");
|
|
||||||
|
|
||||||
const items: FeedItem[] = [];
|
|
||||||
for (const line of split.slice(-8)) {
|
|
||||||
const item = createFeedItem(run, ts, line, "error", nextIdRef.current++);
|
|
||||||
if (item) items.push(item);
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRunActive(run: LiveRunForIssue): boolean {
|
function isRunActive(run: LiveRunForIssue): boolean {
|
||||||
return run.status === "queued" || run.status === "running";
|
return run.status === "queued" || run.status === "running";
|
||||||
}
|
}
|
||||||
@@ -195,11 +23,6 @@ interface ActiveAgentsPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
|
export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
|
||||||
const [feedByRun, setFeedByRun] = useState<Map<string, FeedItem[]>>(new Map());
|
|
||||||
const seenKeysRef = useRef(new Set<string>());
|
|
||||||
const pendingByRunRef = useRef(new Map<string, string>());
|
|
||||||
const nextIdRef = useRef(1);
|
|
||||||
|
|
||||||
const { data: liveRuns } = useQuery({
|
const { data: liveRuns } = useQuery({
|
||||||
queryKey: [...queryKeys.liveRuns(companyId), "dashboard"],
|
queryKey: [...queryKeys.liveRuns(companyId), "dashboard"],
|
||||||
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, MIN_DASHBOARD_RUNS),
|
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, MIN_DASHBOARD_RUNS),
|
||||||
@@ -220,179 +43,30 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
|
|||||||
return map;
|
return map;
|
||||||
}, [issues]);
|
}, [issues]);
|
||||||
|
|
||||||
const runById = useMemo(() => new Map(runs.map((r) => [r.id, r])), [runs]);
|
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
||||||
const activeRunIds = useMemo(() => new Set(runs.filter(isRunActive).map((r) => r.id)), [runs]);
|
runs,
|
||||||
|
companyId,
|
||||||
// Clean up pending buffers for runs that ended
|
maxChunksPerRun: 120,
|
||||||
useEffect(() => {
|
});
|
||||||
const stillActive = new Set<string>();
|
|
||||||
for (const runId of activeRunIds) {
|
|
||||||
stillActive.add(`${runId}:stdout`);
|
|
||||||
stillActive.add(`${runId}:stderr`);
|
|
||||||
}
|
|
||||||
for (const key of pendingByRunRef.current.keys()) {
|
|
||||||
if (!stillActive.has(key)) {
|
|
||||||
pendingByRunRef.current.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [activeRunIds]);
|
|
||||||
|
|
||||||
// WebSocket connection for streaming
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeRunIds.size === 0) return;
|
|
||||||
|
|
||||||
let closed = false;
|
|
||||||
let reconnectTimer: number | null = null;
|
|
||||||
let socket: WebSocket | null = null;
|
|
||||||
|
|
||||||
const appendItems = (runId: string, items: FeedItem[]) => {
|
|
||||||
if (items.length === 0) return;
|
|
||||||
setFeedByRun((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
const existing = [...(next.get(runId) ?? [])];
|
|
||||||
for (const item of items) {
|
|
||||||
if (seenKeysRef.current.has(item.dedupeKey)) continue;
|
|
||||||
seenKeysRef.current.add(item.dedupeKey);
|
|
||||||
|
|
||||||
const last = existing[existing.length - 1];
|
|
||||||
if (
|
|
||||||
item.streamingKind &&
|
|
||||||
last &&
|
|
||||||
last.runId === item.runId &&
|
|
||||||
last.streamingKind === item.streamingKind
|
|
||||||
) {
|
|
||||||
const mergedText = `${last.text}${item.text}`;
|
|
||||||
const nextText =
|
|
||||||
mergedText.length > MAX_STREAMING_TEXT_LENGTH
|
|
||||||
? mergedText.slice(-MAX_STREAMING_TEXT_LENGTH)
|
|
||||||
: mergedText;
|
|
||||||
existing[existing.length - 1] = {
|
|
||||||
...last,
|
|
||||||
ts: item.ts,
|
|
||||||
text: nextText,
|
|
||||||
dedupeKey: last.dedupeKey,
|
|
||||||
};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.push(item);
|
|
||||||
}
|
|
||||||
if (seenKeysRef.current.size > 6000) {
|
|
||||||
seenKeysRef.current.clear();
|
|
||||||
}
|
|
||||||
next.set(runId, existing.slice(-MAX_FEED_ITEMS));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleReconnect = () => {
|
|
||||||
if (closed) return;
|
|
||||||
reconnectTimer = window.setTimeout(connect, 1500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const connect = () => {
|
|
||||||
if (closed) return;
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
||||||
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`;
|
|
||||||
socket = new WebSocket(url);
|
|
||||||
|
|
||||||
socket.onmessage = (message) => {
|
|
||||||
const raw = typeof message.data === "string" ? message.data : "";
|
|
||||||
if (!raw) return;
|
|
||||||
|
|
||||||
let event: LiveEvent;
|
|
||||||
try {
|
|
||||||
event = JSON.parse(raw) as LiveEvent;
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.companyId !== companyId) return;
|
|
||||||
const payload = event.payload ?? {};
|
|
||||||
const runId = readString(payload["runId"]);
|
|
||||||
if (!runId || !activeRunIds.has(runId)) return;
|
|
||||||
|
|
||||||
const run = runById.get(runId);
|
|
||||||
if (!run) return;
|
|
||||||
|
|
||||||
if (event.type === "heartbeat.run.event") {
|
|
||||||
const seq = typeof payload["seq"] === "number" ? payload["seq"] : null;
|
|
||||||
const eventType = readString(payload["eventType"]) ?? "event";
|
|
||||||
const messageText = readString(payload["message"]) ?? eventType;
|
|
||||||
const dedupeKey = `${runId}:event:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`;
|
|
||||||
if (seenKeysRef.current.has(dedupeKey)) return;
|
|
||||||
seenKeysRef.current.add(dedupeKey);
|
|
||||||
if (seenKeysRef.current.size > 6000) seenKeysRef.current.clear();
|
|
||||||
const tone = eventType === "error" ? "error" : eventType === "lifecycle" ? "warn" : "info";
|
|
||||||
const item = createFeedItem(run, event.createdAt, messageText, tone, nextIdRef.current++);
|
|
||||||
if (item) appendItems(run.id, [item]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "heartbeat.run.status") {
|
|
||||||
const status = readString(payload["status"]) ?? "updated";
|
|
||||||
const dedupeKey = `${runId}:status:${status}:${readString(payload["finishedAt"]) ?? ""}`;
|
|
||||||
if (seenKeysRef.current.has(dedupeKey)) return;
|
|
||||||
seenKeysRef.current.add(dedupeKey);
|
|
||||||
if (seenKeysRef.current.size > 6000) seenKeysRef.current.clear();
|
|
||||||
const tone = status === "failed" || status === "timed_out" ? "error" : "warn";
|
|
||||||
const item = createFeedItem(run, event.createdAt, `run ${status}`, tone, nextIdRef.current++);
|
|
||||||
if (item) appendItems(run.id, [item]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "heartbeat.run.log") {
|
|
||||||
const chunk = readString(payload["chunk"]);
|
|
||||||
if (!chunk) return;
|
|
||||||
const stream = readString(payload["stream"]) === "stderr" ? "stderr" : "stdout";
|
|
||||||
if (stream === "stderr") {
|
|
||||||
appendItems(run.id, parseStderrChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
appendItems(run.id, parseStdoutChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onerror = () => {
|
|
||||||
socket?.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onclose = () => {
|
|
||||||
scheduleReconnect();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
connect();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
closed = true;
|
|
||||||
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
|
|
||||||
if (socket) {
|
|
||||||
socket.onmessage = null;
|
|
||||||
socket.onerror = null;
|
|
||||||
socket.onclose = null;
|
|
||||||
socket.close(1000, "active_agents_panel_unmount");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [activeRunIds, companyId, runById]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Agents
|
Agents
|
||||||
</h3>
|
</h3>
|
||||||
{runs.length === 0 ? (
|
{runs.length === 0 ? (
|
||||||
<div className="border border-border rounded-lg p-4">
|
<div className="rounded-xl border border-border p-4">
|
||||||
<p className="text-sm text-muted-foreground">No recent agent runs.</p>
|
<p className="text-sm text-muted-foreground">No recent agent runs.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-2 sm:gap-4">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4">
|
||||||
{runs.map((run) => (
|
{runs.map((run) => (
|
||||||
<AgentRunCard
|
<AgentRunCard
|
||||||
key={run.id}
|
key={run.id}
|
||||||
run={run}
|
run={run}
|
||||||
issue={run.issueId ? issueById.get(run.issueId) : undefined}
|
issue={run.issueId ? issueById.get(run.issueId) : undefined}
|
||||||
feed={feedByRun.get(run.id) ?? []}
|
transcript={transcriptByRun.get(run.id) ?? []}
|
||||||
|
hasOutput={hasOutputForRun(run.id)}
|
||||||
isActive={isRunActive(run)}
|
isActive={isRunActive(run)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -405,104 +79,76 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
|
|||||||
function AgentRunCard({
|
function AgentRunCard({
|
||||||
run,
|
run,
|
||||||
issue,
|
issue,
|
||||||
feed,
|
transcript,
|
||||||
|
hasOutput,
|
||||||
isActive,
|
isActive,
|
||||||
}: {
|
}: {
|
||||||
run: LiveRunForIssue;
|
run: LiveRunForIssue;
|
||||||
issue?: Issue;
|
issue?: Issue;
|
||||||
feed: FeedItem[];
|
transcript: TranscriptEntry[];
|
||||||
|
hasOutput: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}) {
|
}) {
|
||||||
const bodyRef = useRef<HTMLDivElement>(null);
|
|
||||||
const recent = feed.slice(-20);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const body = bodyRef.current;
|
|
||||||
if (!body) return;
|
|
||||||
body.scrollTo({ top: body.scrollHeight, behavior: "smooth" });
|
|
||||||
}, [feed.length]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"flex flex-col rounded-lg border overflow-hidden min-h-[200px]",
|
"flex h-[320px] flex-col overflow-hidden rounded-xl border shadow-sm",
|
||||||
isActive
|
isActive
|
||||||
? "border-blue-500/30 bg-background/80 shadow-[0_0_12px_rgba(59,130,246,0.08)]"
|
? "border-cyan-500/25 bg-cyan-500/[0.04] shadow-[0_16px_40px_rgba(6,182,212,0.08)]"
|
||||||
: "border-border bg-background/50",
|
: "border-border bg-background/70",
|
||||||
)}>
|
)}>
|
||||||
{/* Header */}
|
<div className="border-b border-border/60 px-3 py-3">
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="min-w-0">
|
||||||
{isActive ? (
|
<div className="flex items-center gap-2">
|
||||||
<span className="relative flex h-2 w-2 shrink-0">
|
{isActive ? (
|
||||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-cyan-400 opacity-70" />
|
||||||
</span>
|
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-cyan-500" />
|
||||||
) : (
|
</span>
|
||||||
<span className="flex h-2 w-2 shrink-0">
|
) : (
|
||||||
<span className="inline-flex rounded-full h-2 w-2 bg-muted-foreground/40" />
|
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-muted-foreground/35" />
|
||||||
</span>
|
)}
|
||||||
)}
|
<Identity name={run.agentName} size="sm" />
|
||||||
<Identity name={run.agentName} size="sm" />
|
</div>
|
||||||
{isActive && (
|
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
|
<span>{isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}</span>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
to={`/agents/${run.agentId}/runs/${run.id}`}
|
|
||||||
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground shrink-0"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-2.5 w-2.5" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Issue context */}
|
|
||||||
{run.issueId && (
|
|
||||||
<div className="px-3 py-1.5 border-b border-border/40 text-xs flex items-center gap-1 min-w-0">
|
|
||||||
<Link
|
<Link
|
||||||
to={`/issues/${issue?.identifier ?? run.issueId}`}
|
to={`/agents/${run.agentId}/runs/${run.id}`}
|
||||||
className={cn(
|
className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/70 px-2 py-1 text-[10px] text-muted-foreground transition-colors hover:text-foreground"
|
||||||
"hover:underline min-w-0 line-clamp-2 min-h-[2rem]",
|
|
||||||
isActive ? "text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300" : "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
title={issue?.title ? `${issue?.identifier ?? run.issueId.slice(0, 8)} - ${issue.title}` : issue?.identifier ?? run.issueId.slice(0, 8)}
|
|
||||||
>
|
>
|
||||||
{issue?.identifier ?? run.issueId.slice(0, 8)}
|
<ExternalLink className="h-2.5 w-2.5" />
|
||||||
{issue?.title ? ` - ${issue.title}` : ""}
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Feed body */}
|
{run.issueId && (
|
||||||
<div ref={bodyRef} className="flex-1 max-h-[140px] overflow-y-auto p-2 font-mono text-[11px] space-y-1">
|
<div className="mt-3 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-xs">
|
||||||
{isActive && recent.length === 0 && (
|
<Link
|
||||||
<div className="text-xs text-muted-foreground">Waiting for output...</div>
|
to={`/issues/${issue?.identifier ?? run.issueId}`}
|
||||||
)}
|
className={cn(
|
||||||
{!isActive && recent.length === 0 && (
|
"line-clamp-2 hover:underline",
|
||||||
<div className="text-xs text-muted-foreground">
|
isActive ? "text-cyan-700 dark:text-cyan-300" : "text-muted-foreground hover:text-foreground",
|
||||||
{run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}
|
)}
|
||||||
|
title={issue?.title ? `${issue?.identifier ?? run.issueId.slice(0, 8)} - ${issue.title}` : issue?.identifier ?? run.issueId.slice(0, 8)}
|
||||||
|
>
|
||||||
|
{issue?.identifier ?? run.issueId.slice(0, 8)}
|
||||||
|
{issue?.title ? ` - ${issue.title}` : ""}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{recent.map((item, index) => (
|
</div>
|
||||||
<div
|
|
||||||
key={item.id}
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
className={cn(
|
<RunTranscriptView
|
||||||
"flex gap-2 items-start",
|
entries={transcript}
|
||||||
index === recent.length - 1 && isActive && "animate-in fade-in slide-in-from-bottom-1 duration-300",
|
density="compact"
|
||||||
)}
|
limit={5}
|
||||||
>
|
streaming={isActive}
|
||||||
<span className="text-[10px] text-muted-foreground shrink-0">{relativeTime(item.ts)}</span>
|
collapseStdout
|
||||||
<span className={cn(
|
emptyMessage={hasOutput ? "Waiting for transcript parsing..." : isActive ? "Waiting for output..." : "No transcript captured."}
|
||||||
"min-w-0 break-words",
|
/>
|
||||||
item.tone === "error" && "text-red-600 dark:text-red-300",
|
|
||||||
item.tone === "warn" && "text-amber-600 dark:text-amber-300",
|
|
||||||
item.tone === "assistant" && "text-emerald-700 dark:text-emerald-200",
|
|
||||||
item.tone === "tool" && "text-cyan-600 dark:text-cyan-300",
|
|
||||||
item.tone === "info" && "text-foreground/80",
|
|
||||||
)}>
|
|
||||||
{item.text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
DEFAULT_CODEX_LOCAL_MODEL,
|
DEFAULT_CODEX_LOCAL_MODEL,
|
||||||
} from "@paperclipai/adapter-codex-local";
|
} from "@paperclipai/adapter-codex-local";
|
||||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||||
|
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -282,6 +283,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
const isLocal =
|
const isLocal =
|
||||||
adapterType === "claude_local" ||
|
adapterType === "claude_local" ||
|
||||||
adapterType === "codex_local" ||
|
adapterType === "codex_local" ||
|
||||||
|
adapterType === "gemini_local" ||
|
||||||
adapterType === "opencode_local" ||
|
adapterType === "opencode_local" ||
|
||||||
adapterType === "cursor";
|
adapterType === "cursor";
|
||||||
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||||
@@ -374,9 +376,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
)
|
)
|
||||||
: adapterType === "cursor"
|
: adapterType === "cursor"
|
||||||
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||||
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
||||||
|
const showThinkingEffort = adapterType !== "gemini_local";
|
||||||
const codexSearchEnabled = adapterType === "codex_local"
|
const codexSearchEnabled = adapterType === "codex_local"
|
||||||
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
||||||
: false;
|
: false;
|
||||||
@@ -494,6 +497,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
|
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
|
||||||
nextValues.dangerouslyBypassSandbox =
|
nextValues.dangerouslyBypassSandbox =
|
||||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||||
|
} else if (t === "gemini_local") {
|
||||||
|
nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL;
|
||||||
} else if (t === "cursor") {
|
} else if (t === "cursor") {
|
||||||
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||||
} else if (t === "opencode_local") {
|
} else if (t === "opencode_local") {
|
||||||
@@ -510,6 +515,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
model:
|
model:
|
||||||
t === "codex_local"
|
t === "codex_local"
|
||||||
? DEFAULT_CODEX_LOCAL_MODEL
|
? DEFAULT_CODEX_LOCAL_MODEL
|
||||||
|
: t === "gemini_local"
|
||||||
|
? DEFAULT_GEMINI_LOCAL_MODEL
|
||||||
: t === "cursor"
|
: t === "cursor"
|
||||||
? DEFAULT_CURSOR_LOCAL_MODEL
|
? DEFAULT_CURSOR_LOCAL_MODEL
|
||||||
: "",
|
: "",
|
||||||
@@ -615,6 +622,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
placeholder={
|
placeholder={
|
||||||
adapterType === "codex_local"
|
adapterType === "codex_local"
|
||||||
? "codex"
|
? "codex"
|
||||||
|
: adapterType === "gemini_local"
|
||||||
|
? "gemini"
|
||||||
: adapterType === "cursor"
|
: adapterType === "cursor"
|
||||||
? "agent"
|
? "agent"
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
@@ -646,24 +655,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ThinkingEffortDropdown
|
{showThinkingEffort && (
|
||||||
value={currentThinkingEffort}
|
<>
|
||||||
options={thinkingEffortOptions}
|
<ThinkingEffortDropdown
|
||||||
onChange={(v) =>
|
value={currentThinkingEffort}
|
||||||
isCreate
|
options={thinkingEffortOptions}
|
||||||
? set!({ thinkingEffort: v })
|
onChange={(v) =>
|
||||||
: mark("adapterConfig", thinkingEffortKey, v || undefined)
|
isCreate
|
||||||
}
|
? set!({ thinkingEffort: v })
|
||||||
open={thinkingEffortOpen}
|
: mark("adapterConfig", thinkingEffortKey, v || undefined)
|
||||||
onOpenChange={setThinkingEffortOpen}
|
}
|
||||||
/>
|
open={thinkingEffortOpen}
|
||||||
{adapterType === "codex_local" &&
|
onOpenChange={setThinkingEffortOpen}
|
||||||
codexSearchEnabled &&
|
/>
|
||||||
currentThinkingEffort === "minimal" && (
|
{adapterType === "codex_local" &&
|
||||||
<p className="text-xs text-amber-400">
|
codexSearchEnabled &&
|
||||||
Codex may reject `minimal` thinking when search is enabled.
|
currentThinkingEffort === "minimal" && (
|
||||||
</p>
|
<p className="text-xs text-amber-400">
|
||||||
)}
|
Codex may reject `minimal` thinking when search is enabled.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
|
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
value={
|
value={
|
||||||
@@ -898,7 +911,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
|||||||
|
|
||||||
/* ---- Internal sub-components ---- */
|
/* ---- Internal sub-components ---- */
|
||||||
|
|
||||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local", "cursor"]);
|
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);
|
||||||
|
|
||||||
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
||||||
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface AgentPropertiesProps {
|
|||||||
const adapterLabels: Record<string, string> = {
|
const adapterLabels: Record<string, string> = {
|
||||||
claude_local: "Claude (local)",
|
claude_local: "Claude (local)",
|
||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
|
gemini_local: "Gemini CLI (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
|
|||||||
@@ -1,262 +1,32 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { LiveEvent } from "@paperclipai/shared";
|
|
||||||
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
||||||
import { getUIAdapter } from "../adapters";
|
|
||||||
import type { TranscriptEntry } from "../adapters";
|
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn, relativeTime, formatDateTime } from "../lib/utils";
|
import { formatDateTime } from "../lib/utils";
|
||||||
import { ExternalLink, Square } from "lucide-react";
|
import { ExternalLink, Square } from "lucide-react";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
|
import { RunTranscriptView } from "./transcript/RunTranscriptView";
|
||||||
|
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
|
||||||
|
|
||||||
interface LiveRunWidgetProps {
|
interface LiveRunWidgetProps {
|
||||||
issueId: string;
|
issueId: string;
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeedTone = "info" | "warn" | "error" | "assistant" | "tool";
|
|
||||||
|
|
||||||
interface FeedItem {
|
|
||||||
id: string;
|
|
||||||
ts: string;
|
|
||||||
runId: string;
|
|
||||||
agentId: string;
|
|
||||||
agentName: string;
|
|
||||||
text: string;
|
|
||||||
tone: FeedTone;
|
|
||||||
dedupeKey: string;
|
|
||||||
streamingKind?: "assistant" | "thinking";
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_FEED_ITEMS = 80;
|
|
||||||
const MAX_FEED_TEXT_LENGTH = 220;
|
|
||||||
const MAX_STREAMING_TEXT_LENGTH = 4000;
|
|
||||||
const LOG_POLL_INTERVAL_MS = 2000;
|
|
||||||
const LOG_READ_LIMIT_BYTES = 256_000;
|
|
||||||
|
|
||||||
function readString(value: unknown): string | null {
|
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toIsoString(value: string | Date | null | undefined): string | null {
|
function toIsoString(value: string | Date | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
return typeof value === "string" ? value : value.toISOString();
|
return typeof value === "string" ? value : value.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeEntry(entry: TranscriptEntry): { text: string; tone: FeedTone } | null {
|
function isRunActive(status: string): boolean {
|
||||||
if (entry.kind === "assistant") {
|
return status === "queued" || status === "running";
|
||||||
const text = entry.text.trim();
|
|
||||||
return text ? { text, tone: "assistant" } : null;
|
|
||||||
}
|
|
||||||
if (entry.kind === "thinking") {
|
|
||||||
const text = entry.text.trim();
|
|
||||||
return text ? { text: `[thinking] ${text}`, tone: "info" } : null;
|
|
||||||
}
|
|
||||||
if (entry.kind === "tool_call") {
|
|
||||||
return { text: `tool ${entry.name}`, tone: "tool" };
|
|
||||||
}
|
|
||||||
if (entry.kind === "tool_result") {
|
|
||||||
const base = entry.content.trim();
|
|
||||||
return {
|
|
||||||
text: entry.isError ? `tool error: ${base}` : `tool result: ${base}`,
|
|
||||||
tone: entry.isError ? "error" : "tool",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (entry.kind === "stderr") {
|
|
||||||
const text = entry.text.trim();
|
|
||||||
return text ? { text, tone: "error" } : null;
|
|
||||||
}
|
|
||||||
if (entry.kind === "system") {
|
|
||||||
const text = entry.text.trim();
|
|
||||||
return text ? { text, tone: "warn" } : null;
|
|
||||||
}
|
|
||||||
if (entry.kind === "stdout") {
|
|
||||||
const text = entry.text.trim();
|
|
||||||
return text ? { text, tone: "info" } : null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFeedItem(
|
|
||||||
run: LiveRunForIssue,
|
|
||||||
ts: string,
|
|
||||||
text: string,
|
|
||||||
tone: FeedTone,
|
|
||||||
nextId: number,
|
|
||||||
options?: {
|
|
||||||
streamingKind?: "assistant" | "thinking";
|
|
||||||
preserveWhitespace?: boolean;
|
|
||||||
},
|
|
||||||
): FeedItem | null {
|
|
||||||
if (!text.trim()) return null;
|
|
||||||
const base = options?.preserveWhitespace ? text : text.trim();
|
|
||||||
const maxLength = options?.streamingKind ? MAX_STREAMING_TEXT_LENGTH : MAX_FEED_TEXT_LENGTH;
|
|
||||||
const normalized = base.length > maxLength ? base.slice(-maxLength) : base;
|
|
||||||
return {
|
|
||||||
id: `${run.id}:${nextId}`,
|
|
||||||
ts,
|
|
||||||
runId: run.id,
|
|
||||||
agentId: run.agentId,
|
|
||||||
agentName: run.agentName,
|
|
||||||
text: normalized,
|
|
||||||
tone,
|
|
||||||
dedupeKey: `feed:${run.id}:${ts}:${tone}:${normalized}`,
|
|
||||||
streamingKind: options?.streamingKind,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStdoutChunk(
|
|
||||||
run: LiveRunForIssue,
|
|
||||||
chunk: string,
|
|
||||||
ts: string,
|
|
||||||
pendingByRun: Map<string, string>,
|
|
||||||
nextIdRef: MutableRefObject<number>,
|
|
||||||
): FeedItem[] {
|
|
||||||
const pendingKey = `${run.id}:stdout`;
|
|
||||||
const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`;
|
|
||||||
const split = combined.split(/\r?\n/);
|
|
||||||
pendingByRun.set(pendingKey, split.pop() ?? "");
|
|
||||||
const adapter = getUIAdapter(run.adapterType);
|
|
||||||
|
|
||||||
const summarized: Array<{ text: string; tone: FeedTone; streamingKind?: "assistant" | "thinking" }> = [];
|
|
||||||
const appendSummary = (entry: TranscriptEntry) => {
|
|
||||||
if (entry.kind === "assistant" && entry.delta) {
|
|
||||||
const text = entry.text;
|
|
||||||
if (!text.trim()) return;
|
|
||||||
const last = summarized[summarized.length - 1];
|
|
||||||
if (last && last.streamingKind === "assistant") {
|
|
||||||
last.text += text;
|
|
||||||
} else {
|
|
||||||
summarized.push({ text, tone: "assistant", streamingKind: "assistant" });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.kind === "thinking" && entry.delta) {
|
|
||||||
const text = entry.text;
|
|
||||||
if (!text.trim()) return;
|
|
||||||
const last = summarized[summarized.length - 1];
|
|
||||||
if (last && last.streamingKind === "thinking") {
|
|
||||||
last.text += text;
|
|
||||||
} else {
|
|
||||||
summarized.push({ text: `[thinking] ${text}`, tone: "info", streamingKind: "thinking" });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = summarizeEntry(entry);
|
|
||||||
if (!summary) return;
|
|
||||||
summarized.push({ text: summary.text, tone: summary.tone });
|
|
||||||
};
|
|
||||||
|
|
||||||
const items: FeedItem[] = [];
|
|
||||||
for (const line of split.slice(-8)) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed) continue;
|
|
||||||
const parsed = adapter.parseStdoutLine(trimmed, ts);
|
|
||||||
if (parsed.length === 0) {
|
|
||||||
if (run.adapterType === "openclaw_gateway") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
|
|
||||||
if (fallback) items.push(fallback);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const entry of parsed) {
|
|
||||||
appendSummary(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const summary of summarized) {
|
|
||||||
const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++, {
|
|
||||||
streamingKind: summary.streamingKind,
|
|
||||||
preserveWhitespace: !!summary.streamingKind,
|
|
||||||
});
|
|
||||||
if (item) items.push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStderrChunk(
|
|
||||||
run: LiveRunForIssue,
|
|
||||||
chunk: string,
|
|
||||||
ts: string,
|
|
||||||
pendingByRun: Map<string, string>,
|
|
||||||
nextIdRef: MutableRefObject<number>,
|
|
||||||
): FeedItem[] {
|
|
||||||
const pendingKey = `${run.id}:stderr`;
|
|
||||||
const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`;
|
|
||||||
const split = combined.split(/\r?\n/);
|
|
||||||
pendingByRun.set(pendingKey, split.pop() ?? "");
|
|
||||||
|
|
||||||
const items: FeedItem[] = [];
|
|
||||||
for (const line of split.slice(-8)) {
|
|
||||||
const item = createFeedItem(run, ts, line, "error", nextIdRef.current++);
|
|
||||||
if (item) items.push(item);
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePersistedLogContent(
|
|
||||||
runId: string,
|
|
||||||
content: string,
|
|
||||||
pendingByRun: Map<string, string>,
|
|
||||||
): Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }> {
|
|
||||||
if (!content) return [];
|
|
||||||
|
|
||||||
const pendingKey = `${runId}:records`;
|
|
||||||
const combined = `${pendingByRun.get(pendingKey) ?? ""}${content}`;
|
|
||||||
const split = combined.split("\n");
|
|
||||||
pendingByRun.set(pendingKey, split.pop() ?? "");
|
|
||||||
|
|
||||||
const parsed: Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }> = [];
|
|
||||||
for (const line of split) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed) continue;
|
|
||||||
try {
|
|
||||||
const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown };
|
|
||||||
const stream = raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout";
|
|
||||||
const chunk = typeof raw.chunk === "string" ? raw.chunk : "";
|
|
||||||
const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString();
|
|
||||||
if (!chunk) continue;
|
|
||||||
parsed.push({ ts, stream, chunk });
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed log rows.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
|
export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [feed, setFeed] = useState<FeedItem[]>([]);
|
|
||||||
const [cancellingRunIds, setCancellingRunIds] = useState(new Set<string>());
|
const [cancellingRunIds, setCancellingRunIds] = useState(new Set<string>());
|
||||||
const seenKeysRef = useRef(new Set<string>());
|
|
||||||
const pendingByRunRef = useRef(new Map<string, string>());
|
|
||||||
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
|
|
||||||
const logOffsetByRunRef = useRef(new Map<string, number>());
|
|
||||||
const runMetaByIdRef = useRef(new Map<string, { agentId: string; agentName: string }>());
|
|
||||||
const nextIdRef = useRef(1);
|
|
||||||
const bodyRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const handleCancelRun = async (runId: string) => {
|
|
||||||
setCancellingRunIds((prev) => new Set(prev).add(runId));
|
|
||||||
try {
|
|
||||||
await heartbeatsApi.cancel(runId);
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) });
|
|
||||||
} finally {
|
|
||||||
setCancellingRunIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(runId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data: liveRuns } = useQuery({
|
const { data: liveRuns } = useQuery({
|
||||||
queryKey: queryKeys.issues.liveRuns(issueId),
|
queryKey: queryKeys.issues.liveRuns(issueId),
|
||||||
@@ -297,329 +67,94 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
|
|||||||
);
|
);
|
||||||
}, [activeRun, issueId, liveRuns]);
|
}, [activeRun, issueId, liveRuns]);
|
||||||
|
|
||||||
const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]);
|
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs, companyId });
|
||||||
const activeRunIds = useMemo(() => new Set(runs.map((run) => run.id)), [runs]);
|
|
||||||
const runIdsKey = useMemo(
|
|
||||||
() => runs.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","),
|
|
||||||
[runs],
|
|
||||||
);
|
|
||||||
const appendItems = (items: FeedItem[]) => {
|
|
||||||
if (items.length === 0) return;
|
|
||||||
setFeed((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
for (const item of items) {
|
|
||||||
if (seenKeysRef.current.has(item.dedupeKey)) continue;
|
|
||||||
seenKeysRef.current.add(item.dedupeKey);
|
|
||||||
|
|
||||||
const last = next[next.length - 1];
|
const handleCancelRun = async (runId: string) => {
|
||||||
if (
|
setCancellingRunIds((prev) => new Set(prev).add(runId));
|
||||||
item.streamingKind &&
|
try {
|
||||||
last &&
|
await heartbeatsApi.cancel(runId);
|
||||||
last.runId === item.runId &&
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) });
|
||||||
last.streamingKind === item.streamingKind
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) });
|
||||||
) {
|
} finally {
|
||||||
const mergedText = `${last.text}${item.text}`;
|
setCancellingRunIds((prev) => {
|
||||||
const nextText =
|
const next = new Set(prev);
|
||||||
mergedText.length > MAX_STREAMING_TEXT_LENGTH
|
next.delete(runId);
|
||||||
? mergedText.slice(-MAX_STREAMING_TEXT_LENGTH)
|
return next;
|
||||||
: mergedText;
|
});
|
||||||
next[next.length - 1] = {
|
}
|
||||||
...last,
|
|
||||||
ts: item.ts,
|
|
||||||
text: nextText,
|
|
||||||
dedupeKey: last.dedupeKey,
|
|
||||||
};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
next.push(item);
|
|
||||||
}
|
|
||||||
if (seenKeysRef.current.size > 6000) {
|
|
||||||
seenKeysRef.current.clear();
|
|
||||||
}
|
|
||||||
if (next.length === prev.length) return prev;
|
|
||||||
return next.slice(-MAX_FEED_ITEMS);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
if (runs.length === 0) return null;
|
||||||
const body = bodyRef.current;
|
|
||||||
if (!body) return;
|
|
||||||
body.scrollTo({ top: body.scrollHeight, behavior: "smooth" });
|
|
||||||
}, [feed.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
for (const run of runs) {
|
|
||||||
runMetaByIdRef.current.set(run.id, { agentId: run.agentId, agentName: run.agentName });
|
|
||||||
}
|
|
||||||
}, [runs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const stillActive = new Set<string>();
|
|
||||||
for (const runId of activeRunIds) {
|
|
||||||
stillActive.add(`${runId}:stdout`);
|
|
||||||
stillActive.add(`${runId}:stderr`);
|
|
||||||
}
|
|
||||||
for (const key of pendingByRunRef.current.keys()) {
|
|
||||||
if (!stillActive.has(key)) {
|
|
||||||
pendingByRunRef.current.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const liveRunIds = new Set(activeRunIds);
|
|
||||||
for (const key of pendingLogRowsByRunRef.current.keys()) {
|
|
||||||
const runId = key.replace(/:records$/, "");
|
|
||||||
if (!liveRunIds.has(runId)) {
|
|
||||||
pendingLogRowsByRunRef.current.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const runId of logOffsetByRunRef.current.keys()) {
|
|
||||||
if (!liveRunIds.has(runId)) {
|
|
||||||
logOffsetByRunRef.current.delete(runId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [activeRunIds]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (runs.length === 0) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const readRunLog = async (run: LiveRunForIssue) => {
|
|
||||||
const offset = logOffsetByRunRef.current.get(run.id) ?? 0;
|
|
||||||
try {
|
|
||||||
const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES);
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const rows = parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current);
|
|
||||||
const items: FeedItem[] = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
if (row.stream === "stderr") {
|
|
||||||
items.push(
|
|
||||||
...parseStderrChunk(run, row.chunk, row.ts, pendingByRunRef.current, nextIdRef),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (row.stream === "system") {
|
|
||||||
const item = createFeedItem(run, row.ts, row.chunk, "warn", nextIdRef.current++);
|
|
||||||
if (item) items.push(item);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
items.push(
|
|
||||||
...parseStdoutChunk(run, row.chunk, row.ts, pendingByRunRef.current, nextIdRef),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
appendItems(items);
|
|
||||||
|
|
||||||
if (result.nextOffset !== undefined) {
|
|
||||||
logOffsetByRunRef.current.set(run.id, result.nextOffset);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result.content.length > 0) {
|
|
||||||
logOffsetByRunRef.current.set(run.id, offset + result.content.length);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore log read errors while run output is initializing.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const readAll = async () => {
|
|
||||||
await Promise.all(runs.map((run) => readRunLog(run)));
|
|
||||||
};
|
|
||||||
|
|
||||||
void readAll();
|
|
||||||
const interval = window.setInterval(() => {
|
|
||||||
void readAll();
|
|
||||||
}, LOG_POLL_INTERVAL_MS);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
window.clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, [runIdsKey, runs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!companyId || activeRunIds.size === 0) return;
|
|
||||||
|
|
||||||
let closed = false;
|
|
||||||
let reconnectTimer: number | null = null;
|
|
||||||
let socket: WebSocket | null = null;
|
|
||||||
|
|
||||||
const scheduleReconnect = () => {
|
|
||||||
if (closed) return;
|
|
||||||
reconnectTimer = window.setTimeout(connect, 1500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const connect = () => {
|
|
||||||
if (closed) return;
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
||||||
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`;
|
|
||||||
socket = new WebSocket(url);
|
|
||||||
|
|
||||||
socket.onmessage = (message) => {
|
|
||||||
const raw = typeof message.data === "string" ? message.data : "";
|
|
||||||
if (!raw) return;
|
|
||||||
|
|
||||||
let event: LiveEvent;
|
|
||||||
try {
|
|
||||||
event = JSON.parse(raw) as LiveEvent;
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.companyId !== companyId) return;
|
|
||||||
const payload = event.payload ?? {};
|
|
||||||
const runId = readString(payload["runId"]);
|
|
||||||
if (!runId || !activeRunIds.has(runId)) return;
|
|
||||||
|
|
||||||
const run = runById.get(runId);
|
|
||||||
if (!run) return;
|
|
||||||
|
|
||||||
if (event.type === "heartbeat.run.event") {
|
|
||||||
const seq = typeof payload["seq"] === "number" ? payload["seq"] : null;
|
|
||||||
const eventType = readString(payload["eventType"]) ?? "event";
|
|
||||||
const messageText = readString(payload["message"]) ?? eventType;
|
|
||||||
const dedupeKey = `${runId}:event:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`;
|
|
||||||
if (seenKeysRef.current.has(dedupeKey)) return;
|
|
||||||
seenKeysRef.current.add(dedupeKey);
|
|
||||||
if (seenKeysRef.current.size > 2000) {
|
|
||||||
seenKeysRef.current.clear();
|
|
||||||
}
|
|
||||||
const tone = eventType === "error" ? "error" : eventType === "lifecycle" ? "warn" : "info";
|
|
||||||
const item = createFeedItem(run, event.createdAt, messageText, tone, nextIdRef.current++);
|
|
||||||
if (item) appendItems([item]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "heartbeat.run.status") {
|
|
||||||
const status = readString(payload["status"]) ?? "updated";
|
|
||||||
const dedupeKey = `${runId}:status:${status}:${readString(payload["finishedAt"]) ?? ""}`;
|
|
||||||
if (seenKeysRef.current.has(dedupeKey)) return;
|
|
||||||
seenKeysRef.current.add(dedupeKey);
|
|
||||||
if (seenKeysRef.current.size > 2000) {
|
|
||||||
seenKeysRef.current.clear();
|
|
||||||
}
|
|
||||||
const tone = status === "failed" || status === "timed_out" ? "error" : "warn";
|
|
||||||
const item = createFeedItem(run, event.createdAt, `run ${status}`, tone, nextIdRef.current++);
|
|
||||||
if (item) appendItems([item]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "heartbeat.run.log") {
|
|
||||||
const chunk = readString(payload["chunk"]);
|
|
||||||
if (!chunk) return;
|
|
||||||
const stream = readString(payload["stream"]) === "stderr" ? "stderr" : "stdout";
|
|
||||||
if (stream === "stderr") {
|
|
||||||
appendItems(parseStderrChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
appendItems(parseStdoutChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onerror = () => {
|
|
||||||
socket?.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onclose = () => {
|
|
||||||
scheduleReconnect();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
connect();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
closed = true;
|
|
||||||
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
|
|
||||||
if (socket) {
|
|
||||||
socket.onmessage = null;
|
|
||||||
socket.onerror = null;
|
|
||||||
socket.onclose = null;
|
|
||||||
socket.close(1000, "issue_live_widget_unmount");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [activeRunIds, companyId, runById]);
|
|
||||||
|
|
||||||
if (runs.length === 0 && feed.length === 0) return null;
|
|
||||||
|
|
||||||
const recent = feed.slice(-25);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-cyan-500/30 bg-background/80 overflow-hidden shadow-[0_0_12px_rgba(6,182,212,0.08)]">
|
<div className="overflow-hidden rounded-xl border border-cyan-500/25 bg-background/80 shadow-[0_18px_50px_rgba(6,182,212,0.08)]">
|
||||||
{runs.length > 0 ? (
|
<div className="border-b border-border/60 bg-cyan-500/[0.04] px-4 py-3">
|
||||||
runs.map((run) => (
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-700 dark:text-cyan-300">
|
||||||
<div key={run.id} className="px-3 py-2 border-b border-border/50">
|
Live Runs
|
||||||
<div className="flex items-center justify-between mb-2">
|
</div>
|
||||||
<Link to={`/agents/${run.agentId}`} className="hover:underline">
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
<Identity name={run.agentName} size="sm" />
|
Streamed with the same transcript UI used on the full run detail page.
|
||||||
</Link>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatDateTime(run.startedAt ?? run.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
|
||||||
<span className="text-muted-foreground">Run</span>
|
|
||||||
<Link
|
|
||||||
to={`/agents/${run.agentId}/runs/${run.id}`}
|
|
||||||
className="inline-flex items-center rounded-md border border-border bg-accent/40 px-2 py-1 font-mono text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors"
|
|
||||||
>
|
|
||||||
{run.id.slice(0, 8)}
|
|
||||||
</Link>
|
|
||||||
<StatusBadge status={run.status} />
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleCancelRun(run.id)}
|
|
||||||
disabled={cancellingRunIds.has(run.id)}
|
|
||||||
className="inline-flex items-center gap-1 text-[10px] text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Square className="h-2 w-2" fill="currentColor" />
|
|
||||||
{cancellingRunIds.has(run.id) ? "Stopping…" : "Stop"}
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
to={`/agents/${run.agentId}/runs/${run.id}`}
|
|
||||||
className="inline-flex items-center gap-1 text-[10px] text-cyan-600 hover:text-cyan-500 dark:text-cyan-300 dark:hover:text-cyan-200"
|
|
||||||
>
|
|
||||||
Open run
|
|
||||||
<ExternalLink className="h-2.5 w-2.5" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center px-3 py-2 border-b border-border/50">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">Recent run updates</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={bodyRef} className="max-h-[220px] overflow-y-auto p-2 font-mono text-[11px] space-y-1">
|
|
||||||
{recent.length === 0 && (
|
|
||||||
<div className="text-xs text-muted-foreground">Waiting for run output...</div>
|
|
||||||
)}
|
|
||||||
{recent.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className={cn(
|
|
||||||
"grid grid-cols-[auto_1fr] gap-2 items-start",
|
|
||||||
index === recent.length - 1 && "animate-in fade-in slide-in-from-bottom-1 duration-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="text-[10px] text-muted-foreground">{relativeTime(item.ts)}</span>
|
|
||||||
<div className={cn(
|
|
||||||
"min-w-0",
|
|
||||||
item.tone === "error" && "text-red-600 dark:text-red-300",
|
|
||||||
item.tone === "warn" && "text-amber-600 dark:text-amber-300",
|
|
||||||
item.tone === "assistant" && "text-emerald-700 dark:text-emerald-200",
|
|
||||||
item.tone === "tool" && "text-cyan-600 dark:text-cyan-300",
|
|
||||||
item.tone === "info" && "text-foreground/80",
|
|
||||||
)}>
|
|
||||||
<Identity name={item.agentName} size="sm" className="text-cyan-600 dark:text-cyan-400" />
|
|
||||||
<span className="text-muted-foreground"> [{item.runId.slice(0, 8)}] </span>
|
|
||||||
<span className="break-words">{item.text}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{runs.map((run) => {
|
||||||
|
const isActive = isRunActive(run.status);
|
||||||
|
const transcript = transcriptByRun.get(run.id) ?? [];
|
||||||
|
return (
|
||||||
|
<section key={run.id} className="px-4 py-4">
|
||||||
|
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link to={`/agents/${run.agentId}`} className="inline-flex hover:underline">
|
||||||
|
<Identity name={run.agentName} size="sm" />
|
||||||
|
</Link>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Link
|
||||||
|
to={`/agents/${run.agentId}/runs/${run.id}`}
|
||||||
|
className="inline-flex items-center rounded-full border border-border/70 bg-background/70 px-2 py-1 font-mono hover:border-cyan-500/30 hover:text-foreground"
|
||||||
|
>
|
||||||
|
{run.id.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
<StatusBadge status={run.status} />
|
||||||
|
<span>{formatDateTime(run.startedAt ?? run.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCancelRun(run.id)}
|
||||||
|
disabled={cancellingRunIds.has(run.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-red-500/20 bg-red-500/[0.06] px-2.5 py-1 text-[11px] font-medium text-red-700 transition-colors hover:bg-red-500/[0.12] dark:text-red-300 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Square className="h-2.5 w-2.5" fill="currentColor" />
|
||||||
|
{cancellingRunIds.has(run.id) ? "Stopping…" : "Stop"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to={`/agents/${run.agentId}/runs/${run.id}`}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/70 px-2.5 py-1 text-[11px] font-medium text-cyan-700 transition-colors hover:border-cyan-500/30 hover:text-cyan-600 dark:text-cyan-300"
|
||||||
|
>
|
||||||
|
Open run
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[320px] overflow-y-auto pr-1">
|
||||||
|
<RunTranscriptView
|
||||||
|
entries={transcript}
|
||||||
|
density="compact"
|
||||||
|
limit={8}
|
||||||
|
streaming={isActive}
|
||||||
|
collapseStdout
|
||||||
|
emptyMessage={hasOutputForRun(run.id) ? "Waiting for transcript parsing..." : "Waiting for run output..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Bot,
|
Bot,
|
||||||
Code,
|
Code,
|
||||||
|
Gem,
|
||||||
MousePointer2,
|
MousePointer2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -24,6 +25,7 @@ import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
|||||||
type AdvancedAdapterType =
|
type AdvancedAdapterType =
|
||||||
| "claude_local"
|
| "claude_local"
|
||||||
| "codex_local"
|
| "codex_local"
|
||||||
|
| "gemini_local"
|
||||||
| "opencode_local"
|
| "opencode_local"
|
||||||
| "pi_local"
|
| "pi_local"
|
||||||
| "cursor"
|
| "cursor"
|
||||||
@@ -50,6 +52,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
|
|||||||
desc: "Local Codex agent",
|
desc: "Local Codex agent",
|
||||||
recommended: true,
|
recommended: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "gemini_local",
|
||||||
|
label: "Gemini CLI",
|
||||||
|
icon: Gem,
|
||||||
|
desc: "Local Gemini agent",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "opencode_local",
|
value: "opencode_local",
|
||||||
label: "OpenCode",
|
label: "OpenCode",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
DEFAULT_CODEX_LOCAL_MODEL
|
DEFAULT_CODEX_LOCAL_MODEL
|
||||||
} from "@paperclipai/adapter-codex-local";
|
} from "@paperclipai/adapter-codex-local";
|
||||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||||
|
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||||
import { HintIcon } from "./agent-config-primitives";
|
import { HintIcon } from "./agent-config-primitives";
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Bot,
|
Bot,
|
||||||
Code,
|
Code,
|
||||||
|
Gem,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Rocket,
|
Rocket,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -51,6 +53,7 @@ type Step = 1 | 2 | 3 | 4;
|
|||||||
type AdapterType =
|
type AdapterType =
|
||||||
| "claude_local"
|
| "claude_local"
|
||||||
| "codex_local"
|
| "codex_local"
|
||||||
|
| "gemini_local"
|
||||||
| "opencode_local"
|
| "opencode_local"
|
||||||
| "pi_local"
|
| "pi_local"
|
||||||
| "cursor"
|
| "cursor"
|
||||||
@@ -165,11 +168,17 @@ export function OnboardingWizard() {
|
|||||||
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
|
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
|
||||||
});
|
});
|
||||||
const isLocalAdapter =
|
const isLocalAdapter =
|
||||||
adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local" || adapterType === "cursor";
|
adapterType === "claude_local" ||
|
||||||
|
adapterType === "codex_local" ||
|
||||||
|
adapterType === "gemini_local" ||
|
||||||
|
adapterType === "opencode_local" ||
|
||||||
|
adapterType === "cursor";
|
||||||
const effectiveAdapterCommand =
|
const effectiveAdapterCommand =
|
||||||
command.trim() ||
|
command.trim() ||
|
||||||
(adapterType === "codex_local"
|
(adapterType === "codex_local"
|
||||||
? "codex"
|
? "codex"
|
||||||
|
: adapterType === "gemini_local"
|
||||||
|
? "gemini"
|
||||||
: adapterType === "cursor"
|
: adapterType === "cursor"
|
||||||
? "agent"
|
? "agent"
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
@@ -268,6 +277,8 @@ export function OnboardingWizard() {
|
|||||||
model:
|
model:
|
||||||
adapterType === "codex_local"
|
adapterType === "codex_local"
|
||||||
? model || DEFAULT_CODEX_LOCAL_MODEL
|
? model || DEFAULT_CODEX_LOCAL_MODEL
|
||||||
|
: adapterType === "gemini_local"
|
||||||
|
? model || DEFAULT_GEMINI_LOCAL_MODEL
|
||||||
: adapterType === "cursor"
|
: adapterType === "cursor"
|
||||||
? model || DEFAULT_CURSOR_LOCAL_MODEL
|
? model || DEFAULT_CURSOR_LOCAL_MODEL
|
||||||
: model,
|
: model,
|
||||||
@@ -655,6 +666,12 @@ export function OnboardingWizard() {
|
|||||||
desc: "Local Codex agent",
|
desc: "Local Codex agent",
|
||||||
recommended: true
|
recommended: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "gemini_local" as const,
|
||||||
|
label: "Gemini CLI",
|
||||||
|
icon: Gem,
|
||||||
|
desc: "Local Gemini agent"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "opencode_local" as const,
|
value: "opencode_local" as const,
|
||||||
label: "OpenCode",
|
label: "OpenCode",
|
||||||
@@ -699,6 +716,8 @@ export function OnboardingWizard() {
|
|||||||
setAdapterType(nextType);
|
setAdapterType(nextType);
|
||||||
if (nextType === "codex_local" && !model) {
|
if (nextType === "codex_local" && !model) {
|
||||||
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
||||||
|
} else if (nextType === "gemini_local" && !model) {
|
||||||
|
setModel(DEFAULT_GEMINI_LOCAL_MODEL);
|
||||||
} else if (nextType === "cursor" && !model) {
|
} else if (nextType === "cursor" && !model) {
|
||||||
setModel(DEFAULT_CURSOR_LOCAL_MODEL);
|
setModel(DEFAULT_CURSOR_LOCAL_MODEL);
|
||||||
}
|
}
|
||||||
@@ -732,6 +751,7 @@ export function OnboardingWizard() {
|
|||||||
{/* Conditional adapter fields */}
|
{/* Conditional adapter fields */}
|
||||||
{(adapterType === "claude_local" ||
|
{(adapterType === "claude_local" ||
|
||||||
adapterType === "codex_local" ||
|
adapterType === "codex_local" ||
|
||||||
|
adapterType === "gemini_local" ||
|
||||||
adapterType === "opencode_local" ||
|
adapterType === "opencode_local" ||
|
||||||
adapterType === "pi_local" ||
|
adapterType === "pi_local" ||
|
||||||
adapterType === "cursor") && (
|
adapterType === "cursor") && (
|
||||||
@@ -904,6 +924,8 @@ export function OnboardingWizard() {
|
|||||||
? `${effectiveAdapterCommand} -p --mode ask --output-format json \"Respond with hello.\"`
|
? `${effectiveAdapterCommand} -p --mode ask --output-format json \"Respond with hello.\"`
|
||||||
: adapterType === "codex_local"
|
: adapterType === "codex_local"
|
||||||
? `${effectiveAdapterCommand} exec --json -`
|
? `${effectiveAdapterCommand} exec --json -`
|
||||||
|
: adapterType === "gemini_local"
|
||||||
|
? `${effectiveAdapterCommand} --output-format json \"Respond with hello.\"`
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? `${effectiveAdapterCommand} run --format json "Respond with hello."`
|
? `${effectiveAdapterCommand} run --format json "Respond with hello."`
|
||||||
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
|
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
|
||||||
@@ -912,11 +934,15 @@ export function OnboardingWizard() {
|
|||||||
Prompt:{" "}
|
Prompt:{" "}
|
||||||
<span className="font-mono">Respond with hello.</span>
|
<span className="font-mono">Respond with hello.</span>
|
||||||
</p>
|
</p>
|
||||||
{adapterType === "cursor" || adapterType === "codex_local" || adapterType === "opencode_local" ? (
|
{adapterType === "cursor" || adapterType === "codex_local" || adapterType === "gemini_local" || adapterType === "opencode_local" ? (
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
If auth fails, set{" "}
|
If auth fails, set{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{adapterType === "cursor" ? "CURSOR_API_KEY" : "OPENAI_API_KEY"}
|
{adapterType === "cursor"
|
||||||
|
? "CURSOR_API_KEY"
|
||||||
|
: adapterType === "gemini_local"
|
||||||
|
? "GEMINI_API_KEY"
|
||||||
|
: "OPENAI_API_KEY"}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
in
|
in
|
||||||
env or run{" "}
|
env or run{" "}
|
||||||
@@ -925,6 +951,8 @@ export function OnboardingWizard() {
|
|||||||
? "agent login"
|
? "agent login"
|
||||||
: adapterType === "codex_local"
|
: adapterType === "codex_local"
|
||||||
? "codex login"
|
? "codex login"
|
||||||
|
: adapterType === "gemini_local"
|
||||||
|
? "gemini auth"
|
||||||
: "opencode auth login"}
|
: "opencode auth login"}
|
||||||
</span>.
|
</span>.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const help: Record<string, string> = {
|
|||||||
export const adapterLabels: Record<string, string> = {
|
export const adapterLabels: Record<string, string> = {
|
||||||
claude_local: "Claude (local)",
|
claude_local: "Claude (local)",
|
||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
|
gemini_local: "Gemini CLI (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
|
|||||||
1000
ui/src/components/transcript/RunTranscriptView.tsx
Normal file
1000
ui/src/components/transcript/RunTranscriptView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
283
ui/src/components/transcript/useLiveRunTranscripts.ts
Normal file
283
ui/src/components/transcript/useLiveRunTranscripts.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { LiveEvent } from "@paperclipai/shared";
|
||||||
|
import { heartbeatsApi, type LiveRunForIssue } from "../../api/heartbeats";
|
||||||
|
import { buildTranscript, getUIAdapter, type RunLogChunk, type TranscriptEntry } from "../../adapters";
|
||||||
|
|
||||||
|
const LOG_POLL_INTERVAL_MS = 2000;
|
||||||
|
const LOG_READ_LIMIT_BYTES = 256_000;
|
||||||
|
|
||||||
|
interface UseLiveRunTranscriptsOptions {
|
||||||
|
runs: LiveRunForIssue[];
|
||||||
|
companyId?: string | null;
|
||||||
|
maxChunksPerRun?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalStatus(status: string): boolean {
|
||||||
|
return status === "failed" || status === "timed_out" || status === "cancelled" || status === "succeeded";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePersistedLogContent(
|
||||||
|
runId: string,
|
||||||
|
content: string,
|
||||||
|
pendingByRun: Map<string, string>,
|
||||||
|
): Array<RunLogChunk & { dedupeKey: string }> {
|
||||||
|
if (!content) return [];
|
||||||
|
|
||||||
|
const pendingKey = `${runId}:records`;
|
||||||
|
const combined = `${pendingByRun.get(pendingKey) ?? ""}${content}`;
|
||||||
|
const split = combined.split("\n");
|
||||||
|
pendingByRun.set(pendingKey, split.pop() ?? "");
|
||||||
|
|
||||||
|
const parsed: Array<RunLogChunk & { dedupeKey: string }> = [];
|
||||||
|
for (const line of split) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown };
|
||||||
|
const stream = raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout";
|
||||||
|
const chunk = typeof raw.chunk === "string" ? raw.chunk : "";
|
||||||
|
const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString();
|
||||||
|
if (!chunk) continue;
|
||||||
|
parsed.push({
|
||||||
|
ts,
|
||||||
|
stream,
|
||||||
|
chunk,
|
||||||
|
dedupeKey: `log:${runId}:${ts}:${stream}:${chunk}`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed log rows.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLiveRunTranscripts({
|
||||||
|
runs,
|
||||||
|
companyId,
|
||||||
|
maxChunksPerRun = 200,
|
||||||
|
}: UseLiveRunTranscriptsOptions) {
|
||||||
|
const [chunksByRun, setChunksByRun] = useState<Map<string, RunLogChunk[]>>(new Map());
|
||||||
|
const seenChunkKeysRef = useRef(new Set<string>());
|
||||||
|
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
|
||||||
|
const logOffsetByRunRef = useRef(new Map<string, number>());
|
||||||
|
|
||||||
|
const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]);
|
||||||
|
const activeRunIds = useMemo(
|
||||||
|
() => new Set(runs.filter((run) => !isTerminalStatus(run.status)).map((run) => run.id)),
|
||||||
|
[runs],
|
||||||
|
);
|
||||||
|
const runIdsKey = useMemo(
|
||||||
|
() => runs.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","),
|
||||||
|
[runs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const appendChunks = (runId: string, chunks: Array<RunLogChunk & { dedupeKey: string }>) => {
|
||||||
|
if (chunks.length === 0) return;
|
||||||
|
setChunksByRun((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = [...(next.get(runId) ?? [])];
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
if (seenChunkKeysRef.current.has(chunk.dedupeKey)) continue;
|
||||||
|
seenChunkKeysRef.current.add(chunk.dedupeKey);
|
||||||
|
existing.push({ ts: chunk.ts, stream: chunk.stream, chunk: chunk.chunk });
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return prev;
|
||||||
|
if (seenChunkKeysRef.current.size > 12000) {
|
||||||
|
seenChunkKeysRef.current.clear();
|
||||||
|
}
|
||||||
|
next.set(runId, existing.slice(-maxChunksPerRun));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const knownRunIds = new Set(runs.map((run) => run.id));
|
||||||
|
setChunksByRun((prev) => {
|
||||||
|
const next = new Map<string, RunLogChunk[]>();
|
||||||
|
for (const [runId, chunks] of prev) {
|
||||||
|
if (knownRunIds.has(runId)) {
|
||||||
|
next.set(runId, chunks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next.size === prev.size ? prev : next;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const key of pendingLogRowsByRunRef.current.keys()) {
|
||||||
|
const runId = key.replace(/:records$/, "");
|
||||||
|
if (!knownRunIds.has(runId)) {
|
||||||
|
pendingLogRowsByRunRef.current.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const runId of logOffsetByRunRef.current.keys()) {
|
||||||
|
if (!knownRunIds.has(runId)) {
|
||||||
|
logOffsetByRunRef.current.delete(runId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [runs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (runs.length === 0) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const readRunLog = async (run: LiveRunForIssue) => {
|
||||||
|
const offset = logOffsetByRunRef.current.get(run.id) ?? 0;
|
||||||
|
try {
|
||||||
|
const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
appendChunks(run.id, parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current));
|
||||||
|
|
||||||
|
if (result.nextOffset !== undefined) {
|
||||||
|
logOffsetByRunRef.current.set(run.id, result.nextOffset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.content.length > 0) {
|
||||||
|
logOffsetByRunRef.current.set(run.id, offset + result.content.length);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore log read errors while output is initializing.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const readAll = async () => {
|
||||||
|
await Promise.all(runs.map((run) => readRunLog(run)));
|
||||||
|
};
|
||||||
|
|
||||||
|
void readAll();
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void readAll();
|
||||||
|
}, LOG_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [runIdsKey, runs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!companyId || activeRunIds.size === 0) return;
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
let socket: WebSocket | null = null;
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (closed) return;
|
||||||
|
reconnectTimer = window.setTimeout(connect, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (closed) return;
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`;
|
||||||
|
socket = new WebSocket(url);
|
||||||
|
|
||||||
|
socket.onmessage = (message) => {
|
||||||
|
const raw = typeof message.data === "string" ? message.data : "";
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
let event: LiveEvent;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(raw) as LiveEvent;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.companyId !== companyId) return;
|
||||||
|
const payload = event.payload ?? {};
|
||||||
|
const runId = readString(payload["runId"]);
|
||||||
|
if (!runId || !activeRunIds.has(runId)) return;
|
||||||
|
if (!runById.has(runId)) return;
|
||||||
|
|
||||||
|
if (event.type === "heartbeat.run.log") {
|
||||||
|
const chunk = readString(payload["chunk"]);
|
||||||
|
if (!chunk) return;
|
||||||
|
const ts = readString(payload["ts"]) ?? event.createdAt;
|
||||||
|
const stream =
|
||||||
|
readString(payload["stream"]) === "stderr"
|
||||||
|
? "stderr"
|
||||||
|
: readString(payload["stream"]) === "system"
|
||||||
|
? "system"
|
||||||
|
: "stdout";
|
||||||
|
appendChunks(runId, [{
|
||||||
|
ts,
|
||||||
|
stream,
|
||||||
|
chunk,
|
||||||
|
dedupeKey: `log:${runId}:${ts}:${stream}:${chunk}`,
|
||||||
|
}]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "heartbeat.run.event") {
|
||||||
|
const seq = typeof payload["seq"] === "number" ? payload["seq"] : null;
|
||||||
|
const eventType = readString(payload["eventType"]) ?? "event";
|
||||||
|
const messageText = readString(payload["message"]) ?? eventType;
|
||||||
|
appendChunks(runId, [{
|
||||||
|
ts: event.createdAt,
|
||||||
|
stream: eventType === "error" ? "stderr" : "system",
|
||||||
|
chunk: messageText,
|
||||||
|
dedupeKey: `socket:event:${runId}:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`,
|
||||||
|
}]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "heartbeat.run.status") {
|
||||||
|
const status = readString(payload["status"]) ?? "updated";
|
||||||
|
appendChunks(runId, [{
|
||||||
|
ts: event.createdAt,
|
||||||
|
stream: isTerminalStatus(status) && status !== "succeeded" ? "stderr" : "system",
|
||||||
|
chunk: `run ${status}`,
|
||||||
|
dedupeKey: `socket:status:${runId}:${status}:${readString(payload["finishedAt"]) ?? ""}`,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
socket?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
|
||||||
|
if (socket) {
|
||||||
|
socket.onmessage = null;
|
||||||
|
socket.onerror = null;
|
||||||
|
socket.onclose = null;
|
||||||
|
socket.close(1000, "live_run_transcripts_unmount");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [activeRunIds, companyId, runById]);
|
||||||
|
|
||||||
|
const transcriptByRun = useMemo(() => {
|
||||||
|
const next = new Map<string, TranscriptEntry[]>();
|
||||||
|
for (const run of runs) {
|
||||||
|
const adapter = getUIAdapter(run.adapterType);
|
||||||
|
next.set(run.id, buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}, [chunksByRun, runs]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
transcriptByRun,
|
||||||
|
hasOutputForRun(runId: string) {
|
||||||
|
return (chunksByRun.get(runId)?.length ?? 0) > 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
226
ui/src/fixtures/runTranscriptFixtures.ts
Normal file
226
ui/src/fixtures/runTranscriptFixtures.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import type { TranscriptEntry } from "../adapters";
|
||||||
|
|
||||||
|
export interface RunTranscriptFixtureMeta {
|
||||||
|
sourceRunId: string;
|
||||||
|
fixtureLabel: string;
|
||||||
|
agentName: string;
|
||||||
|
agentId: string;
|
||||||
|
issueIdentifier: string;
|
||||||
|
issueTitle: string;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runTranscriptFixtureMeta: RunTranscriptFixtureMeta = {
|
||||||
|
sourceRunId: "65a79d5d-5f85-4392-a5cc-8fb48beb9e71",
|
||||||
|
fixtureLabel: "Sanitized development fixture",
|
||||||
|
agentName: "CodexCoder",
|
||||||
|
agentId: "codexcoder-fixture",
|
||||||
|
issueIdentifier: "PAP-473",
|
||||||
|
issueTitle: "Humanize run transcripts across run detail and live surfaces",
|
||||||
|
startedAt: "2026-03-11T15:21:05.948Z",
|
||||||
|
finishedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sanitized from a real development run. Paths, secrets, env vars, and user-local identifiers
|
||||||
|
// are replaced with safe placeholders while preserving the interaction shape.
|
||||||
|
export const runTranscriptFixtureEntries: TranscriptEntry[] = [
|
||||||
|
{
|
||||||
|
kind: "stderr",
|
||||||
|
ts: "2026-03-11T15:21:05.594Z",
|
||||||
|
text: "[paperclip] Skipping saved session resume for task \"PAP-473\" because wake reason is issue_assigned.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "init",
|
||||||
|
ts: "2026-03-11T15:21:05.948Z",
|
||||||
|
model: "codex",
|
||||||
|
sessionId: "sess_fixture_01",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "system",
|
||||||
|
ts: "2026-03-11T15:21:07.249Z",
|
||||||
|
text: "turn started",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "assistant",
|
||||||
|
ts: "2026-03-11T15:21:18.851Z",
|
||||||
|
text: "I’m starting with the Paperclip heartbeat procedure and the repo-required docs so I can see what task is actually assigned and then work only on that scope.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts: "2026-03-11T15:21:19.026Z",
|
||||||
|
name: "command_execution",
|
||||||
|
toolUseId: "item_1",
|
||||||
|
input: {
|
||||||
|
command: "sed -n '1,220p' doc/GOAL.md",
|
||||||
|
cwd: "/workspace/paperclip",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts: "2026-03-11T15:21:19.026Z",
|
||||||
|
name: "command_execution",
|
||||||
|
toolUseId: "item_2",
|
||||||
|
input: {
|
||||||
|
command: "sed -n '1,320p' doc/SPEC-implementation.md",
|
||||||
|
cwd: "/workspace/paperclip",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts: "2026-03-11T15:21:19.034Z",
|
||||||
|
toolUseId: "item_1",
|
||||||
|
content: "command: sed -n '1,220p' doc/GOAL.md\nstatus: completed\nexit_code: 0\n\n# Paperclip\n\nPaperclip is the backbone of the autonomous economy.",
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts: "2026-03-11T15:21:19.035Z",
|
||||||
|
toolUseId: "item_2",
|
||||||
|
content: "command: sed -n '1,320p' doc/SPEC-implementation.md\nstatus: completed\nexit_code: 0\n\n# Paperclip V1 Implementation Spec\n\nPaperclip V1 must provide a full control-plane loop for autonomous agents.",
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "assistant",
|
||||||
|
ts: "2026-03-11T15:22:08.806Z",
|
||||||
|
text: "I’ve got the rendering entry points. I’m reading the existing transcript parser and the three UI surfaces together so I can collapse them onto one renderer instead of layering another one-off view.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "thinking",
|
||||||
|
ts: "2026-03-11T15:22:12.044Z",
|
||||||
|
text: "The current transcript UX duplicates parsing logic in multiple surfaces. A shared presentation layer will make the detail page and live surfaces behave the same way while keeping raw fallback available.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts: "2026-03-11T15:22:15.401Z",
|
||||||
|
name: "command_execution",
|
||||||
|
toolUseId: "item_3",
|
||||||
|
input: {
|
||||||
|
command: "rg -n \"LiveRunWidget|ActiveAgentsPanel|buildTranscript|TranscriptEntry|transcript\" ui/src server/src packages",
|
||||||
|
cwd: "/workspace/paperclip",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts: "2026-03-11T15:22:15.982Z",
|
||||||
|
toolUseId: "item_3",
|
||||||
|
content: "command: rg -n ...\nstatus: completed\nexit_code: 0\n\nui/src/components/ActiveAgentsPanel.tsx\nui/src/components/LiveRunWidget.tsx\nui/src/pages/AgentDetail.tsx",
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "system",
|
||||||
|
ts: "2026-03-11T15:22:21.617Z",
|
||||||
|
text: "item started: web_search (id=ws_fixture_reasoning_ui)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "system",
|
||||||
|
ts: "2026-03-11T15:22:24.806Z",
|
||||||
|
text: "item completed: web_search (id=ws_fixture_reasoning_ui)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "assistant",
|
||||||
|
ts: "2026-03-11T15:22:29.214Z",
|
||||||
|
text: "I’ve confirmed the duplication: the run page has the only full transcript renderer, while live surfaces still collapse output into feed strings. I’m pulling markdown utilities and adapter shapes now so the new renderer can stay shared.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts: "2026-03-11T15:23:04.251Z",
|
||||||
|
name: "command_execution",
|
||||||
|
toolUseId: "item_4",
|
||||||
|
input: {
|
||||||
|
command: "pnpm -r typecheck",
|
||||||
|
cwd: "/workspace/paperclip",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts: "2026-03-11T15:23:26.903Z",
|
||||||
|
toolUseId: "item_4",
|
||||||
|
content: "command: pnpm -r typecheck\nstatus: completed\nexit_code: 0\n\npackages/adapter-utils typecheck: Done\nserver typecheck: Done\nui typecheck: Done\ncli typecheck: Done",
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "assistant",
|
||||||
|
ts: "2026-03-11T15:23:34.102Z",
|
||||||
|
text: "Typecheck is clean. I’m running the remaining repo checks now so the transcript work is verified under the full workspace build, not just the UI package.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts: "2026-03-11T15:23:40.511Z",
|
||||||
|
name: "command_execution",
|
||||||
|
toolUseId: "item_5",
|
||||||
|
input: {
|
||||||
|
command: "pnpm test:run",
|
||||||
|
cwd: "/workspace/paperclip",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts: "2026-03-11T15:23:56.944Z",
|
||||||
|
toolUseId: "item_5",
|
||||||
|
content: "command: pnpm test:run\nstatus: failed\nexit_code: 1\n\nFAIL packages/db/src/runtime-config.test.ts\nFAIL src/__tests__/worktree.test.ts\n\nAll transcript-adapter tests passed after snapshot updates.",
|
||||||
|
isError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "thinking",
|
||||||
|
ts: "2026-03-11T15:24:02.188Z",
|
||||||
|
text: "The remaining failures are unrelated to transcript rendering, so I should isolate them in the handoff and keep the UI change set focused.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "assistant",
|
||||||
|
ts: "2026-03-11T15:24:08.490Z",
|
||||||
|
text: "The new parser metadata changed a few adapter test snapshots exactly where expected, and the remaining red tests are outside this change set. I’m keeping the transcript patch focused and shipping with that caveat called out.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts: "2026-03-11T15:24:21.327Z",
|
||||||
|
name: "command_execution",
|
||||||
|
toolUseId: "item_6",
|
||||||
|
input: {
|
||||||
|
command: "pnpm build",
|
||||||
|
cwd: "/workspace/paperclip",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts: "2026-03-11T15:24:44.030Z",
|
||||||
|
toolUseId: "item_6",
|
||||||
|
content: "command: pnpm build\nstatus: completed\nexit_code: 0\n\nui build: built successfully\ncli build: Done",
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts: "2026-03-11T15:24:52.504Z",
|
||||||
|
name: "command_execution",
|
||||||
|
toolUseId: "item_7",
|
||||||
|
input: {
|
||||||
|
command: "git commit -m \"Humanize run transcripts across run detail and live surfaces\"",
|
||||||
|
cwd: "/workspace/paperclip",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts: "2026-03-11T15:24:53.104Z",
|
||||||
|
toolUseId: "item_7",
|
||||||
|
content: "command: git commit ...\nstatus: completed\nexit_code: 0\n\n87b8e21 Humanize run transcripts across run detail and live surfaces",
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "assistant",
|
||||||
|
ts: "2026-03-11T15:25:00.908Z",
|
||||||
|
text: "Implemented the transcript UI refactor and switched the run detail page, issue live widget, and dashboard agent card onto the same rendering language.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "result",
|
||||||
|
ts: "2026-03-11T15:25:05.440Z",
|
||||||
|
text: "Transcript rollout complete with shared nice/raw rendering and compact live variants.",
|
||||||
|
inputTokens: 11240,
|
||||||
|
outputTokens: 3460,
|
||||||
|
cachedTokens: 520,
|
||||||
|
costUsd: 0.048121,
|
||||||
|
subtype: "success",
|
||||||
|
isError: false,
|
||||||
|
errors: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
@@ -17,7 +17,6 @@ import { AgentConfigForm } from "../components/AgentConfigForm";
|
|||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { adapterLabels, roleLabels } from "../components/agent-config-primitives";
|
import { adapterLabels, roleLabels } from "../components/agent-config-primitives";
|
||||||
import { getUIAdapter, buildTranscript } from "../adapters";
|
import { getUIAdapter, buildTranscript } from "../adapters";
|
||||||
import type { TranscriptEntry } from "../adapters";
|
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
|
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
|
||||||
import { MarkdownBody } from "../components/MarkdownBody";
|
import { MarkdownBody } from "../components/MarkdownBody";
|
||||||
@@ -58,6 +57,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||||
|
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
||||||
import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent } from "@paperclipai/shared";
|
import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent } from "@paperclipai/shared";
|
||||||
import { agentRouteRef } from "../lib/utils";
|
import { agentRouteRef } from "../lib/utils";
|
||||||
|
|
||||||
@@ -1675,6 +1675,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
const [logOffset, setLogOffset] = useState(0);
|
const [logOffset, setLogOffset] = useState(0);
|
||||||
const [isFollowing, setIsFollowing] = useState(false);
|
const [isFollowing, setIsFollowing] = useState(false);
|
||||||
const [isStreamingConnected, setIsStreamingConnected] = useState(false);
|
const [isStreamingConnected, setIsStreamingConnected] = useState(false);
|
||||||
|
const [transcriptMode, setTranscriptMode] = useState<TranscriptMode>("nice");
|
||||||
const logEndRef = useRef<HTMLDivElement>(null);
|
const logEndRef = useRef<HTMLDivElement>(null);
|
||||||
const pendingLogLineRef = useRef("");
|
const pendingLogLineRef = useRef("");
|
||||||
const scrollContainerRef = useRef<ScrollContainer | null>(null);
|
const scrollContainerRef = useRef<ScrollContainer | null>(null);
|
||||||
@@ -2028,6 +2029,10 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||||
const transcript = useMemo(() => buildTranscript(logLines, adapter.parseStdoutLine), [logLines, adapter]);
|
const transcript = useMemo(() => buildTranscript(logLines, adapter.parseStdoutLine), [logLines, adapter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTranscriptMode("nice");
|
||||||
|
}, [run.id]);
|
||||||
|
|
||||||
if (loading && logLoading) {
|
if (loading && logLoading) {
|
||||||
return <p className="text-xs text-muted-foreground">Loading run logs...</p>;
|
return <p className="text-xs text-muted-foreground">Loading run logs...</p>;
|
||||||
}
|
}
|
||||||
@@ -2120,6 +2125,23 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
Transcript ({transcript.length})
|
Transcript ({transcript.length})
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="inline-flex rounded-lg border border-border/70 bg-background/70 p-0.5">
|
||||||
|
{(["nice", "raw"] as const).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-2.5 py-1 text-[11px] font-medium capitalize transition-colors",
|
||||||
|
transcriptMode === mode
|
||||||
|
? "bg-accent text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => setTranscriptMode(mode)}
|
||||||
|
>
|
||||||
|
{mode}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
{isLive && !isFollowing && (
|
{isLive && !isFollowing && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -2146,123 +2168,18 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-neutral-100 dark:bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5 overflow-x-hidden">
|
<div className="max-h-[38rem] overflow-y-auto rounded-2xl border border-border/70 bg-background/40 p-3 sm:p-4">
|
||||||
{transcript.length === 0 && !run.logRef && (
|
<RunTranscriptView
|
||||||
<div className="text-neutral-500">No persisted transcript for this run.</div>
|
entries={transcript}
|
||||||
|
mode={transcriptMode}
|
||||||
|
streaming={isLive}
|
||||||
|
emptyMessage={run.logRef ? "Waiting for transcript..." : "No persisted transcript for this run."}
|
||||||
|
/>
|
||||||
|
{logError && (
|
||||||
|
<div className="mt-3 rounded-xl border border-red-500/20 bg-red-500/[0.06] px-3 py-2 text-xs text-red-700 dark:text-red-300">
|
||||||
|
{logError}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{transcript.map((entry, idx) => {
|
|
||||||
const time = new Date(entry.ts).toLocaleTimeString("en-US", { hour12: false });
|
|
||||||
const grid = "grid grid-cols-[auto_auto_1fr] gap-x-2 sm:gap-x-3 items-baseline";
|
|
||||||
const tsCell = "text-neutral-400 dark:text-neutral-600 select-none w-12 sm:w-16 text-[10px] sm:text-xs";
|
|
||||||
const lblCell = "w-14 sm:w-20 text-[10px] sm:text-xs";
|
|
||||||
const contentCell = "min-w-0 whitespace-pre-wrap break-words overflow-hidden";
|
|
||||||
const expandCell = "col-span-full md:col-start-3 md:col-span-1";
|
|
||||||
|
|
||||||
if (entry.kind === "assistant") {
|
|
||||||
return (
|
|
||||||
<div key={`${entry.ts}-assistant-${idx}`} className={cn(grid, "py-0.5")}>
|
|
||||||
<span className={tsCell}>{time}</span>
|
|
||||||
<span className={cn(lblCell, "text-green-700 dark:text-green-300")}>assistant</span>
|
|
||||||
<span className={cn(contentCell, "text-green-900 dark:text-green-100")}>{entry.text}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.kind === "thinking") {
|
|
||||||
return (
|
|
||||||
<div key={`${entry.ts}-thinking-${idx}`} className={cn(grid, "py-0.5")}>
|
|
||||||
<span className={tsCell}>{time}</span>
|
|
||||||
<span className={cn(lblCell, "text-green-600/60 dark:text-green-300/60")}>thinking</span>
|
|
||||||
<span className={cn(contentCell, "text-green-800/60 dark:text-green-100/60 italic")}>{entry.text}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.kind === "user") {
|
|
||||||
return (
|
|
||||||
<div key={`${entry.ts}-user-${idx}`} className={cn(grid, "py-0.5")}>
|
|
||||||
<span className={tsCell}>{time}</span>
|
|
||||||
<span className={cn(lblCell, "text-neutral-500 dark:text-neutral-400")}>user</span>
|
|
||||||
<span className={cn(contentCell, "text-neutral-700 dark:text-neutral-300")}>{entry.text}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.kind === "tool_call") {
|
|
||||||
return (
|
|
||||||
<div key={`${entry.ts}-tool-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
|
|
||||||
<span className={tsCell}>{time}</span>
|
|
||||||
<span className={cn(lblCell, "text-yellow-700 dark:text-yellow-300")}>tool_call</span>
|
|
||||||
<span className="text-yellow-900 dark:text-yellow-100 min-w-0">{entry.name}</span>
|
|
||||||
<pre className={cn(expandCell, "bg-neutral-200 dark:bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-800 dark:text-neutral-200")}>
|
|
||||||
{JSON.stringify(entry.input, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.kind === "tool_result") {
|
|
||||||
return (
|
|
||||||
<div key={`${entry.ts}-toolres-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
|
|
||||||
<span className={tsCell}>{time}</span>
|
|
||||||
<span className={cn(lblCell, entry.isError ? "text-red-600 dark:text-red-300" : "text-purple-600 dark:text-purple-300")}>tool_result</span>
|
|
||||||
{entry.isError ? <span className="text-red-600 dark:text-red-400 min-w-0">error</span> : <span />}
|
|
||||||
<pre className={cn(expandCell, "bg-neutral-100 dark:bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-700 dark:text-neutral-300 max-h-60 overflow-y-auto")}>
|
|
||||||
{(() => { try { return JSON.stringify(JSON.parse(entry.content), null, 2); } catch { return entry.content; } })()}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.kind === "init") {
|
|
||||||
return (
|
|
||||||
<div key={`${entry.ts}-init-${idx}`} className={grid}>
|
|
||||||
<span className={tsCell}>{time}</span>
|
|
||||||
<span className={cn(lblCell, "text-blue-700 dark:text-blue-300")}>init</span>
|
|
||||||
<span className={cn(contentCell, "text-blue-900 dark:text-blue-100")}>model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.kind === "result") {
|
|
||||||
return (
|
|
||||||
<div key={`${entry.ts}-result-${idx}`} className={cn(grid, "gap-y-1 py-0.5")}>
|
|
||||||
<span className={tsCell}>{time}</span>
|
|
||||||
<span className={cn(lblCell, "text-cyan-700 dark:text-cyan-300")}>result</span>
|
|
||||||
<span className={cn(contentCell, "text-cyan-900 dark:text-cyan-100")}>
|
|
||||||
tokens in={formatTokens(entry.inputTokens)} out={formatTokens(entry.outputTokens)} cached={formatTokens(entry.cachedTokens)} cost=${entry.costUsd.toFixed(6)}
|
|
||||||
</span>
|
|
||||||
{(entry.subtype || entry.isError || entry.errors.length > 0) && (
|
|
||||||
<div className={cn(expandCell, "text-red-600 dark:text-red-300 whitespace-pre-wrap break-words")}>
|
|
||||||
subtype={entry.subtype || "unknown"} is_error={entry.isError ? "true" : "false"}
|
|
||||||
{entry.errors.length > 0 ? ` errors=${entry.errors.join(" | ")}` : ""}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{entry.text && (
|
|
||||||
<div className={cn(expandCell, "whitespace-pre-wrap break-words text-neutral-800 dark:text-neutral-100")}>{entry.text}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawText = entry.text;
|
|
||||||
const label =
|
|
||||||
entry.kind === "stderr" ? "stderr" :
|
|
||||||
entry.kind === "system" ? "system" :
|
|
||||||
"stdout";
|
|
||||||
const color =
|
|
||||||
entry.kind === "stderr" ? "text-red-600 dark:text-red-300" :
|
|
||||||
entry.kind === "system" ? "text-blue-600 dark:text-blue-300" :
|
|
||||||
"text-neutral-500";
|
|
||||||
return (
|
|
||||||
<div key={`${entry.ts}-raw-${idx}`} className={grid}>
|
|
||||||
<span className={tsCell}>{time}</span>
|
|
||||||
<span className={cn(lblCell, color)}>{label}</span>
|
|
||||||
<span className={cn(contentCell, color)}>{rawText}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{logError && <div className="text-red-600 dark:text-red-300">{logError}</div>}
|
|
||||||
<div ref={logEndRef} />
|
<div ref={logEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
|
|||||||
const adapterLabels: Record<string, string> = {
|
const adapterLabels: Record<string, string> = {
|
||||||
claude_local: "Claude",
|
claude_local: "Claude",
|
||||||
codex_local: "Codex",
|
codex_local: "Codex",
|
||||||
|
gemini_local: "Gemini",
|
||||||
opencode_local: "OpenCode",
|
opencode_local: "OpenCode",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
|
|||||||
const adapterLabels: Record<string, string> = {
|
const adapterLabels: Record<string, string> = {
|
||||||
claude_local: "Claude (local)",
|
claude_local: "Claude (local)",
|
||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
|
gemini_local: "Gemini CLI (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
@@ -22,7 +23,7 @@ const adapterLabels: Record<string, string> = {
|
|||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "opencode_local", "cursor"]);
|
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);
|
||||||
|
|
||||||
function dateTime(value: string) {
|
function dateTime(value: string) {
|
||||||
return new Date(value).toLocaleString();
|
return new Date(value).toLocaleString();
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ import {
|
|||||||
DEFAULT_CODEX_LOCAL_MODEL,
|
DEFAULT_CODEX_LOCAL_MODEL,
|
||||||
} from "@paperclipai/adapter-codex-local";
|
} from "@paperclipai/adapter-codex-local";
|
||||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||||
|
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||||
|
|
||||||
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>([
|
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>([
|
||||||
"claude_local",
|
"claude_local",
|
||||||
"codex_local",
|
"codex_local",
|
||||||
|
"gemini_local",
|
||||||
"opencode_local",
|
"opencode_local",
|
||||||
"pi_local",
|
"pi_local",
|
||||||
"cursor",
|
"cursor",
|
||||||
@@ -43,6 +45,8 @@ function createValuesForAdapterType(
|
|||||||
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
|
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
|
||||||
nextValues.dangerouslyBypassSandbox =
|
nextValues.dangerouslyBypassSandbox =
|
||||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||||
|
} else if (adapterType === "gemini_local") {
|
||||||
|
nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL;
|
||||||
} else if (adapterType === "cursor") {
|
} else if (adapterType === "cursor") {
|
||||||
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||||
} else if (adapterType === "opencode_local") {
|
} else if (adapterType === "opencode_local") {
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L
|
|||||||
const adapterLabels: Record<string, string> = {
|
const adapterLabels: Record<string, string> = {
|
||||||
claude_local: "Claude",
|
claude_local: "Claude",
|
||||||
codex_local: "Codex",
|
codex_local: "Codex",
|
||||||
|
gemini_local: "Gemini",
|
||||||
opencode_local: "OpenCode",
|
opencode_local: "OpenCode",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
|
|||||||
334
ui/src/pages/RunTranscriptUxLab.tsx
Normal file
334
ui/src/pages/RunTranscriptUxLab.tsx
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn, formatDateTime } from "../lib/utils";
|
||||||
|
import { Identity } from "../components/Identity";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import { RunTranscriptView, type TranscriptDensity, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
||||||
|
import { runTranscriptFixtureEntries, runTranscriptFixtureMeta } from "../fixtures/runTranscriptFixtures";
|
||||||
|
import { ExternalLink, FlaskConical, LayoutPanelLeft, MonitorCog, PanelsTopLeft, RadioTower } from "lucide-react";
|
||||||
|
|
||||||
|
type SurfaceId = "detail" | "live" | "dashboard";
|
||||||
|
|
||||||
|
const surfaceOptions: Array<{
|
||||||
|
id: SurfaceId;
|
||||||
|
label: string;
|
||||||
|
eyebrow: string;
|
||||||
|
description: string;
|
||||||
|
icon: typeof LayoutPanelLeft;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: "detail",
|
||||||
|
label: "Run Detail",
|
||||||
|
eyebrow: "Full transcript",
|
||||||
|
description: "The long-form run page with the `Nice | Raw` toggle and the most inspectable transcript view.",
|
||||||
|
icon: MonitorCog,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "live",
|
||||||
|
label: "Issue Widget",
|
||||||
|
eyebrow: "Live stream",
|
||||||
|
description: "The issue-detail live run widget, optimized for following an active run without leaving the task page.",
|
||||||
|
icon: RadioTower,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dashboard",
|
||||||
|
label: "Dashboard Card",
|
||||||
|
eyebrow: "Dense card",
|
||||||
|
description: "The active-agents dashboard card, tuned for compact scanning while keeping the same transcript language.",
|
||||||
|
icon: PanelsTopLeft,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function previewEntries(surface: SurfaceId) {
|
||||||
|
if (surface === "dashboard") {
|
||||||
|
return runTranscriptFixtureEntries.slice(-9);
|
||||||
|
}
|
||||||
|
if (surface === "live") {
|
||||||
|
return runTranscriptFixtureEntries.slice(-14);
|
||||||
|
}
|
||||||
|
return runTranscriptFixtureEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunDetailPreview({
|
||||||
|
mode,
|
||||||
|
streaming,
|
||||||
|
density,
|
||||||
|
}: {
|
||||||
|
mode: TranscriptMode;
|
||||||
|
streaming: boolean;
|
||||||
|
density: TranscriptDensity;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border/70 bg-background/80 shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
|
||||||
|
<div className="border-b border-border/60 bg-background/90 px-5 py-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline" className="uppercase tracking-[0.18em] text-[10px]">
|
||||||
|
Run Detail
|
||||||
|
</Badge>
|
||||||
|
<StatusBadge status={streaming ? "running" : "succeeded"} />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(runTranscriptFixtureMeta.startedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm font-medium">
|
||||||
|
Transcript ({runTranscriptFixtureEntries.length})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[720px] overflow-y-auto bg-[radial-gradient(circle_at_top_left,rgba(8,145,178,0.08),transparent_36%),radial-gradient(circle_at_bottom_right,rgba(245,158,11,0.10),transparent_28%)] p-5">
|
||||||
|
<RunTranscriptView
|
||||||
|
entries={runTranscriptFixtureEntries}
|
||||||
|
mode={mode}
|
||||||
|
density={density}
|
||||||
|
streaming={streaming}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveWidgetPreview({
|
||||||
|
streaming,
|
||||||
|
mode,
|
||||||
|
density,
|
||||||
|
}: {
|
||||||
|
streaming: boolean;
|
||||||
|
mode: TranscriptMode;
|
||||||
|
density: TranscriptDensity;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-cyan-500/25 bg-background/85 shadow-[0_20px_50px_rgba(6,182,212,0.10)]">
|
||||||
|
<div className="border-b border-border/60 bg-cyan-500/[0.05] px-5 py-4">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">
|
||||||
|
Live Runs
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Compact live transcript stream for the issue detail page.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Identity name={runTranscriptFixtureMeta.agentName} size="sm" />
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="rounded-full border border-border/70 bg-background/70 px-2 py-1 font-mono">
|
||||||
|
{runTranscriptFixtureMeta.sourceRunId.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={streaming ? "running" : "succeeded"} />
|
||||||
|
<span>{formatDateTime(runTranscriptFixtureMeta.startedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/70 px-2.5 py-1 text-[11px] text-muted-foreground">
|
||||||
|
Open run
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[460px] overflow-y-auto pr-1">
|
||||||
|
<RunTranscriptView
|
||||||
|
entries={previewEntries("live")}
|
||||||
|
mode={mode}
|
||||||
|
density={density}
|
||||||
|
limit={density === "compact" ? 10 : 12}
|
||||||
|
streaming={streaming}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardPreview({
|
||||||
|
streaming,
|
||||||
|
mode,
|
||||||
|
density,
|
||||||
|
}: {
|
||||||
|
streaming: boolean;
|
||||||
|
mode: TranscriptMode;
|
||||||
|
density: TranscriptDensity;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-md">
|
||||||
|
<div className={cn(
|
||||||
|
"flex h-[320px] flex-col overflow-hidden rounded-xl border shadow-[0_20px_40px_rgba(15,23,42,0.10)]",
|
||||||
|
streaming
|
||||||
|
? "border-cyan-500/25 bg-cyan-500/[0.04]"
|
||||||
|
: "border-border bg-background/75",
|
||||||
|
)}>
|
||||||
|
<div className="border-b border-border/60 px-4 py-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex h-2.5 w-2.5 rounded-full",
|
||||||
|
streaming ? "bg-cyan-500 shadow-[0_0_0_6px_rgba(34,211,238,0.12)]" : "bg-muted-foreground/35",
|
||||||
|
)} />
|
||||||
|
<Identity name={runTranscriptFixtureMeta.agentName} size="sm" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-muted-foreground">
|
||||||
|
{streaming ? "Live now" : "Finished 2m ago"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-border/70 bg-background/70 px-2 py-1 text-[10px] text-muted-foreground">
|
||||||
|
<ExternalLink className="h-2.5 w-2.5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 rounded-lg border border-border/60 bg-background/60 px-3 py-2 text-xs text-cyan-700 dark:text-cyan-300">
|
||||||
|
{runTranscriptFixtureMeta.issueIdentifier} - {runTranscriptFixtureMeta.issueTitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||||
|
<RunTranscriptView
|
||||||
|
entries={previewEntries("dashboard")}
|
||||||
|
mode={mode}
|
||||||
|
density={density}
|
||||||
|
limit={density === "compact" ? 6 : 8}
|
||||||
|
streaming={streaming}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunTranscriptUxLab() {
|
||||||
|
const [selectedSurface, setSelectedSurface] = useState<SurfaceId>("detail");
|
||||||
|
const [detailMode, setDetailMode] = useState<TranscriptMode>("nice");
|
||||||
|
const [streaming, setStreaming] = useState(true);
|
||||||
|
const [density, setDensity] = useState<TranscriptDensity>("comfortable");
|
||||||
|
|
||||||
|
const selected = surfaceOptions.find((option) => option.id === selectedSurface) ?? surfaceOptions[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-border/70 bg-[linear-gradient(135deg,rgba(8,145,178,0.08),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.08),transparent_40%),var(--background)] shadow-[0_28px_70px_rgba(15,23,42,0.10)]">
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||||
|
<aside className="border-b border-border/60 bg-background/75 p-5 lg:border-b-0 lg:border-r">
|
||||||
|
<div className="mb-5">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-500/25 bg-cyan-500/[0.08] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.24em] text-cyan-700 dark:text-cyan-300">
|
||||||
|
<FlaskConical className="h-3.5 w-3.5" />
|
||||||
|
UX Lab
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-4 text-2xl font-semibold tracking-tight">Run Transcript Fixtures</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Built from a real Paperclip development run, then sanitized so no secrets, local paths, or environment details survive into the fixture.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{surfaceOptions.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedSurface(option.id)}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-xl border px-4 py-3 text-left transition-all",
|
||||||
|
selectedSurface === option.id
|
||||||
|
? "border-cyan-500/35 bg-cyan-500/[0.10] shadow-[0_12px_24px_rgba(6,182,212,0.12)]"
|
||||||
|
: "border-border/70 bg-background/70 hover:border-cyan-500/20 hover:bg-cyan-500/[0.04]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="rounded-lg border border-current/15 p-2 text-cyan-700 dark:text-cyan-300">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{option.eyebrow}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-sm font-medium">{option.label}</span>
|
||||||
|
<span className="mt-1 block text-xs text-muted-foreground">
|
||||||
|
{option.description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="min-w-0 p-5">
|
||||||
|
<div className="mb-5 flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
|
{selected.eyebrow}
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-1 text-2xl font-semibold">{selected.label}</h2>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{selected.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
|
||||||
|
Source run {runTranscriptFixtureMeta.sourceRunId.slice(0, 8)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
|
||||||
|
{runTranscriptFixtureMeta.issueIdentifier}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Controls
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex rounded-full border border-border/70 bg-background/80 p-1">
|
||||||
|
{(["nice", "raw"] as const).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-3 py-1 text-xs font-medium capitalize transition-colors",
|
||||||
|
detailMode === mode ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => setDetailMode(mode)}
|
||||||
|
>
|
||||||
|
{mode}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex rounded-full border border-border/70 bg-background/80 p-1">
|
||||||
|
{(["comfortable", "compact"] as const).map((nextDensity) => (
|
||||||
|
<button
|
||||||
|
key={nextDensity}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-3 py-1 text-xs font-medium capitalize transition-colors",
|
||||||
|
density === nextDensity ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => setDensity(nextDensity)}
|
||||||
|
>
|
||||||
|
{nextDensity}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-full"
|
||||||
|
onClick={() => setStreaming((value) => !value)}
|
||||||
|
>
|
||||||
|
{streaming ? "Show settled state" : "Show streaming state"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSurface === "detail" ? (
|
||||||
|
<div className={cn(density === "compact" && "max-w-5xl")}>
|
||||||
|
<RunDetailPreview mode={detailMode} streaming={streaming} density={density} />
|
||||||
|
</div>
|
||||||
|
) : selectedSurface === "live" ? (
|
||||||
|
<div className={cn(density === "compact" && "max-w-4xl")}>
|
||||||
|
<LiveWidgetPreview streaming={streaming} mode={detailMode} density={density} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DashboardPreview streaming={streaming} mode={detailMode} density={density} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user