mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
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:
parent
65f6f1be32
commit
f5f020d250
15 changed files with 882 additions and 347 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
285
.claude/skills/setup-spa/helpers.ts
Normal file
285
.claude/skills/setup-spa/helpers.ts
Normal 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
|
||||
|
|
@ -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({
|
||||
|
|
@ -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:*",
|
||||
|
|
|
|||
275
.claude/skills/setup-spa/spa.test.ts
Normal file
275
.claude/skills/setup-spa/spa.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
7
fixtures/claude-code/_metadata.json
Normal file
7
fixtures/claude-code/_metadata.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
107
fixtures/claude-code/stream-events.json
Normal file
107
fixtures/claude-code/stream-events.json
Normal 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."
|
||||
}
|
||||
]
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.1",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue