feat: Result monad in shared + Claude Code fixtures + SPA Result adoption (#1858)

* refactor: split SPA into helpers + main, add build script and tests

Split slack-bot.ts into helpers.ts (pure functions) and main.ts (entry
point) for testability. Add build.ts to bundle SPA into spa.js. Add
spa.test.ts with 19 tests covering stream parsing and text helpers.

Improved streaming: tool_use and tool_result events get their own Slack
messages instead of concatenating everything into one. Prompt is passed
via stdin to avoid CLI flag parsing issues with user content.

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

* chore: drop build.ts — run main.ts directly via bun

Bun runs TypeScript natively, no bundling step needed.

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

* feat: move Result monad to shared, add Claude Code fixtures, use Result in SPA

- Move Result type/Ok/Err from packages/cli/src/shared/result.ts to
  packages/shared/src/result.ts and re-export from @openrouter/spawn-shared
- Update CLI imports (ui.ts) to use the shared package
- Add fixtures/claude-code/ with realistic stream-json events covering
  all event types (assistant text, tool_use, user tool_result, result)
- Refactor SPA helpers to return Result<T> instead of throwing/returning null:
  loadState() → Result<State>, saveState() → Result<void>,
  downloadSlackFile() → Result<string>, addMapping() → Result<void>
- Update main.ts call sites to handle Result returns
- Update SPA tests to import events from fixtures and test Result returns
- Bump CLI version 0.10.0 → 0.10.1

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

* fix: biome format issues in aws.test.ts, aws.ts, daytona.ts

Expand inline objects/arrays to multi-line format to satisfy biome
formatter rules. No logic changes.

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

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-02-23 22:48:51 -08:00 committed by GitHub
parent 65f6f1be32
commit f5f020d250
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 882 additions and 347 deletions

View file

@ -60,15 +60,15 @@ export SLACK_BOT_TOKEN="xoxb-YOUR-BOT-TOKEN"
export SLACK_APP_TOKEN="xapp-YOUR-APP-TOKEN"
export SLACK_CHANNEL_ID="C0000000000"
export GITHUB_REPO="OpenRouterTeam/spawn"
export REPO_ROOT="/home/lab/spawn"
export REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
exec bun run "${SCRIPT_DIR}/slack-bot.ts"
exec bun run "${SCRIPT_DIR}/main.ts"
```
## Install
```bash
cd .claude/skills/setup-spa && bun install
bun install # from repo root — workspace install
```
## Systemd Service

View file

@ -1,11 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.3/schema.json",
"extends": ["../../../lint/biome.json"],
"vcs": {
"enabled": false
},
"files": {
"includes": ["**/*.ts"]
},
"plugins": ["../../../lint/no-type-assertion.grit"]
}

View file

@ -0,0 +1,285 @@
// SPA helpers — pure functions for parsing Claude Code stream events,
// Slack formatting, state management, and file download/cleanup.
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync, statSync } from "node:fs";
import { dirname } from "node:path";
import * as v from "valibot";
import { toRecord, type Result, Ok, Err } from "@openrouter/spawn-shared";
// #region State
const STATE_PATH = process.env.STATE_PATH ?? `${process.env.HOME ?? "/root"}/.config/spawn/slack-issues.json`;
const MappingSchema = v.object({
channel: v.string(),
threadTs: v.string(),
sessionId: v.string(),
createdAt: v.string(),
});
const StateSchema = v.object({
mappings: v.array(MappingSchema),
});
export type Mapping = v.InferOutput<typeof MappingSchema>;
export type State = v.InferOutput<typeof StateSchema>;
export function loadState(): Result<State> {
try {
if (!existsSync(STATE_PATH)) {
return Ok({ mappings: [] });
}
const raw = readFileSync(STATE_PATH, "utf-8");
const parsed = v.parse(StateSchema, JSON.parse(raw));
return Ok(parsed);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return Err(new Error(`Failed to load state: ${msg}`));
}
}
export function saveState(s: State): Result<void> {
try {
const dir = dirname(STATE_PATH);
mkdirSync(dir, { recursive: true });
writeFileSync(STATE_PATH, `${JSON.stringify(s, null, 2)}\n`);
return Ok(undefined);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return Err(new Error(`Failed to save state: ${msg}`));
}
}
export function findMapping(s: State, channel: string, threadTs: string): Mapping | undefined {
return s.mappings.find((m) => m.channel === channel && m.threadTs === threadTs);
}
export function addMapping(s: State, mapping: Mapping): Result<void> {
s.mappings.push(mapping);
return saveState(s);
}
// #endregion
// #region Claude Code stream parsing
export const ResultSchema = v.object({
type: v.literal("result"),
session_id: v.string(),
});
export interface SlackSegment {
kind: "text" | "tool_use" | "tool_result";
text: string;
}
/**
* Parse a Claude Code stream-json event into a typed Slack segment.
*
* Claude Code emits complete messages (not Anthropic streaming deltas):
* {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
* {"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{...}}]}}
* {"type":"user","message":{"content":[{"type":"tool_result","content":"..."}]}}
* {"type":"result","result":"...","session_id":"..."}
*/
export function parseStreamEvent(event: Record<string, unknown>): SlackSegment | null {
const type = event.type;
if (type === "assistant") {
const msg = toRecord(event.message);
if (!msg) {
return null;
}
const content = Array.isArray(msg.content) ? msg.content : [];
// Check what kind of content blocks this message has
const textParts: string[] = [];
const toolParts: string[] = [];
for (const rawBlock of content) {
const block = toRecord(rawBlock);
if (!block) {
continue;
}
if (block.type === "text" && typeof block.text === "string") {
textParts.push(block.text);
}
if (block.type === "tool_use" && typeof block.name === "string") {
const input = toRecord(block.input);
let summary = "";
if (input) {
const hint =
(typeof input.command === "string" ? input.command : null) ??
(typeof input.pattern === "string" ? input.pattern : null) ??
(typeof input.file_path === "string" ? input.file_path : null);
if (hint) {
const short = hint.length > 80 ? `${hint.slice(0, 80)}...` : hint;
summary = ` \`${short}\``;
}
}
toolParts.push(`:hammer_and_wrench: *${block.name}*${summary}`);
}
}
// Tool use takes priority — it's a distinct event kind
if (toolParts.length > 0) {
return {
kind: "tool_use",
text: toolParts.join("\n"),
};
}
if (textParts.length > 0) {
return {
kind: "text",
text: textParts.join(""),
};
}
return null;
}
if (type === "user") {
const msg = toRecord(event.message);
if (!msg) {
return null;
}
const content = Array.isArray(msg.content) ? msg.content : [];
const parts: string[] = [];
for (const rawBlock of content) {
const block = toRecord(rawBlock);
if (!block || block.type !== "tool_result") {
continue;
}
const isError = block.is_error === true;
const prefix = isError ? ":x: Error" : ":white_check_mark: Result";
const resultText = typeof block.content === "string" ? block.content : "";
const truncated = resultText.length > 500 ? `${resultText.slice(0, 500)}...` : resultText;
if (!truncated) {
parts.push(`${prefix}: (empty)`);
} else {
parts.push(`${prefix}:\n\`\`\`\n${truncated}\n\`\`\``);
}
}
if (parts.length === 0) {
return null;
}
return {
kind: "tool_result",
text: parts.join("\n"),
};
}
return null;
}
// #endregion
// #region Text helpers
export function stripMention(text: string): string {
return text.replace(/<@[A-Z0-9]+>/g, "").trim();
}
// #endregion
// #region File downloads
const DOWNLOADS_DIR = "/tmp/spa-downloads";
/** Download a Slack-hosted file into a thread-scoped temp dir. */
export async function downloadSlackFile(
url: string,
filename: string,
threadTs: string,
botToken: string,
): Promise<Result<string>> {
try {
const resp = await fetch(url, {
headers: {
Authorization: `Bearer ${botToken}`,
},
});
if (!resp.ok) {
return Err(new Error(`Failed to download ${filename}: ${resp.status}`));
}
const dir = `${DOWNLOADS_DIR}/${threadTs}`;
mkdirSync(dir, { recursive: true });
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const localPath = `${dir}/${safeName}`;
const buf = await resp.arrayBuffer();
writeFileSync(localPath, Buffer.from(buf));
return Ok(localPath);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return Err(new Error(`Error downloading ${filename}: ${msg}`));
}
}
// #endregion
// #region Cleanup
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const CLEANUP_TIMESTAMP_PATH = `${DOWNLOADS_DIR}/.last-cleanup`;
/** Remove download directories older than 30 days. */
export function cleanupStaleDownloads(): void {
if (!existsSync(DOWNLOADS_DIR)) {
return;
}
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
const cutoff = Date.now() - thirtyDaysMs;
let removed = 0;
try {
for (const entry of readdirSync(DOWNLOADS_DIR)) {
const entryPath = `${DOWNLOADS_DIR}/${entry}`;
try {
const stat = statSync(entryPath);
if (stat.isDirectory() && stat.mtimeMs < cutoff) {
rmSync(entryPath, {
recursive: true,
force: true,
});
removed++;
}
} catch {
// skip entries we can't stat
}
}
} catch {
// ignore if dir disappeared
}
if (removed > 0) {
console.log(`[spa] Cleaned up ${removed} stale download dir(s)`);
}
}
/** Run cleanup only if at least 1 hour since last run. Persists timestamp to disk. */
export function runCleanupIfDue(): void {
try {
if (existsSync(CLEANUP_TIMESTAMP_PATH)) {
const lastRun = Number.parseInt(readFileSync(CLEANUP_TIMESTAMP_PATH, "utf-8").trim(), 10);
if (Date.now() - lastRun < CLEANUP_INTERVAL_MS) {
return;
}
}
} catch {
// file missing or unreadable — run cleanup
}
cleanupStaleDownloads();
try {
mkdirSync(DOWNLOADS_DIR, {
recursive: true,
});
writeFileSync(CLEANUP_TIMESTAMP_PATH, String(Date.now()));
} catch {
// non-fatal
}
}
// #endregion

