Skip to content

Commit 335ad22

Browse files
authored
fix(cloud onboarding): CEO can hire + clean claude transcript (#244)
fix(cloud onboarding): CEO can hire + clean claude transcript
2 parents d367d60 + dfe75b0 commit 335ad22

3 files changed

Lines changed: 156 additions & 13 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseClaudeStdoutLine } from "./parse-stdout.js";
3+
4+
const TS = "2026-07-15T20:21:29.280Z";
5+
6+
describe("parseClaudeStdoutLine", () => {
7+
it("renders assistant text blocks", () => {
8+
const line = JSON.stringify({
9+
type: "assistant",
10+
message: { role: "assistant", content: [{ type: "text", text: "hello" }] },
11+
});
12+
expect(parseClaudeStdoutLine(line, TS)).toEqual([{ kind: "assistant", ts: TS, text: "hello" }]);
13+
});
14+
15+
it("renders user tool_result blocks", () => {
16+
const line = JSON.stringify({
17+
type: "user",
18+
message: {
19+
role: "user",
20+
content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok", is_error: false }],
21+
},
22+
});
23+
expect(parseClaudeStdoutLine(line, TS)).toEqual([
24+
{ kind: "tool_result", ts: TS, toolUseId: "toolu_1", content: "ok", isError: false },
25+
]);
26+
});
27+
28+
it("renders the system init frame", () => {
29+
const line = JSON.stringify({
30+
type: "system",
31+
subtype: "init",
32+
model: "claude-sonnet-5",
33+
session_id: "c4a56dd0",
34+
});
35+
expect(parseClaudeStdoutLine(line, TS)).toEqual([
36+
{ kind: "init", ts: TS, model: "claude-sonnet-5", sessionId: "c4a56dd0" },
37+
]);
38+
});
39+
40+
// Regression: these control frames used to fall through to `{ kind: "stdout" }`
41+
// and dump raw JSON between the nicely-rendered messages in "nice" mode.
42+
it("suppresses rate_limit_event frames", () => {
43+
const line = JSON.stringify({
44+
type: "rate_limit_event",
45+
rate_limit_info: {
46+
status: "allowed",
47+
resetsAt: 1784153400,
48+
rateLimitType: "five_hour",
49+
overageStatus: "rejected",
50+
overageDisabledReason: "org_level_disabled",
51+
},
52+
});
53+
expect(parseClaudeStdoutLine(line, TS)).toEqual([]);
54+
});
55+
56+
it("suppresses system thinking_tokens streaming deltas", () => {
57+
const line = JSON.stringify({
58+
type: "system",
59+
subtype: "thinking_tokens",
60+
estimated_tokens: 50,
61+
estimated_tokens_delta: 50,
62+
uuid: "34603adf",
63+
session_id: "c4a56dd0",
64+
});
65+
expect(parseClaudeStdoutLine(line, TS)).toEqual([]);
66+
});
67+
68+
it("renders sub-task lifecycle frames as compact system notes, not raw JSON", () => {
69+
const started = parseClaudeStdoutLine(
70+
JSON.stringify({
71+
type: "system",
72+
subtype: "task_started",
73+
task_id: "b8luq6h49",
74+
tool_use_id: "toolu_2",
75+
description: "Provision founding engineer",
76+
}),
77+
TS,
78+
);
79+
expect(started).toEqual([
80+
{ kind: "system", ts: TS, text: "task started: Provision founding engineer" },
81+
]);
82+
83+
const notified = parseClaudeStdoutLine(
84+
JSON.stringify({ type: "system", subtype: "task_notification", task_id: "b8luq6h49", status: "completed" }),
85+
TS,
86+
);
87+
expect(notified).toEqual([{ kind: "system", ts: TS, text: "task completed" }]);
88+
});
89+
90+
it("renders other system subtypes as a compact note", () => {
91+
const line = JSON.stringify({ type: "system", subtype: "compact_boundary" });
92+
expect(parseClaudeStdoutLine(line, TS)).toEqual([{ kind: "system", ts: TS, text: "system: compact_boundary" }]);
93+
});
94+
95+
it("keeps non-JSON bridge lines as stdout", () => {
96+
const line = "[paperclip] Claude ACP default unavailable; falling back to Claude CLI.";
97+
expect(parseClaudeStdoutLine(line, TS)).toEqual([{ kind: "stdout", ts: TS, text: line }]);
98+
});
99+
100+
it("does not dump unrecognized JSON events as raw stdout", () => {
101+
const line = JSON.stringify({ type: "some_future_event", foo: 1 });
102+
expect(parseClaudeStdoutLine(line, TS)).toEqual([{ kind: "system", ts: TS, text: "some_future_event" }]);
103+
});
104+
105+
it("drops typeless JSON control objects", () => {
106+
const line = JSON.stringify({ status: "allowed", resetsAt: 1784153400 });
107+
expect(parseClaudeStdoutLine(line, TS)).toEqual([]);
108+
});
109+
});

