Repository navigation
Expand file tree
/
Copy pathtest-claude-session-wrapper.ts
More file actions
176 lines (160 loc) · 6.38 KB
/
Copy pathtest-claude-session-wrapper.ts
File metadata and controls
176 lines (160 loc) · 6.38 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
import assert from "node:assert/strict"
import * as path from "node:path"
import { test } from "node:test"
import {
decodeUserEnvelope,
interactiveExtraArgs,
spawnInteractiveProcess,
} from "./src/claude-session-wrapper.js"
import { ClaudeSession, encodeCwd } from "./src/claude-session-bun.js"
// ---------------------------------------------------------------------------
// decodeUserEnvelope — doStream writes stream-json envelopes to stdin; the
// interactive TUI must receive plain typed text, never raw JSON or base64.
// ---------------------------------------------------------------------------
test("decodeUserEnvelope extracts text blocks from a stream-json envelope", () => {
const envelope = JSON.stringify({
type: "user",
message: {
role: "user",
content: [
{ type: "text", text: "Hello there" },
{ type: "text", text: "(think)" },
],
},
})
assert.equal(decodeUserEnvelope(envelope), "Hello there\n\n(think)")
})
test("decodeUserEnvelope passes string message content through", () => {
const envelope = JSON.stringify({
type: "user",
message: { role: "user", content: "plain string content" },
})
assert.equal(decodeUserEnvelope(envelope), "plain string content")
})
test("decodeUserEnvelope drops image blocks but keeps text", () => {
const envelope = JSON.stringify({
type: "user",
message: {
role: "user",
content: [
{ type: "text", text: "look at this" },
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "AAAA" },
},
],
},
})
const decoded = decodeUserEnvelope(envelope)
assert.equal(decoded, "look at this")
assert.ok(!decoded.includes("AAAA"), "base64 must never reach the TUI")
})
test("decodeUserEnvelope renders tool_result blocks as labeled text", () => {
const envelope = JSON.stringify({
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tu_1",
content: [{ type: "text", text: "exit code 0" }],
},
],
},
})
const decoded = decodeUserEnvelope(envelope)
assert.ok(decoded.includes("[Tool result tu_1]"))
assert.ok(decoded.includes("exit code 0"))
})
test("decodeUserEnvelope passes non-JSON input through verbatim", () => {
assert.equal(decodeUserEnvelope("just plain text"), "just plain text")
})
test("decodeUserEnvelope passes non-user JSON through verbatim", () => {
const control = JSON.stringify({ type: "control_response", response: {} })
assert.equal(decodeUserEnvelope(control), control)
})
// ---------------------------------------------------------------------------
// encodeCwd — transcript dir name: every non-alphanumeric char becomes "-".
// ---------------------------------------------------------------------------
test("encodeCwd replaces every non-alphanumeric char with a dash", () => {
// Use a relative-free absolute path so path.resolve is a no-op on POSIX.
if (process.platform === "win32") {
assert.equal(encodeCwd("C:\\dev\\My Project"), "C--dev-My-Project")
} else {
assert.equal(encodeCwd("/Users/me/my-app"), "-Users-me-my-app")
assert.equal(encodeCwd("/tmp/My Project"), "-tmp-My-Project")
}
})
test("ClaudeSession uses configDir for the transcript path", () => {
const configDir = path.join(process.cwd(), ".tmp-claude-config")
const cwd = path.join(process.cwd(), "workspace")
const session = new ClaudeSession({ cwd, configDir })
assert.equal(session.configDir, configDir)
assert.equal(
session.jsonlPath,
path.join(configDir, "projects", encodeCwd(cwd), `${session.sessionId}.jsonl`),
)
})
// ---------------------------------------------------------------------------
// spawnInteractiveProcess — ActiveProcess shim shape. No claude is spawned
// until the first stdin.write, so constructing + killing is offline-safe.
// ---------------------------------------------------------------------------
test("spawnInteractiveProcess returns an ActiveProcess-shaped shim", () => {
const ap = spawnInteractiveProcess({ cwd: process.cwd() })
const proc = ap.proc as any
assert.equal(typeof proc.stdin.write, "function")
assert.equal(typeof proc.kill, "function")
assert.equal(typeof proc.on, "function")
assert.equal(typeof proc.off, "function")
assert.equal(ap.proxyServer, null)
assert.equal(ap.mcpHash, undefined)
// kill() before any turn must be safe (no session started yet).
assert.equal(proc.kill(), true)
assert.equal(proc.killed, true)
})
test("spawnInteractiveProcess threads systemPromptFile into ActiveProcess", () => {
const ap = spawnInteractiveProcess({
cwd: process.cwd(),
systemPromptFile: "/tmp/nonexistent-system-prompt.txt",
})
assert.equal(ap.systemPromptFile, "/tmp/nonexistent-system-prompt.txt")
;(ap.proc as any).kill()
})
// The skill bridge reaches the TUI through the same `--plugin-dir` flag as
// the headless spawn. `interactiveExtraArgs` is exactly what ClaudeSession
// appends to its argv, so this is the spawn argument list without a PTY.
test("interactiveExtraArgs passes one --plugin-dir per staged directory, keeping the single --settings payload", () => {
const args = interactiveExtraArgs({
cwd: process.cwd(),
mcpConfigPaths: ["/tmp/mcp.json"],
pluginDirs: ["/tmp/skills-a", "/tmp/skills-b"],
permissionsAllow: ["Bash"],
fastMode: true,
})
assert.deepEqual(args.slice(0, 3), ["--mcp-config", "/tmp/mcp.json", "--strict-mcp-config"])
const dirs = args.reduce<string[]>((acc, arg, i) => {
if (arg === "--plugin-dir") acc.push(args[i + 1]!)
return acc
}, [])
assert.deepEqual(dirs, ["/tmp/skills-a", "/tmp/skills-b"])
assert.equal(args.filter((arg) => arg === "--settings").length, 1, "the CLI takes --settings once")
assert.deepEqual(JSON.parse(args[args.indexOf("--settings") + 1]!), {
permissions: { allow: ["Bash"] },
fastMode: true,
})
})
test("interactiveExtraArgs omits --plugin-dir when nothing was staged", () => {
for (const pluginDirs of [undefined, [] as string[]]) {
const args = interactiveExtraArgs({ cwd: process.cwd(), pluginDirs })
assert.equal(args.includes("--plugin-dir"), false)
}
})
test("error handler registration is add/remove symmetric", () => {
const ap = spawnInteractiveProcess({ cwd: process.cwd() })
const proc = ap.proc as any
const handler = () => {}
proc.on("error", handler)
proc.off("error", handler)
proc.kill()
})