mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
* 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>
575 lines
16 KiB
TypeScript
575 lines
16 KiB
TypeScript
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;
|
||
}
|
||
});
|
||
});
|