packages/adapters/claude-local/src/ui/parse-stdout.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,37 @@ export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry
4646
return parseAcpxStdoutLine(line, ts);
4747
}
4848

49-
if (type === "system" && parsed.subtype === "init") {
50-
return [
51-
{
52-
kind: "init",
53-
ts,
54-
model: typeof parsed.model === "string" ? parsed.model : "unknown",
55-
sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "",
56-
},
57-
];
49+
// Claude Code emits high-frequency control frames alongside the message stream
50+
// (rate-limit polls, partial-message token estimates). They carry no transcript
51+
// value and, when rendered raw, dump JSON between the nicely-formatted messages.
52+
// Suppress them in "nice" mode (raw mode still shows the full log).
53+
if (type === "rate_limit_event") return [];
54+
55+
if (type === "system") {
56+
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
57+
if (subtype === "init") {
58+
return [
59+
{
60+
kind: "init",
61+
ts,
62+
model: typeof parsed.model === "string" ? parsed.model : "unknown",
63+
sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "",
64+
},
65+
];
66+
}
67+
// Streaming token-estimate deltas are pure noise (one per partial message).
68+
if (subtype === "thinking_tokens") return [];
69+
// Sub-task lifecycle and any other system event: render a compact one-line
70+
// note instead of the raw JSON.
71+
if (subtype === "task_started") {
72+
const desc = typeof parsed.description === "string" ? parsed.description : "";
73+
return [{ kind: "system", ts, text: desc ? `task started: ${desc}` : "task started" }];
74+
}
75+
if (subtype === "task_notification") {
76+
const status = typeof parsed.status === "string" ? parsed.status : "";
77+
return [{ kind: "system", ts, text: status ? `task ${status}` : "task update" }];
78+
}
79+
return [{ kind: "system", ts, text: subtype ? `system: ${subtype}` : "system event" }];
5880
}
5981

6082
if (type === "assistant") {
@@ -86,7 +108,7 @@ export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry
86108
});
87109
}
88110
}
89-
return entries.length > 0 ? entries : [{ kind: "stdout", ts, text: line }];
111+
return entries.length > 0 ? entries : [];
90112
}
91113

92114
if (type === "user") {
@@ -118,7 +140,8 @@ export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry
118140
}
119141
}
120142
if (entries.length > 0) return entries;
121-
// fall through to stdout for user messages without recognized blocks
143+
// user message with no recognized blocks: nothing to show in "nice" mode.
144+
return [];
122145
}
123146

124147
if (type === "result") {
@@ -145,5 +168,8 @@ export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry
145168
}];
146169
}
147170

148-
return [{ kind: "stdout", ts, text: line }];
171+
// Unrecognized JSON event: render a compact note if it is typed, otherwise drop
172+
// it. Never dump raw JSON into the "nice" transcript (non-JSON plain-text lines,
173+
// e.g. "[paperclip] ..." bridge messages, are handled above as stdout).
174+
return type ? [{ kind: "system", ts, text: type }] : [];
149175
}

ui/src/components/OnboardingWizard.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@ function buildMissionFromQuestionnaire(q1: string, q2: string, q3: string, q4: s
136136
}
137137

138138
const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
139+
// Skill (by key) that teaches the governance-aware agent-hiring flow. Attached to
140+
// the onboarding CEO so it can fulfil its seed task of hiring the first engineer.
141+
const ONBOARDING_CEO_SKILL_KEY = "paperclip-create-agent";
139142
const DEFAULT_TASK_TITLE = "Hire your first engineer and create a hiring plan";
140143
const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
141144
@@ -815,7 +818,12 @@ export function OnboardingWizard() {
815818
role: "ceo",
816819
adapterType,
817820
adapterConfig: mergeCredentialBindings(buildAdapterConfig(), credentialBindings, credentialSetup),
818-
runtimeConfig: buildNewAgentRuntimeConfig()
821+
runtimeConfig: buildNewAgentRuntimeConfig(),
822+
// The onboarding CEO's seed task is to hire the first engineer. Attach the
823+
// create-agent skill so its run session exposes the governance-aware hiring
824+
// flow (POST /api/companies/:id/agent-hires); without it the agent does not
825+
// know the sanctioned route and hits "Route not allowed" on guessed ones.
826+
desiredSkills: [ONBOARDING_CEO_SKILL_KEY],
819827
});
820828
if (hire.approval) {
821829
await approvalsApi.approve(

0 commit comments

Comments
 (0)