spawn/.claude/skills/setup-spa/spa.test.ts
L 65a81edc57
fix: add unique spawn IDs to prevent history record corruption (#2235)
* fix: add unique spawn IDs to prevent history record corruption

History records were matched by heuristic ("most recent record for this
cloud without a connection"), which caused saveVmConnection and
saveLaunchCmd to overwrite the wrong record during concurrent or failed
spawns.

Fix: every SpawnRecord now has a unique `id` (UUID). All history
operations (saveVmConnection, saveLaunchCmd, removeRecord,
markRecordDeleted, mergeLastConnection) match by id when available,
falling back to the old heuristic for pre-migration records.

The orchestrator (TS path) now creates the history record AFTER server
creation succeeds, not before — so failed provisions don't leave orphan
entries.

Also adds "Remove from history" option to the spawn ls action picker,
restoring the ability to soft-delete entries without destroying the VM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add 18 unit tests for spawn ID history behavior

Tests cover:
- generateSpawnId returns unique UUIDs
- saveSpawnRecord auto-generates id when not provided
- saveVmConnection matches by spawnId (not heuristic)
- saveVmConnection does not cross-contaminate concurrent spawns
- saveVmConnection falls back to heuristic without spawnId
- saveLaunchCmd matches by spawnId (not heuristic)
- saveLaunchCmd falls back without spawnId
- removeRecord matches by id, not by timestamp+agent+cloud
- removeRecord handles duplicate timestamps correctly
- removeRecord falls back for legacy records without id
- markRecordDeleted targets correct record by id
- mergeLastConnection uses spawn_id from last-connection.json
- mergeLastConnection falls back to heuristic without spawn_id

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: enable biome import sorting with grouped imports

Adds organizeImports to biome assist config with groups:
1. Type imports
2. Node built-ins
3. Third-party packages
4. @openrouter/* packages
5. Aliases

Auto-fixed import order and lint issues across all TypeScript files,
including .claude/skills/ and packages/cli/src/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 23:27:03 -08:00

575 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { ToolCall } from "./helpers";
import { afterEach, describe, expect, it, mock } from "bun:test";
import { toRecord } from "@openrouter/spawn-shared";
import streamEvents from "../../../fixtures/claude-code/stream-events.json";
import {
downloadSlackFile,
extractToolHint,
formatToolHistory,
formatToolStats,
loadState,
markdownToSlack,
parseStreamEvent,
saveState,
stripMention,
} from "./helpers";
// Helper: extract a fixture event by index and cast to Record<string, unknown>
function fixture(index: number): Record<string, unknown> {
const event = toRecord(streamEvents[index]);
if (!event) {
throw new Error(`Fixture at index ${index} is not a record`);
}
return event;
}
describe("parseStreamEvent", () => {
it("parses assistant text from fixture", () => {
// fixture[0]: assistant with text "I'll look at the issue..."
const result = parseStreamEvent(fixture(0));
expect(result?.kind).toBe("text");
expect(result?.text).toContain("I'll look at the issue and check the repository structure.");
});
it("parses assistant tool_use (Bash) from fixture with toolName and toolHint", () => {
// fixture[1]: assistant with tool_use Bash
const result = parseStreamEvent(fixture(1));
expect(result?.kind).toBe("tool_use");
expect(result?.toolName).toBe("Bash");
expect(result?.toolHint).toContain("gh issue list");
expect(result?.text).toContain(":hammer_and_wrench: *Bash*");
expect(result?.text).toContain("gh issue list");
});
it("parses user tool_result (success) from fixture without isError", () => {
// fixture[2]: user with successful tool_result
const result = parseStreamEvent(fixture(2));
expect(result?.kind).toBe("tool_result");
expect(result?.isError).toBeUndefined();
expect(result?.text).toContain(":white_check_mark: Result");
expect(result?.text).toContain("Fly.io deploy fails on arm64");
});
it("parses assistant tool_use (Glob) from fixture with toolName and toolHint", () => {
// fixture[3]: assistant with tool_use Glob
const result = parseStreamEvent(fixture(3));
expect(result?.kind).toBe("tool_use");
expect(result?.toolName).toBe("Glob");
expect(result?.toolHint).toBe("**/*.ts");
expect(result?.text).toBe(":hammer_and_wrench: *Glob* `**/*.ts`");
});
it("parses assistant tool_use (Read) from fixture", () => {
// fixture[5]: assistant with tool_use Read
const result = parseStreamEvent(fixture(5));
expect(result?.kind).toBe("tool_use");
expect(result?.text).toContain(":hammer_and_wrench: *Read*");
expect(result?.text).toContain("index.ts");
});
it("parses user tool_result (error) from fixture with isError", () => {
// fixture[6]: user with is_error: true
const result = parseStreamEvent(fixture(6));
expect(result?.kind).toBe("tool_result");
expect(result?.isError).toBe(true);
expect(result?.text).toContain(":x: Error");
expect(result?.text).toContain("Permission denied");
});
it("parses final assistant text from fixture with markdown→slack conversion", () => {
// fixture[7]: assistant with summary text containing **bold**
const result = parseStreamEvent(fixture(7));
expect(result?.kind).toBe("text");
// **#1234** → *#1234* (Slack bold)
expect(result?.text).toContain("*#1234*");
expect(result?.text).not.toContain("**#1234**");
// inline code preserved
expect(result?.text).toContain("`--json`");
expect(result?.text).toContain("Would you like me to create a new issue");
});
it("returns null for result event (not assistant/user)", () => {
// fixture[8]: result event with session_id
const result = parseStreamEvent(fixture(8));
expect(result).toBeNull();
});
it("truncates long tool hints to 80 chars", () => {
const longCmd = "a".repeat(100);
const event: Record<string, unknown> = {
type: "assistant",
message: {
content: [
{
type: "tool_use",
name: "Bash",
input: {
command: longCmd,
},
},
],
},
};
const result = parseStreamEvent(event);
expect(result?.text).toContain("...");
expect(result?.toolHint).toContain("...");
expect(result?.kind).toBe("tool_use");
});
it("returns null for empty assistant content", () => {
const event: Record<string, unknown> = {
type: "assistant",
message: {
content: [],
},
};
expect(parseStreamEvent(event)).toBeNull();
});
it("returns null for unknown event types", () => {
expect(
parseStreamEvent({
type: "unknown",
}),
).toBeNull();
});
it("returns null for assistant without message", () => {
expect(
parseStreamEvent({
type: "assistant",
}),
).toBeNull();
});
it("returns null for user without tool_result blocks", () => {
const event: Record<string, unknown> = {
type: "user",
message: {
content: [
{
type: "text",
text: "not a tool result",
},
],
},
};
expect(parseStreamEvent(event)).toBeNull();
});
it("handles tool_use without input gracefully", () => {
const event: Record<string, unknown> = {
type: "assistant",
message: {
content: [
{
type: "tool_use",
name: "Bash",
},
],
},
};
const result = parseStreamEvent(event);
expect(result?.kind).toBe("tool_use");
expect(result?.toolName).toBe("Bash");
expect(result?.toolHint).toBe("");
expect(result?.text).toBe(":hammer_and_wrench: *Bash*");
});
it("prefers tool_use over text when both present", () => {
const event: Record<string, unknown> = {
type: "assistant",
message: {
content: [
{
type: "text",
text: "some text",
},
{
type: "tool_use",
name: "Bash",
input: {
command: "echo hi",
},
},
],
},
};
const result = parseStreamEvent(event);
expect(result?.kind).toBe("tool_use");
});
it("handles empty tool_result content", () => {
const event: Record<string, unknown> = {
type: "user",
message: {
content: [
{
type: "tool_result",
content: "",
},
],
},
};
const result = parseStreamEvent(event);
expect(result?.text).toContain("(empty)");
});
it("truncates long tool results to 500 chars", () => {
const longResult = "x".repeat(600);
const event: Record<string, unknown> = {
type: "user",
message: {
content: [
{
type: "tool_result",
content: longResult,
},
],
},
};
const result = parseStreamEvent(event);
expect(result?.text).toContain("...");
});
});
describe("stripMention", () => {
it("strips a single mention", () => {
expect(stripMention("<@U12345> hello")).toBe("hello");
});
it("strips multiple mentions", () => {
expect(stripMention("<@U12345> <@U67890> hello")).toBe("hello");
});
it("returns text without mentions unchanged", () => {
expect(stripMention("hello world")).toBe("hello world");
});
it("trims whitespace", () => {
expect(stripMention(" <@U12345> ")).toBe("");
});
});
describe("markdownToSlack", () => {
it("converts bold to Slack format", () => {
const result = markdownToSlack("This is **bold** text");
expect(result).toContain("*bold*");
expect(result).not.toContain("**bold**");
});
it("converts markdown links to Slack format", () => {
const result = markdownToSlack("[click here](https://example.com)");
expect(result).toContain("<https://example.com|click here>");
expect(result).not.toContain("](");
});
it("converts headers to bold", () => {
expect(markdownToSlack("## Summary")).toContain("*Summary*");
});
it("converts strikethrough", () => {
const result = markdownToSlack("~~removed~~");
expect(result).toContain("~removed~");
expect(result).not.toContain("~~");
});
it("preserves inline code", () => {
const result = markdownToSlack("Use `**not bold**` here");
expect(result).toContain("`**not bold**`");
});
it("preserves fenced code blocks", () => {
const input = "Before\n```\n**not bold**\n```\nAfter **bold**";
const result = markdownToSlack(input);
expect(result).toContain("**not bold**");
expect(result).toContain("*bold*");
});
it("handles the real SPA output pattern", () => {
const input =
"1. **[#1859 — Agent processes die](https://github.com/OpenRouterTeam/spawn/issues/1859)** — covers the root cause\n\n" +
"The SIGTERM is the **smoking gun**.";
const result = markdownToSlack(input);
expect(result).toContain("<https://github.com/OpenRouterTeam/spawn/issues/1859|#1859");
expect(result).toContain("*smoking gun*");
expect(result).not.toContain("](");
});
it("returns plain text unchanged", () => {
expect(markdownToSlack("no markdown here")).toContain("no markdown here");
});
it("handles empty string", () => {
expect(markdownToSlack("")).toBe("");
});
});
describe("loadState", () => {
it("returns a Result object", () => {
// STATE_PATH is captured at module load time; the default path likely
// doesn't exist in CI, so loadState returns Ok({ mappings: [] })
const result = loadState();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.mappings).toBeInstanceOf(Array);
}
});
});
describe("saveState", () => {
it("returns a Result object", () => {
// Write to a temp file by using the module's STATE_PATH (default).
// If the default dir is writable, we get Ok; if not, Err. Either way it's a Result.
const result = saveState({
mappings: [],
});
expect(typeof result.ok).toBe("boolean");
});
});
describe("extractToolHint", () => {
it("extracts command from input", () => {
const block: Record<string, unknown> = {
input: {
command: "gh issue list --repo OpenRouterTeam/spawn",
},
};
expect(extractToolHint(block)).toBe("gh issue list --repo OpenRouterTeam/spawn");
});
it("extracts pattern from input", () => {
const block: Record<string, unknown> = {
input: {
pattern: "**/*.ts",
},
};
expect(extractToolHint(block)).toBe("**/*.ts");
});
it("extracts file_path from input", () => {
const block: Record<string, unknown> = {
input: {
file_path: "/home/user/spawn/index.ts",
},
};
expect(extractToolHint(block)).toBe("/home/user/spawn/index.ts");
});
it("prefers command over pattern and file_path", () => {
const block: Record<string, unknown> = {
input: {
command: "echo hi",
pattern: "*.ts",
file_path: "/foo",
},
};
expect(extractToolHint(block)).toBe("echo hi");
});
it("truncates hints longer than 80 chars", () => {
const longCmd = "x".repeat(100);
const block: Record<string, unknown> = {
input: {
command: longCmd,
},
};
const result = extractToolHint(block);
expect(result).toHaveLength(83); // 80 + "..."
expect(result).toEndWith("...");
});
it("returns empty string for missing input", () => {
expect(extractToolHint({})).toBe("");
});
it("returns empty string for input without recognized keys", () => {
const block: Record<string, unknown> = {
input: {
query: "search term",
},
};
expect(extractToolHint(block)).toBe("");
});
});
describe("formatToolStats", () => {
it("formats a single tool count", () => {
const counts = new Map([
[
"Bash",
3,
],
]);
expect(formatToolStats(counts)).toBe("3× Bash");
});
it("formats multiple tool counts", () => {
const counts = new Map<string, number>([
[
"Bash",
1,
],
[
"Read",
4,
],
[
"Grep",
5,
],
[
"Glob",
8,
],
]);
expect(formatToolStats(counts)).toBe("1× Bash, 4× Read, 5× Grep, 8× Glob");
});
it("returns empty string for empty map", () => {
expect(formatToolStats(new Map())).toBe("");
});
});
describe("formatToolHistory", () => {
it("formats a single tool call", () => {
const history: ToolCall[] = [
{
name: "Bash",
hint: "echo hi",
},
];
expect(formatToolHistory(history)).toBe("1. ✓ Bash — echo hi");
});
it("formats multiple tool calls with numbering", () => {
const history: ToolCall[] = [
{
name: "Bash",
hint: "gh issue list",
},
{
name: "Glob",
hint: "**/*.ts",
},
{
name: "Read",
hint: "/home/user/index.ts",
},
];
const result = formatToolHistory(history);
expect(result).toBe("1. ✓ Bash — gh issue list\n2. ✓ Glob — **/*.ts\n3. ✓ Read — /home/user/index.ts");
});
it("marks errored tools with ✗", () => {
const history: ToolCall[] = [
{
name: "Bash",
hint: "rm -rf /",
errored: true,
},
{
name: "Read",
hint: "file.ts",
},
];
const result = formatToolHistory(history);
expect(result).toContain("1. ✗ Bash — rm -rf /");
expect(result).toContain("2. ✓ Read — file.ts");
});
it("handles tools without hints", () => {
const history: ToolCall[] = [
{
name: "Bash",
hint: "",
},
];
expect(formatToolHistory(history)).toBe("1. ✓ Bash");
});
it("returns empty string for empty history", () => {
expect(formatToolHistory([])).toBe("");
});
});
describe("downloadSlackFile", () => {
afterEach(() => {
mock.restore();
});
it("returns Ok with local path on success", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = mock(() =>
Promise.resolve(
new Response("file-content", {
status: 200,
}),
),
);
try {
const threadTs = `test-${Date.now()}`;
const result = await downloadSlackFile(
"https://files.slack.com/test.txt",
"test.txt",
threadTs,
"xoxb-fake-token",
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toContain("test.txt");
expect(result.data).toContain(threadTs);
}
} finally {
globalThis.fetch = originalFetch;
}
});
it("returns Err on HTTP error", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = mock(() =>
Promise.resolve(
new Response("Not Found", {
status: 404,
}),
),
);
try {
const result = await downloadSlackFile(
"https://files.slack.com/missing.txt",
"missing.txt",
"thread-123",
"xoxb-fake-token",
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("404");
}
} finally {
globalThis.fetch = originalFetch;
}
});
it("returns Err on network failure", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = mock(() => Promise.reject(new Error("Network failure")));
try {
const result = await downloadSlackFile(
"https://files.slack.com/fail.txt",
"fail.txt",
"thread-456",
"xoxb-fake-token",
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("Network failure");
}
} finally {
globalThis.fetch = originalFetch;
}
});
});