View file

@ -1,8 +1,22 @@
// SPA (Spawn's Personal Agent) — Slack bot entry point.
// Pipes Slack threads into Claude Code sessions and streams responses back.
import { App } from "@slack/bolt";
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync, statSync } from "node:fs";
import { dirname } from "node:path";
import * as v from "valibot";
import { toRecord } from "@openrouter/spawn-shared";
import {
type State,
type Mapping,
ResultSchema,
loadState,
saveState,
findMapping,
addMapping,
parseStreamEvent,
stripMention,
downloadSlackFile,
runCleanupIfDue,
} from "./helpers";
// #region Environment
@ -34,59 +48,12 @@ let BOT_USER_ID = "";
// #region State
const STATE_PATH = process.env.STATE_PATH ?? `${process.env.HOME ?? "/root"}/.config/spawn/slack-issues.json`;
const MappingSchema = v.object({
channel: v.string(),
threadTs: v.string(),
sessionId: v.string(),
createdAt: v.string(),
});
const StateSchema = v.object({
mappings: v.array(MappingSchema),
});
type Mapping = v.InferOutput<typeof MappingSchema>;
type State = v.InferOutput<typeof StateSchema>;
function loadState(): State {
try {
if (!existsSync(STATE_PATH)) {
return {
mappings: [],
};
}
const raw = readFileSync(STATE_PATH, "utf-8");
const parsed = v.parse(StateSchema, JSON.parse(raw));
return parsed;
} catch {
console.warn("[spa] Could not load state, starting fresh");
return {
mappings: [],
};
}
const stateResult = loadState();
const state: State = stateResult.ok ? stateResult.data : { mappings: [] };
if (!stateResult.ok) {
console.warn(`[spa] ${stateResult.error.message}, starting fresh`);
}
function saveState(s: State): void {
const dir = dirname(STATE_PATH);
mkdirSync(dir, {
recursive: true,
});
writeFileSync(STATE_PATH, `${JSON.stringify(s, null, 2)}\n`);
}
function findMapping(s: State, channel: string, threadTs: string): Mapping | undefined {
return s.mappings.find((m) => m.channel === channel && m.threadTs === threadTs);
}
function addMapping(s: State, mapping: Mapping): void {
s.mappings.push(mapping);
saveState(s);
}
const state = loadState();
// Active Claude Code processes — keyed by threadTs
const activeRuns = new Map<
string,
@ -100,100 +67,6 @@ const activeRuns = new Map<
// #region Claude Code helpers
const ResultSchema = v.object({
type: v.literal("result"),
session_id: v.string(),
});
/**
* Format a Claude Code stream-json event into Slack-friendly text, or null to skip.
*
* Claude Code emits complete messages (not Anthropic streaming deltas):
* {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
* {"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{...}}]}}
* {"type":"user","message":{"content":[{"type":"tool_result","content":"..."}]}}
* {"type":"result","result":"...","session_id":"..."}
*/
function formatStreamEvent(event: Record<string, unknown>): string | null {
const type = event.type;
// Assistant messages — contain text, tool_use, or thinking blocks
if (type === "assistant") {
const msg = toRecord(event.message);
if (!msg) {
return null;
}
const content = Array.isArray(msg.content) ? msg.content : [];
const parts: string[] = [];
for (const rawBlock of content) {
const block = toRecord(rawBlock);
if (!block) {
continue;
}
if (block.type === "text" && typeof block.text === "string") {
parts.push(block.text);
}
if (block.type === "tool_use" && typeof block.name === "string") {
const input = toRecord(block.input);
// Show a short summary of the tool input
let summary = "";
if (input) {
const cmd = typeof input.command === "string" ? input.command : null;
const pattern = typeof input.pattern === "string" ? input.pattern : null;
const filePath = typeof input.file_path === "string" ? input.file_path : null;
const hint = cmd ?? pattern ?? filePath;
if (hint) {
const short = hint.length > 80 ? `${hint.slice(0, 80)}...` : hint;
summary = ` \`${short}\``;
}
}
parts.push(`\n:hammer_and_wrench: *${block.name}*${summary}\n`);
}
}
if (parts.length === 0) {
return null;
}
return parts.join("");
}
// User messages — contain tool_result blocks
if (type === "user") {
const msg = toRecord(event.message);
if (!msg) {
return null;
}
const content = Array.isArray(msg.content) ? msg.content : [];
const parts: string[] = [];
for (const rawBlock of content) {
const block = toRecord(rawBlock);
if (!block || block.type !== "tool_result") {
continue;
}
const isError = block.is_error === true;
const prefix = isError ? ":x: Error" : ":white_check_mark: Result";
const resultText = typeof block.content === "string" ? block.content : "";
const truncated = resultText.length > 500 ? `${resultText.slice(0, 500)}...` : resultText;
if (!truncated) {
parts.push(`${prefix}: (empty)\n`);
} else {
parts.push(`${prefix}:\n\`\`\`\n${truncated}\n\`\`\`\n`);
}
}
if (parts.length === 0) {
return null;
}
return parts.join("");
}
return null;
}
const SYSTEM_PROMPT = `You are SPA (Spawn's Personal Agent), a Slack bot for the Spawn project (${GITHUB_REPO}).
Your primary job is to help manage GitHub issues based on Slack conversations:
@ -211,102 +84,6 @@ When creating issues, include a footer: "_Filed from Slack by SPA_"
Below is the full Slack thread. The most recent message is the one you should respond to. Prior messages are context.`;
const DOWNLOADS_DIR = "/tmp/spa-downloads";
/** Download a Slack-hosted file into a thread-scoped temp dir. Returns the local path or null. */
async function downloadSlackFile(url: string, filename: string, threadTs: string): Promise<string | null> {
try {
const resp = await fetch(url, {
headers: {
Authorization: `Bearer ${SLACK_BOT_TOKEN}`,
},
});
if (!resp.ok) {
console.error(`[spa] Failed to download ${filename}: ${resp.status}`);
return null;
}
const dir = `${DOWNLOADS_DIR}/${threadTs}`;
mkdirSync(dir, {
recursive: true,
});
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const localPath = `${dir}/${safeName}`;
const buf = await resp.arrayBuffer();
writeFileSync(localPath, Buffer.from(buf));
return localPath;
} catch (err) {
console.error(`[spa] Error downloading ${filename}:`, err);
return null;
}
}
/** Remove download directories older than 30 days. */
function cleanupStaleDownloads(): void {
if (!existsSync(DOWNLOADS_DIR)) {
return;
}
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
const cutoff = Date.now() - thirtyDaysMs;
let removed = 0;
try {
for (const entry of readdirSync(DOWNLOADS_DIR)) {
const entryPath = `${DOWNLOADS_DIR}/${entry}`;
try {
const stat = statSync(entryPath);
if (stat.isDirectory() && stat.mtimeMs < cutoff) {
rmSync(entryPath, {
recursive: true,
force: true,
});
removed++;
}
} catch {
// skip entries we can't stat
}
}
} catch {
// ignore if dir disappeared
}
if (removed > 0) {
console.log(`[spa] Cleaned up ${removed} stale download dir(s)`);
}
}
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const CLEANUP_TIMESTAMP_PATH = `${DOWNLOADS_DIR}/.last-cleanup`;
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
/** Run cleanup only if at least 1 hour since last run. Persists timestamp to disk. */
function runCleanupIfDue(): void {
try {
if (existsSync(CLEANUP_TIMESTAMP_PATH)) {
const lastRun = Number.parseInt(readFileSync(CLEANUP_TIMESTAMP_PATH, "utf-8").trim(), 10);
if (Date.now() - lastRun < CLEANUP_INTERVAL_MS) {
return;
}
}
} catch {
// file missing or unreadable — run cleanup
}
cleanupStaleDownloads();
try {
mkdirSync(DOWNLOADS_DIR, {
recursive: true,
});
writeFileSync(CLEANUP_TIMESTAMP_PATH, String(Date.now()));
} catch {
// non-fatal
}
}
/** Start the hourly cleanup schedule. */
function startCleanupSchedule(): void {
runCleanupIfDue();
cleanupTimer = setInterval(runCleanupIfDue, CLEANUP_INTERVAL_MS);
}
/**
* Fetch full thread history from Slack and format as a prompt.
*/
@ -354,9 +131,11 @@ async function buildThreadPrompt(
if (!url) {
continue;
}
const localPath = await downloadSlackFile(url, name, threadTs);
if (localPath) {
parts.push(`[File: ${name}] → ${localPath}`);
const dlResult = await downloadSlackFile(url, name, threadTs, SLACK_BOT_TOKEN);
if (dlResult.ok) {
parts.push(`[File: ${name}] → ${dlResult.data}`);
} else {
console.error(`[spa] ${dlResult.error.message}`);
}
}
}
@ -413,16 +192,22 @@ async function runClaudeAndStream(
args.push("--resume", sessionId);
}
args.push(prompt);
// Pass prompt via stdin to avoid CLI flag parsing issues with user content
args.push("-");
console.log(`[spa] Starting claude session (thread=${threadTs}, resume=${sessionId ?? "new"})`);
const proc = Bun.spawn(args, {
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
cwd: process.env.REPO_ROOT ?? process.cwd(),
});
// Write prompt to stdin and close
proc.stdin.write(prompt);
proc.stdin.end();
activeRuns.set(threadTs, {
proc,
startedAt: Date.now(),
@ -437,15 +222,62 @@ async function runClaudeAndStream(
})
.catch(() => null);
const updateTs = thinkingMsg?.ts;
let fullText = "";
let lastUpdateLen = 0;
let currentMsgTs = thinkingMsg?.ts;
let currentText = "";
let returnedSessionId: string | null = null;
let hasOutput = false;
// Throttle Slack updates — update at most every 2s
let lastUpdateTime = 0;
const UPDATE_INTERVAL_MS = 2000;
const MAX_MSG_LEN = 3900; // Slack limit ~4000, leave room
const MAX_MSG_LEN = 3800; // Slack limit ~4000, leave room for formatting
/** Update the current Slack message, or post a new one if at limit. */
async function flushToSlack(text: string, forceNew = false): Promise<void> {
if (!text) {
return;
}
hasOutput = true;
// Need a new message if text would exceed limit or forced
if (forceNew || !currentMsgTs || text.length > MAX_MSG_LEN) {
// If there's leftover text in the current message, finalize it first
if (currentMsgTs && currentText) {
await client.chat
.update({
channel,
ts: currentMsgTs,
text: currentText.slice(0, MAX_MSG_LEN),
})
.catch(() => {});
}
// Post a new message
const newMsg = await client.chat
.postMessage({
channel,
thread_ts: threadTs,
text: text.slice(0, MAX_MSG_LEN),
})
.catch(() => null);
currentMsgTs = newMsg?.ts;
currentText = text.slice(0, MAX_MSG_LEN);
return;
}
await client.chat
.update({
channel,
ts: currentMsgTs,
text: text.slice(0, MAX_MSG_LEN),
})
.catch(() => {});
currentText = text.slice(0, MAX_MSG_LEN);
}
// Accumulates text for the current "section" (consecutive text blocks)
let pendingText = "";
const decoder = new TextDecoder();
const reader = proc.stdout.getReader();
@ -488,27 +320,28 @@ async function runClaudeAndStream(
returnedSessionId = resultEvent.output.session_id;
}
// Format event for Slack display
const segment = formatStreamEvent(obj);
if (segment) {
fullText += segment;
// Parse event into typed segment
const segment = parseStreamEvent(obj);
if (!segment) {
continue;
}
if (segment.kind === "text") {
pendingText += segment.text;
} else {
// tool_use and tool_result get their own messages
if (pendingText) {
await flushToSlack(pendingText);
pendingText = "";
}
await flushToSlack(segment.text, true);
}
}
// Throttled Slack update
// Throttled update for accumulated text
const now = Date.now();
if (updateTs && fullText.length > lastUpdateLen && now - lastUpdateTime >= UPDATE_INTERVAL_MS) {
const displayText = fullText.length > MAX_MSG_LEN ? `...${fullText.slice(-MAX_MSG_LEN)}` : fullText;
await client.chat
.update({
channel,
ts: updateTs,
text: displayText,
})
.catch(() => {});
lastUpdateLen = fullText.length;
if (pendingText && now - lastUpdateTime >= UPDATE_INTERVAL_MS) {
await flushToSlack(pendingText);
lastUpdateTime = now;
}
}
@ -516,17 +349,22 @@ async function runClaudeAndStream(
activeRuns.delete(threadTs);
}
// Flush any remaining text
if (pendingText) {
await flushToSlack(pendingText);
}
// Read stderr for errors
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
if (exitCode !== 0 && !fullText) {
if (exitCode !== 0 && !hasOutput) {
console.error(`[spa] claude exited ${exitCode}: ${stderr}`);
if (updateTs) {
if (currentMsgTs) {
await client.chat
.update({
channel,
ts: updateTs,
ts: currentMsgTs,
text: `:x: Claude Code errored (exit ${exitCode}):\n\`\`\`\n${stderr.slice(0, 1500)}\n\`\`\``,
})
.catch(() => {});
@ -534,44 +372,23 @@ async function runClaudeAndStream(
return null;
}
// Final update with complete text
if (updateTs && fullText) {
const displayText = fullText.length > MAX_MSG_LEN ? `...${fullText.slice(-MAX_MSG_LEN)}` : fullText;
if (!hasOutput && currentMsgTs) {
await client.chat
.update({
channel,
ts: updateTs,
text: displayText,
})
.catch(() => {});
}
if (!fullText && updateTs) {
await client.chat
.update({
channel,
ts: updateTs,
ts: currentMsgTs,
text: ":white_check_mark: Done (no text output)",
})
.catch(() => {});
}
console.log(`[spa] Claude done (thread=${threadTs}, session=${returnedSessionId}, len=${fullText.length})`);
console.log(`[spa] Claude done (thread=${threadTs}, session=${returnedSessionId})`);
return returnedSessionId;
}
// #endregion
// #region Text helpers
function stripMention(text: string): string {
return text.replace(/<@[A-Z0-9]+>/g, "").trim();
}
// #endregion
// #region Core handler
async function handleThread(
@ -610,19 +427,21 @@ async function handleThread(
.catch(() => {});
// Run Claude Code and stream back
const sessionId = await runClaudeAndStream(client, channel, threadTs, prompt, existing?.sessionId);
const newSessionId = await runClaudeAndStream(client, channel, threadTs, prompt, existing?.sessionId);
// Save session mapping
if (sessionId && !existing) {
addMapping(state, {
if (newSessionId && !existing) {
const r = addMapping(state, {
channel,
threadTs,
sessionId,
sessionId: newSessionId,
createdAt: new Date().toISOString(),
});
} else if (sessionId && existing) {
existing.sessionId = sessionId;
saveState(state);
if (!r.ok) console.error(`[spa] ${r.error.message}`);
} else if (newSessionId && existing) {
existing.sessionId = newSessionId;
const r = saveState(state);
if (!r.ok) console.error(`[spa] ${r.error.message}`);
}
}
@ -653,16 +472,13 @@ app.event("app_mention", async ({ event, client }) => {
function shutdown(signal: string): void {
console.log(`[spa] Received ${signal}, shutting down...`);
if (cleanupTimer) {
clearInterval(cleanupTimer);
}
for (const [threadTs, run] of activeRuns) {
console.log(`[spa] Killing active run for thread ${threadTs}`);
run.proc.kill("SIGTERM");
}
saveState(state);
const r = saveState(state);
if (!r.ok) console.error(`[spa] ${r.error.message}`);
process.exit(0);
}
@ -674,7 +490,7 @@ process.on("SIGINT", () => shutdown("SIGINT"));
// #region Start
(async () => {
startCleanupSchedule();
runCleanupIfDue();
// Resolve our own bot user ID
const authResult = await app.client.auth.test({

View file

@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"start": "bun run slack-bot.ts"
"start": "bun run main.ts"
},
"dependencies": {
"@openrouter/spawn-shared": "workspace:*",

View file

@ -0,0 +1,275 @@
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test";
import { parseStreamEvent, stripMention, loadState, saveState, downloadSlackFile } from "./helpers";
import { toRecord } from "@openrouter/spawn-shared";
import streamEvents from "../../../fixtures/claude-code/stream-events.json";
// 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).toEqual({
kind: "text",
text: "I'll look at the issue and check the repository structure.",
});
});
it("parses assistant tool_use (Bash) from fixture", () => {
// fixture[1]: assistant with tool_use Bash
const result = parseStreamEvent(fixture(1));
expect(result?.kind).toBe("tool_use");
expect(result?.text).toContain(":hammer_and_wrench: *Bash*");
expect(result?.text).toContain("gh issue list");
});
it("parses user tool_result (success) from fixture", () => {
// fixture[2]: user with successful tool_result
const result = parseStreamEvent(fixture(2));
expect(result?.kind).toBe("tool_result");
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", () => {
// fixture[3]: assistant with tool_use Glob
const result = parseStreamEvent(fixture(3));
expect(result).toEqual({
kind: "tool_use",
text: ":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", () => {
// fixture[6]: user with is_error: true
const result = parseStreamEvent(fixture(6));
expect(result?.kind).toBe("tool_result");
expect(result?.text).toContain(":x: Error");
expect(result?.text).toContain("Permission denied");
});
it("parses final assistant text from fixture", () => {
// fixture[7]: assistant with summary text
const result = parseStreamEvent(fixture(7));
expect(result?.kind).toBe("text");
expect(result?.text).toContain("#1234");
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?.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).toEqual({ kind: "tool_use", text: ":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("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("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;
}
});
});

View file

@ -0,0 +1,7 @@
{
"agent": "claude-code",
"recorded_at": "2026-02-24T00:00:00Z",
"fixtures": {
"stream-events": {"format": "stream-json", "type": "synthetic", "recorded_at": "2026-02-24T00:00:00Z"}
}
}

View file

@ -0,0 +1,107 @@
[
{
"type": "assistant",
"message": {
"id": "msg_01ABC",
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "I'll look at the issue and check the repository structure."}
],
"model": "claude-sonnet-4-20250514",
"stop_reason": "end_turn"
}
},
{
"type": "assistant",
"message": {
"id": "msg_01DEF",
"type": "message",
"role": "assistant",
"content": [
{"type": "tool_use", "id": "toolu_01ABC", "name": "Bash", "input": {"command": "gh issue list --repo OpenRouterTeam/spawn --state open --limit 5"}}
],
"model": "claude-sonnet-4-20250514",
"stop_reason": "tool_use"
}
},
{
"type": "user",
"message": {
"id": "msg_user_01",
"type": "message",
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_01ABC", "content": "#1234 [Bug]: Fly.io deploy fails on arm64\n#1235 [CLI]: Add --json flag to list command", "is_error": false}
]
}
},
{
"type": "assistant",
"message": {
"id": "msg_01GHI",
"type": "message",
"role": "assistant",
"content": [
{"type": "tool_use", "id": "toolu_01DEF", "name": "Glob", "input": {"pattern": "**/*.ts"}}
],
"model": "claude-sonnet-4-20250514",
"stop_reason": "tool_use"
}
},
{
"type": "user",
"message": {
"id": "msg_user_02",
"type": "message",
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_01DEF", "content": "packages/cli/src/index.ts\npackages/cli/src/commands.ts\npackages/cli/src/manifest.ts", "is_error": false}
]
}
},
{
"type": "assistant",
"message": {
"id": "msg_01JKL",
"type": "message",
"role": "assistant",
"content": [
{"type": "tool_use", "id": "toolu_01GHI", "name": "Read", "input": {"file_path": "/home/user/spawn/packages/cli/src/index.ts"}}
],
"model": "claude-sonnet-4-20250514",
"stop_reason": "tool_use"
}
},
{
"type": "user",
"message": {
"id": "msg_user_03",
"type": "message",
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_01GHI", "content": "Command execution failed: Permission denied", "is_error": true}
]
}
},
{
"type": "assistant",
"message": {
"id": "msg_01MNO",
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "I've reviewed the open issues. Here's a summary:\n\n1. **#1234** - Fly.io deploy fails on arm64 architecture\n2. **#1235** - Request to add `--json` flag to the list command\n\nWould you like me to create a new issue or work on one of these?"}
],
"model": "claude-sonnet-4-20250514",
"stop_reason": "end_turn"
}
},
{
"type": "result",
"subtype": "success",
"session_id": "abc123-def456-ghi789",
"is_error": false,
"result": "I've reviewed the open issues and provided a summary."
}
]

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.10.0",
"version": "0.10.1",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -29,25 +29,42 @@ describe("aws/credential-cache", () => {
describe("loadCredsFromConfig", () => {
it("returns null when config file does not exist", () => {
if (existsSync(AWS_CONFIG_PATH)) { unlinkSync(AWS_CONFIG_PATH); }
if (existsSync(AWS_CONFIG_PATH)) {
unlinkSync(AWS_CONFIG_PATH);
}
expect(loadCredsFromConfig()).toBeNull();
});
it("returns null for malformed JSON", async () => {
await Bun.write(AWS_CONFIG_PATH, "not-json", { mode: 0o600 });
await Bun.write(AWS_CONFIG_PATH, "not-json", {
mode: 0o600,
});
expect(loadCredsFromConfig()).toBeNull();
});
it("returns null when accessKeyId is missing", async () => {
await Bun.write(AWS_CONFIG_PATH, JSON.stringify({ secretAccessKey: "secretsecretkey1234" }), { mode: 0o600 });
await Bun.write(
AWS_CONFIG_PATH,
JSON.stringify({
secretAccessKey: "secretsecretkey1234",
}),
{
mode: 0o600,
},
);
expect(loadCredsFromConfig()).toBeNull();
});
it("returns null when secretAccessKey is too short", async () => {
await Bun.write(
AWS_CONFIG_PATH,
JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "tooshort" }),
{ mode: 0o600 },
JSON.stringify({
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "tooshort",
}),
{
mode: 0o600,
},
);
expect(loadCredsFromConfig()).toBeNull();
});
@ -55,8 +72,13 @@ describe("aws/credential-cache", () => {
it("returns null for invalid accessKeyId format", async () => {
await Bun.write(
AWS_CONFIG_PATH,
JSON.stringify({ accessKeyId: "invalid key!", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY" }),
{ mode: 0o600 },
JSON.stringify({
accessKeyId: "invalid key!",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY",
}),
{
mode: 0o600,
},
);
expect(loadCredsFromConfig()).toBeNull();
});
@ -64,8 +86,14 @@ describe("aws/credential-cache", () => {
it("returns credentials for valid data", async () => {
await Bun.write(
AWS_CONFIG_PATH,
JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", region: "eu-west-1" }),
{ mode: 0o600 },
JSON.stringify({
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY",
region: "eu-west-1",
}),
{
mode: 0o600,
},
);
const result = loadCredsFromConfig();
expect(result).not.toBeNull();
@ -77,8 +105,13 @@ describe("aws/credential-cache", () => {
it("defaults region to us-east-1 when not stored", async () => {
await Bun.write(
AWS_CONFIG_PATH,
JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY" }),
{ mode: 0o600 },
JSON.stringify({
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY",
}),
{
mode: 0o600,
},
);
const result = loadCredsFromConfig();
expect(result?.region).toBe("us-east-1");
@ -87,7 +120,9 @@ describe("aws/credential-cache", () => {
describe("saveCredsToConfig", () => {
it("writes credentials to config file", async () => {
if (existsSync(AWS_CONFIG_PATH)) { unlinkSync(AWS_CONFIG_PATH); }
if (existsSync(AWS_CONFIG_PATH)) {
unlinkSync(AWS_CONFIG_PATH);
}
await saveCredsToConfig("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", "us-west-2");
const result = loadCredsFromConfig();
expect(result?.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE");
@ -96,7 +131,9 @@ describe("aws/credential-cache", () => {
});
it("round-trips credentials with special characters in secret key", async () => {
if (existsSync(AWS_CONFIG_PATH)) { unlinkSync(AWS_CONFIG_PATH); }
if (existsSync(AWS_CONFIG_PATH)) {
unlinkSync(AWS_CONFIG_PATH);
}
const secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCY==";
await saveCredsToConfig("AKIAIOSFODNN7EXAMPLE", secret, "ap-northeast-1");
const result = loadCredsFromConfig();

View file

@ -39,18 +39,34 @@ const AwsCredsSchema = v.object({
export async function saveCredsToConfig(accessKeyId: string, secretAccessKey: string, region: string): Promise<void> {
const dir = AWS_CONFIG_PATH.replace(/\/[^/]+$/, "");
await Bun.spawn(["mkdir", "-p", dir]).exited;
await Bun.spawn([
"mkdir",
"-p",
dir,
]).exited;
const payload = `{\n "accessKeyId": ${jsonEscape(accessKeyId)},\n "secretAccessKey": ${jsonEscape(secretAccessKey)},\n "region": ${jsonEscape(region)}\n}\n`;
await Bun.write(AWS_CONFIG_PATH, payload, { mode: 0o600 });
await Bun.write(AWS_CONFIG_PATH, payload, {
mode: 0o600,
});
}
export function loadCredsFromConfig(): { accessKeyId: string; secretAccessKey: string; region: string } | null {
export function loadCredsFromConfig(): {
accessKeyId: string;
secretAccessKey: string;
region: string;
} | null {
try {
const raw = readFileSync(AWS_CONFIG_PATH, "utf-8");
const data = parseJsonWith(raw, AwsCredsSchema);
if (!data?.accessKeyId || !data?.secretAccessKey) { return null; }
if (!/^[A-Za-z0-9/+]{16,128}$/.test(data.accessKeyId)) { return null; }
if (data.secretAccessKey.length < 16) { return null; }
if (!data?.accessKeyId || !data?.secretAccessKey) {
return null;
}
if (!/^[A-Za-z0-9/+]{16,128}$/.test(data.accessKeyId)) {
return null;
}
if (data.secretAccessKey.length < 16) {
return null;
}
return {
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
@ -521,7 +537,10 @@ export async function authenticate(): Promise<void> {
awsSecretAccessKey = cached.secretAccessKey;
if (hasAwsCli()) {
const result = awsCliSync(["sts", "get-caller-identity"]);
const result = awsCliSync([
"sts",
"get-caller-identity",
]);
if (result.exitCode === 0) {
lightsailMode = "cli";
logInfo(`AWS CLI ready with cached credentials, using region: ${cachedRegion}`);

View file

@ -55,7 +55,6 @@ function parseJson(text: string): Record<string, unknown> | null {
return parseJsonWith(text, LooseObject);
}
async function daytonaApi(method: string, endpoint: string, body?: string, maxRetries = 3): Promise<string> {
const url = `${DAYTONA_API_BASE}${endpoint}`;

View file

@ -171,8 +171,8 @@ export function openBrowser(url: string): void {
// ─── Result-based retry ────────────────────────────────────────────────
import type { Result } from "./result";
export { type Result, Ok, Err } from "./result";
import type { Result } from "@openrouter/spawn-shared";
export { type Result, Ok, Err } from "@openrouter/spawn-shared";
/**
* Phase-aware retry helper using the Result monad.

View file

@ -1,2 +1,3 @@
export { isString, isNumber, hasStatus, hasMessage, toRecord, toObjectArray } from "./type-guards";
export { parseJsonWith, parseJsonRaw } from "./parse";
export { type Result, Ok, Err } from "./result";