diff --git a/packages/adapters/claude-local/src/cli/format-event.ts b/packages/adapters/claude-local/src/cli/format-event.ts index 08423d6e..13263be6 100644 --- a/packages/adapters/claude-local/src/cli/format-event.ts +++ b/packages/adapters/claude-local/src/cli/format-event.ts @@ -17,6 +17,27 @@ function asErrorText(value: unknown): string { } } +function printToolResult(block: Record): void { + const isError = block.is_error === true; + let text = ""; + if (typeof block.content === "string") { + text = block.content; + } else if (Array.isArray(block.content)) { + const parts: string[] = []; + for (const part of block.content) { + if (typeof part !== "object" || part === null || Array.isArray(part)) continue; + const record = part as Record; + if (typeof record.text === "string") parts.push(record.text); + } + text = parts.join("\n"); + } + + console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`)); + if (text) { + console.log((isError ? pc.red : pc.gray)(text)); + } +} + export function printClaudeStreamEvent(raw: string, debug: boolean): void { const line = raw.trim(); if (!line) return; @@ -51,6 +72,9 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void { if (blockType === "text") { const text = typeof block.text === "string" ? block.text : ""; if (text) console.log(pc.green(`assistant: ${text}`)); + } else if (blockType === "thinking") { + const text = typeof block.thinking === "string" ? block.thinking : ""; + if (text) console.log(pc.gray(`thinking: ${text}`)); } else if (blockType === "tool_use") { const name = typeof block.name === "string" ? block.name : "unknown"; console.log(pc.yellow(`tool_call: ${name}`)); @@ -62,6 +86,22 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void { return; } + if (type === "user") { + const message = + typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message) + ? (parsed.message as Record) + : {}; + const content = Array.isArray(message.content) ? message.content : []; + for (const blockRaw of content) { + if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue; + const block = blockRaw as Record; + if (typeof block.type === "string" && block.type === "tool_result") { + printToolResult(block); + } + } + return; + } + if (type === "result") { const usage = typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage) diff --git a/server/src/__tests__/claude-local-adapter.test.ts b/server/src/__tests__/claude-local-adapter.test.ts index 26fc4531..d2cb8665 100644 --- a/server/src/__tests__/claude-local-adapter.test.ts +++ b/server/src/__tests__/claude-local-adapter.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { isClaudeMaxTurnsResult } from "@paperclipai/adapter-claude-local/server"; +import { parseClaudeStdoutLine } from "@paperclipai/adapter-claude-local/ui"; +import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli"; describe("claude_local max-turn detection", () => { it("detects max-turn exhaustion by subtype", () => { @@ -28,3 +30,158 @@ describe("claude_local max-turn detection", () => { ).toBe(false); }); }); + +describe("claude_local ui stdout parser", () => { + it("maps assistant text, thinking, tool calls, and tool results into transcript entries", () => { + const ts = "2026-03-29T00:00:00.000Z"; + + expect( + parseClaudeStdoutLine( + JSON.stringify({ + type: "system", + subtype: "init", + model: "claude-sonnet-4-6", + session_id: "claude-session-1", + }), + ts, + ), + ).toEqual([ + { + kind: "init", + ts, + model: "claude-sonnet-4-6", + sessionId: "claude-session-1", + }, + ]); + + expect( + parseClaudeStdoutLine( + JSON.stringify({ + type: "assistant", + session_id: "claude-session-1", + message: { + content: [ + { type: "text", text: "I will inspect the repo." }, + { type: "thinking", thinking: "Checking the adapter wiring" }, + { type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls -1" } }, + ], + }, + }), + ts, + ), + ).toEqual([ + { kind: "assistant", ts, text: "I will inspect the repo." }, + { kind: "thinking", ts, text: "Checking the adapter wiring" }, + { kind: "tool_call", ts, name: "bash", toolUseId: "tool_1", input: { command: "ls -1" } }, + ]); + + expect( + parseClaudeStdoutLine( + JSON.stringify({ + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tool_1", + content: [{ type: "text", text: "AGENTS.md\nREADME.md" }], + is_error: false, + }, + ], + }, + }), + ts, + ), + ).toEqual([ + { + kind: "tool_result", + ts, + toolUseId: "tool_1", + content: "AGENTS.md\nREADME.md", + isError: false, + }, + ]); + }); +}); + +function stripAnsi(value: string) { + return value.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("claude_local cli formatter", () => { + it("prints the user-visible and background transcript events from stream-json output", () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + printClaudeStreamEvent( + JSON.stringify({ + type: "system", + subtype: "init", + model: "claude-sonnet-4-6", + session_id: "claude-session-1", + }), + false, + ); + printClaudeStreamEvent( + JSON.stringify({ + type: "assistant", + message: { + content: [ + { type: "text", text: "I will inspect the repo." }, + { type: "thinking", thinking: "Checking the adapter wiring" }, + { type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls -1" } }, + ], + }, + }), + false, + ); + printClaudeStreamEvent( + JSON.stringify({ + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tool_1", + content: [{ type: "text", text: "AGENTS.md\nREADME.md" }], + is_error: false, + }, + ], + }, + }), + false, + ); + printClaudeStreamEvent( + JSON.stringify({ + type: "result", + subtype: "success", + result: "Done", + usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 2 }, + total_cost_usd: 0.00042, + }), + false, + ); + + const lines = spy.mock.calls + .map((call) => call.map((value) => String(value)).join(" ")) + .map(stripAnsi); + + expect(lines).toEqual( + expect.arrayContaining([ + "Claude initialized (model: claude-sonnet-4-6, session: claude-session-1)", + "assistant: I will inspect the repo.", + "thinking: Checking the adapter wiring", + "tool_call: bash", + '{\n "command": "ls -1"\n}', + "tool_result", + "AGENTS.md\nREADME.md", + "result:", + "Done", + "tokens: in=10 out=5 cached=2 cost=$0.000420", + ]), + ); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 1511b95e..8f80eec6 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -1,16 +1,56 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import express from "express"; import request from "supertest"; +import type { Db } from "@paperclipai/db"; import { healthRoutes } from "../routes/health.js"; +import * as devServerStatus from "../dev-server-status.js"; import { serverVersion } from "../version.js"; describe("GET /health", () => { - const app = express(); - app.use("/health", healthRoutes()); + beforeEach(() => { + vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); it("returns 200 with status ok", async () => { + const app = express(); + app.use("/health", healthRoutes()); + const res = await request(app).get("/health"); expect(res.status).toBe(200); expect(res.body).toEqual({ status: "ok", version: serverVersion }); }); + + it("returns 200 when the database probe succeeds", async () => { + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + } as unknown as Db; + const app = express(); + app.use("/health", healthRoutes(db)); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ status: "ok", version: serverVersion }); + }); + + it("returns 503 when the database probe fails", async () => { + const db = { + execute: vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED")), + } as unknown as Db; + const app = express(); + app.use("/health", healthRoutes(db)); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(503); + expect(res.body).toEqual({ + status: "unhealthy", + version: serverVersion, + error: "database_unreachable", + }); + }); }); diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts index 189126f9..990587d3 100644 --- a/server/src/__tests__/openclaw-invite-prompt-route.test.ts +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -1,6 +1,7 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { companies, invites } from "@paperclipai/db"; import { accessRoutes } from "../routes/access.js"; import { errorHandler } from "../middleware/index.js"; @@ -51,19 +52,35 @@ function createDbStub() { inviteType: "company_join", allowedJoinTypes: "agent", defaultsPayload: null, - expiresAt: new Date("2026-03-07T00:10:00.000Z"), + expiresAt: new Date("2099-03-07T00:10:00.000Z"), invitedByUserId: null, tokenHash: "hash", revokedAt: null, acceptedAt: null, - createdAt: new Date("2026-03-07T00:00:00.000Z"), - updatedAt: new Date("2026-03-07T00:00:00.000Z"), + createdAt: new Date("2099-03-07T00:00:00.000Z"), + updatedAt: new Date("2099-03-07T00:00:00.000Z"), }; const returning = vi.fn().mockResolvedValue([createdInvite]); const values = vi.fn().mockReturnValue({ returning }); const insert = vi.fn().mockReturnValue({ values }); + const select = vi.fn(() => ({ + from(table: unknown) { + return { + where: vi.fn().mockImplementation(() => { + if (table === invites) { + return Promise.resolve([createdInvite]); + } + if (table === companies) { + return Promise.resolve([{ name: "Acme AI" }]); + } + return Promise.resolve([]); + }), + }; + }, + })); return { insert, + select, }; } @@ -143,9 +160,30 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { expect(res.status).toBe(201); expect(res.body.allowedJoinTypes).toBe("agent"); expect(typeof res.body.token).toBe("string"); + expect(res.body.companyName).toBe("Acme AI"); expect(res.body.onboardingTextPath).toContain("/api/invites/"); }); + it("includes companyName in invite summary responses", async () => { + const db = createDbStub(); + const app = createApp( + { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, + db, + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.companyId).toBe("company-1"); + expect(res.body.companyName).toBe("Acme AI"); + }); + it("allows board callers with invite permission", async () => { const db = createDbStub(); mockAccessService.canUser.mockResolvedValue(true); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 7d7dfe2b..a53bf0dc 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -14,6 +14,7 @@ import type { Db } from "@paperclipai/db"; import { agentApiKeys, authUsers, + companies, invites, joinRequests } from "@paperclipai/db"; @@ -856,7 +857,8 @@ export function normalizeAgentDefaultsForJoin(input: { function toInviteSummaryResponse( req: Request, token: string, - invite: typeof invites.$inferSelect + invite: typeof invites.$inferSelect, + companyName: string | null = null ) { const baseUrl = requestBaseUrl(req); const onboardingPath = `/api/invites/${token}/onboarding`; @@ -865,6 +867,7 @@ function toInviteSummaryResponse( return { id: invite.id, companyId: invite.companyId, + companyName, inviteType: invite.inviteType, allowedJoinTypes: invite.allowedJoinTypes, expiresAt: invite.expiresAt, @@ -993,6 +996,7 @@ function buildInviteOnboardingManifest( token: string, invite: typeof invites.$inferSelect, opts: { + companyName?: string | null; deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; bindHost: string; @@ -1024,7 +1028,12 @@ function buildInviteOnboardingManifest( }); return { - invite: toInviteSummaryResponse(req, token, invite), + invite: toInviteSummaryResponse( + req, + token, + invite, + opts.companyName ?? null + ), onboarding: { instructions: "Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).", @@ -1084,6 +1093,7 @@ export function buildInviteOnboardingTextDocument( token: string, invite: typeof invites.$inferSelect, opts: { + companyName?: string | null; deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; bindHost: string; @@ -1133,6 +1143,10 @@ export function buildInviteOnboardingTextDocument( - expiresAt: ${invite.expiresAt.toISOString()} `); + if (manifest.invite.companyName) { + lines.push(`- companyName: ${manifest.invite.companyName}`); + } + if (onboarding.inviteMessage) { appendBlock(` ## Message from inviter @@ -1882,6 +1896,16 @@ export function accessRoutes( return { token, created, normalizedAgentMessage }; } + async function getInviteCompanyName(companyId: string | null) { + if (!companyId) return null; + const company = await db + .select({ name: companies.name }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + return company?.name ?? null; + } + router.get("/skills/available", (_req, res) => { res.json({ skills: listAvailableSkills() }); }); @@ -1942,11 +1966,18 @@ export function accessRoutes( } }); - const inviteSummary = toInviteSummaryResponse(req, token, created); + const companyName = await getInviteCompanyName(created.companyId); + const inviteSummary = toInviteSummaryResponse( + req, + token, + created, + companyName + ); res.status(201).json({ ...created, token, inviteUrl: `/invite/${token}`, + companyName, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, inviteMessage: inviteSummary.inviteMessage @@ -1987,11 +2018,18 @@ export function accessRoutes( } }); - const inviteSummary = toInviteSummaryResponse(req, token, created); + const companyName = await getInviteCompanyName(created.companyId); + const inviteSummary = toInviteSummaryResponse( + req, + token, + created, + companyName + ); res.status(201).json({ ...created, token, inviteUrl: `/invite/${token}`, + companyName, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, inviteMessage: inviteSummary.inviteMessage @@ -2016,7 +2054,8 @@ export function accessRoutes( throw notFound("Invite not found"); } - res.json(toInviteSummaryResponse(req, token, invite)); + const companyName = await getInviteCompanyName(invite.companyId); + res.json(toInviteSummaryResponse(req, token, invite, companyName)); }); router.get("/invites/:token/onboarding", async (req, res) => { @@ -2031,7 +2070,11 @@ export function accessRoutes( throw notFound("Invite not found"); } - res.json(buildInviteOnboardingManifest(req, token, invite, opts)); + const companyName = await getInviteCompanyName(invite.companyId); + res.json(buildInviteOnboardingManifest(req, token, invite, { + ...opts, + companyName + })); }); router.get("/invites/:token/onboarding.txt", async (req, res) => { @@ -2046,9 +2089,15 @@ export function accessRoutes( throw notFound("Invite not found"); } + const companyName = await getInviteCompanyName(invite.companyId); res .type("text/plain; charset=utf-8") - .send(buildInviteOnboardingTextDocument(req, token, invite, opts)); + .send( + buildInviteOnboardingTextDocument(req, token, invite, { + ...opts, + companyName + }) + ); }); router.get("/invites/:token/test-resolution", async (req, res) => { @@ -2458,11 +2507,15 @@ export function accessRoutes( const response = toJoinRequestResponse(created); if (claimSecret) { + const companyName = await getInviteCompanyName(invite.companyId); const onboardingManifest = buildInviteOnboardingManifest( req, token, invite, - opts + { + ...opts, + companyName + } ); res.status(202).json({ ...response, diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 0bf6e92f..795eb9a5 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -29,6 +29,17 @@ export function healthRoutes( return; } + try { + await db.execute(sql`SELECT 1`); + } catch { + res.status(503).json({ + status: "unhealthy", + version: serverVersion, + error: "database_unreachable", + }); + return; + } + let bootstrapStatus: "ready" | "bootstrap_pending" = "ready"; let bootstrapInviteActive = false; if (opts.deploymentMode === "authenticated") { diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 90afd1dd..0a111150 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -4,6 +4,7 @@ import { api } from "./client"; type InviteSummary = { id: string; companyId: string | null; + companyName?: string | null; inviteType: "company_join" | "bootstrap_ceo"; allowedJoinTypes: "human" | "agent" | "both"; expiresAt: string; @@ -87,6 +88,7 @@ type CompanyInviteCreated = { inviteUrl: string; expiresAt: string; allowedJoinTypes: "human" | "agent" | "both"; + companyName?: string | null; onboardingTextPath?: string; onboardingTextUrl?: string; inviteMessage?: string | null; diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 84041401..2501d95a 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -10,6 +10,7 @@ import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./Ma import { StatusBadge } from "./StatusBadge"; import { AgentIcon } from "./AgentIconPicker"; import { formatDateTime } from "../lib/utils"; +import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { PluginSlotOutlet } from "@/plugins/slots"; interface CommentWithRunMeta extends IssueComment { @@ -420,17 +421,24 @@ export function CommentThread({ if (!trimmed) return; const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null; + const submittedBody = trimmed; setSubmitting(true); + setBody(""); try { // TODO: wire an explicit "send + interrupt" action through the composer if we expose it in the UI. - await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined); - setBody(""); + await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined); if (draftKey) clearDraft(draftKey); setReopen(true); setReassignTarget(effectiveSuggestedAssigneeValue); } catch { - // Parent mutation handlers surface the failure and keep the draft intact. + setBody((current) => + restoreSubmittedCommentDraft({ + currentBody: current, + submittedBody, + }), + ); + // Parent mutation handlers surface the failure and the draft is restored for retry. } finally { setSubmitting(false); } diff --git a/ui/src/components/SwipeToArchive.test.tsx b/ui/src/components/SwipeToArchive.test.tsx index 4cdf92db..8a3c07f3 100644 --- a/ui/src/components/SwipeToArchive.test.tsx +++ b/ui/src/components/SwipeToArchive.test.tsx @@ -136,7 +136,10 @@ describe("SwipeToArchive", () => { const surface = container.querySelector("[data-inbox-row-surface]") as HTMLDivElement | null; expect(surface).not.toBeNull(); - expect(surface?.style.backgroundColor).toBe("hsl(var(--muted))"); + expect(surface?.className).toContain("bg-zinc-100"); + expect(surface?.className).toContain("dark:bg-zinc-800"); + expect(surface?.className).not.toContain("bg-card"); + expect(surface?.style.backgroundColor).toBe(""); expect(surface?.style.boxShadow).toBe(""); act(() => { diff --git a/ui/src/components/SwipeToArchive.tsx b/ui/src/components/SwipeToArchive.tsx index acd1d467..639179c3 100644 --- a/ui/src/components/SwipeToArchive.tsx +++ b/ui/src/components/SwipeToArchive.tsx @@ -13,7 +13,6 @@ interface SwipeToArchiveProps { const COMMIT_THRESHOLD = 0.32; const MAX_SWIPE = 0.88; const COMMIT_DELAY_MS = 140; -const SELECTED_ROW_BACKGROUND = "hsl(var(--muted))"; export function SwipeToArchive({ children, @@ -152,11 +151,13 @@ export function SwipeToArchive({
{children} diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index e66bf4d8..481aa4b0 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -105,11 +105,13 @@ export function ToggleField({ hint, checked, onChange, + toggleTestId, }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void; + toggleTestId?: string; }) { return (
@@ -119,6 +121,8 @@ export function ToggleField({