storkit: create 365_story_surface_api_rate_limit_warnings_in_chat
This commit is contained in:
@@ -1,387 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentConfigInfo, AgentEvent, AgentInfo } from "./agents";
|
||||
import { agentsApi, subscribeAgentStream } from "./agents";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function okResponse(body: unknown) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(status: number, text: string) {
|
||||
return new Response(text, { status });
|
||||
}
|
||||
|
||||
const sampleAgent: AgentInfo = {
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: null,
|
||||
base_branch: null,
|
||||
log_session_id: null,
|
||||
};
|
||||
|
||||
const sampleConfig: AgentConfigInfo = {
|
||||
name: "coder",
|
||||
role: "engineer",
|
||||
stage: "coder",
|
||||
model: "claude-sonnet-4-6",
|
||||
allowed_tools: null,
|
||||
max_turns: null,
|
||||
max_budget_usd: null,
|
||||
};
|
||||
|
||||
// ── agentsApi ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("agentsApi", () => {
|
||||
describe("startAgent", () => {
|
||||
it("sends POST to /agents/start with story_id", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
|
||||
|
||||
const result = await agentsApi.startAgent("42_story_test");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/start",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
story_id: "42_story_test",
|
||||
agent_name: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(sampleAgent);
|
||||
});
|
||||
|
||||
it("sends POST with optional agent_name", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
|
||||
|
||||
await agentsApi.startAgent("42_story_test", "coder");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/start",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
|
||||
|
||||
await agentsApi.startAgent(
|
||||
"42_story_test",
|
||||
undefined,
|
||||
"http://localhost:3002/api",
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents/start",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopAgent", () => {
|
||||
it("sends POST to /agents/stop with story_id and agent_name", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(true));
|
||||
|
||||
const result = await agentsApi.stopAgent("42_story_test", "coder");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/stop",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(false));
|
||||
|
||||
await agentsApi.stopAgent(
|
||||
"42_story_test",
|
||||
"coder",
|
||||
"http://localhost:3002/api",
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents/stop",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAgents", () => {
|
||||
it("sends GET to /agents and returns agent list", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleAgent]));
|
||||
|
||||
const result = await agentsApi.listAgents();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(result).toEqual([sampleAgent]);
|
||||
});
|
||||
|
||||
it("returns empty array when no agents running", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([]));
|
||||
|
||||
const result = await agentsApi.listAgents();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([]));
|
||||
|
||||
await agentsApi.listAgents("http://localhost:3002/api");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentConfig", () => {
|
||||
it("sends GET to /agents/config and returns config list", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
|
||||
|
||||
const result = await agentsApi.getAgentConfig();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/config",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(result).toEqual([sampleConfig]);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
|
||||
|
||||
await agentsApi.getAgentConfig("http://localhost:3002/api");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents/config",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadConfig", () => {
|
||||
it("sends POST to /agents/config/reload", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
|
||||
|
||||
const result = await agentsApi.reloadConfig();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/config/reload",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(result).toEqual([sampleConfig]);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([]));
|
||||
|
||||
await agentsApi.reloadConfig("http://localhost:3002/api");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents/config/reload",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws on non-ok response with body text", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(404, "agent not found"));
|
||||
|
||||
await expect(agentsApi.listAgents()).rejects.toThrow("agent not found");
|
||||
});
|
||||
|
||||
it("throws with status code when no body", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
|
||||
|
||||
await expect(agentsApi.listAgents()).rejects.toThrow(
|
||||
"Request failed (500)",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── subscribeAgentStream ─────────────────────────────────────────────────────
|
||||
|
||||
interface MockESInstance {
|
||||
url: string;
|
||||
onmessage: ((e: { data: string }) => void) | null;
|
||||
onerror: ((e: Event) => void) | null;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
simulateMessage: (data: unknown) => void;
|
||||
simulateError: (e: Event) => void;
|
||||
}
|
||||
|
||||
function makeMockEventSource() {
|
||||
const instances: MockESInstance[] = [];
|
||||
|
||||
class MockEventSource {
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onerror: ((e: Event) => void) | null = null;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(public url: string) {
|
||||
instances.push(this as unknown as MockESInstance);
|
||||
}
|
||||
|
||||
simulateMessage(data: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
simulateError(e: Event) {
|
||||
this.onerror?.(e);
|
||||
}
|
||||
}
|
||||
|
||||
return { MockEventSource, instances };
|
||||
}
|
||||
|
||||
describe("subscribeAgentStream", () => {
|
||||
let instances: MockESInstance[];
|
||||
|
||||
beforeEach(() => {
|
||||
const { MockEventSource, instances: inst } = makeMockEventSource();
|
||||
instances = inst;
|
||||
vi.stubGlobal("EventSource", MockEventSource);
|
||||
});
|
||||
|
||||
it("creates an EventSource with encoded story and agent in URL", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0].url).toContain(
|
||||
`/agents/${encodeURIComponent("42_story_test")}/${encodeURIComponent("coder")}/stream`,
|
||||
);
|
||||
});
|
||||
|
||||
it("calls onEvent when a message is received", () => {
|
||||
const onEvent = vi.fn();
|
||||
subscribeAgentStream("42_story_test", "coder", onEvent);
|
||||
|
||||
const event: AgentEvent = { type: "output", text: "hello" };
|
||||
instances[0].simulateMessage(event);
|
||||
|
||||
expect(onEvent).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it("closes EventSource on 'done' type event", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
instances[0].simulateMessage({ type: "done" });
|
||||
|
||||
expect(instances[0].close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes EventSource on 'error' type event", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
instances[0].simulateMessage({
|
||||
type: "error",
|
||||
message: "something failed",
|
||||
});
|
||||
|
||||
expect(instances[0].close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes EventSource on status=stopped event", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
instances[0].simulateMessage({ type: "status", status: "stopped" });
|
||||
|
||||
expect(instances[0].close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not close on status=running event", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
instances[0].simulateMessage({ type: "status", status: "running" });
|
||||
|
||||
expect(instances[0].close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not close on 'output' event", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
instances[0].simulateMessage({ type: "output", text: "building..." });
|
||||
|
||||
expect(instances[0].close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onError and closes on EventSource onerror", () => {
|
||||
const onError = vi.fn();
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn(), onError);
|
||||
|
||||
const err = new Event("error");
|
||||
instances[0].simulateError(err);
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(err);
|
||||
expect(instances[0].close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes EventSource when onError is not provided", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
const err = new Event("error");
|
||||
instances[0].simulateError(err);
|
||||
|
||||
expect(instances[0].close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes EventSource when cleanup function is called", () => {
|
||||
const cleanup = subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(instances[0].close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles malformed JSON without throwing", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
expect(() => {
|
||||
instances[0].onmessage?.({ data: "{ not valid json" });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("delivers multiple events before a terminal event", () => {
|
||||
const onEvent = vi.fn();
|
||||
subscribeAgentStream("42_story_test", "coder", onEvent);
|
||||
|
||||
instances[0].simulateMessage({ type: "output", text: "line 1" });
|
||||
instances[0].simulateMessage({ type: "output", text: "line 2" });
|
||||
instances[0].simulateMessage({ type: "done" });
|
||||
|
||||
expect(onEvent).toHaveBeenCalledTimes(3);
|
||||
expect(instances[0].close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
export type AgentStatusValue = "pending" | "running" | "completed" | "failed";
|
||||
|
||||
export interface AgentInfo {
|
||||
story_id: string;
|
||||
agent_name: string;
|
||||
status: AgentStatusValue;
|
||||
session_id: string | null;
|
||||
worktree_path: string | null;
|
||||
base_branch: string | null;
|
||||
log_session_id: string | null;
|
||||
}
|
||||
|
||||
export interface AgentEvent {
|
||||
type:
|
||||
| "status"
|
||||
| "output"
|
||||
| "thinking"
|
||||
| "agent_json"
|
||||
| "done"
|
||||
| "error"
|
||||
| "warning";
|
||||
story_id?: string;
|
||||
agent_name?: string;
|
||||
status?: string;
|
||||
text?: string;
|
||||
data?: unknown;
|
||||
session_id?: string | null;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface AgentConfigInfo {
|
||||
name: string;
|
||||
role: string;
|
||||
stage: string | null;
|
||||
model: string | null;
|
||||
allowed_tools: string[] | null;
|
||||
max_turns: number | null;
|
||||
max_budget_usd: number | null;
|
||||
}
|
||||
|
||||
const DEFAULT_API_BASE = "/api";
|
||||
|
||||
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
baseUrl = DEFAULT_API_BASE,
|
||||
): Promise<T> {
|
||||
const res = await fetch(buildApiUrl(path, baseUrl), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const agentsApi = {
|
||||
startAgent(storyId: string, agentName?: string, baseUrl?: string) {
|
||||
return requestJson<AgentInfo>(
|
||||
"/agents/start",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
story_id: storyId,
|
||||
agent_name: agentName,
|
||||
}),
|
||||
},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
|
||||
stopAgent(storyId: string, agentName: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/agents/stop",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
story_id: storyId,
|
||||
agent_name: agentName,
|
||||
}),
|
||||
},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
|
||||
listAgents(baseUrl?: string) {
|
||||
return requestJson<AgentInfo[]>("/agents", {}, baseUrl);
|
||||
},
|
||||
|
||||
getAgentConfig(baseUrl?: string) {
|
||||
return requestJson<AgentConfigInfo[]>("/agents/config", {}, baseUrl);
|
||||
},
|
||||
|
||||
reloadConfig(baseUrl?: string) {
|
||||
return requestJson<AgentConfigInfo[]>(
|
||||
"/agents/config/reload",
|
||||
{ method: "POST" },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
|
||||
getAgentOutput(storyId: string, agentName: string, baseUrl?: string) {
|
||||
return requestJson<{ output: string }>(
|
||||
`/agents/${encodeURIComponent(storyId)}/${encodeURIComponent(agentName)}/output`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to SSE events for a running agent.
|
||||
* Returns a cleanup function to close the connection.
|
||||
*/
|
||||
export function subscribeAgentStream(
|
||||
storyId: string,
|
||||
agentName: string,
|
||||
onEvent: (event: AgentEvent) => void,
|
||||
onError?: (error: Event) => void,
|
||||
): () => void {
|
||||
const url = `/agents/${encodeURIComponent(storyId)}/${encodeURIComponent(agentName)}/stream`;
|
||||
|
||||
const eventSource = new EventSource(url);
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data) as AgentEvent;
|
||||
onEvent(data);
|
||||
|
||||
// Close on terminal events
|
||||
if (
|
||||
data.type === "done" ||
|
||||
data.type === "error" ||
|
||||
(data.type === "status" && data.status === "stopped")
|
||||
) {
|
||||
eventSource.close();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to parse agent event:", err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (e) => {
|
||||
onError?.(e);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api, ChatWebSocket, resolveWsHost } from "./client";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function okResponse(body: unknown) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(status: number, text: string) {
|
||||
return new Response(text, { status });
|
||||
}
|
||||
|
||||
describe("api client", () => {
|
||||
describe("getCurrentProject", () => {
|
||||
it("sends GET to /project", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse("/home/user/project"));
|
||||
|
||||
const result = await api.getCurrentProject();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/project",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(result).toBe("/home/user/project");
|
||||
});
|
||||
|
||||
it("returns null when no project open", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(null));
|
||||
|
||||
const result = await api.getCurrentProject();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openProject", () => {
|
||||
it("sends POST with path", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse("/home/user/project"));
|
||||
|
||||
await api.openProject("/home/user/project");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/project",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path: "/home/user/project" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeProject", () => {
|
||||
it("sends DELETE to /project", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(true));
|
||||
|
||||
await api.closeProject();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/project",
|
||||
expect.objectContaining({ method: "DELETE" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKnownProjects", () => {
|
||||
it("returns array of project paths", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(["/a", "/b"]));
|
||||
|
||||
const result = await api.getKnownProjects();
|
||||
expect(result).toEqual(["/a", "/b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws on non-ok response with body text", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(404, "Not found"));
|
||||
|
||||
await expect(api.getCurrentProject()).rejects.toThrow("Not found");
|
||||
});
|
||||
|
||||
it("throws with status code when no body", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
|
||||
|
||||
await expect(api.getCurrentProject()).rejects.toThrow(
|
||||
"Request failed (500)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchFiles", () => {
|
||||
it("sends POST with query", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
okResponse([{ path: "src/main.rs", matches: 1 }]),
|
||||
);
|
||||
|
||||
const result = await api.searchFiles("hello");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/fs/search",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ query: "hello" }),
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("execShell", () => {
|
||||
it("sends POST with command and args", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
okResponse({ stdout: "output", stderr: "", exit_code: 0 }),
|
||||
);
|
||||
|
||||
const result = await api.execShell("ls", ["-la"]);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/shell/exec",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ command: "ls", args: ["-la"] }),
|
||||
}),
|
||||
);
|
||||
expect(result.exit_code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWsHost", () => {
|
||||
it("uses env port in dev mode", () => {
|
||||
expect(resolveWsHost(true, "4200", "example.com")).toBe("127.0.0.1:4200");
|
||||
});
|
||||
|
||||
it("defaults to 3001 in dev mode when no env port", () => {
|
||||
expect(resolveWsHost(true, undefined, "example.com")).toBe(
|
||||
"127.0.0.1:3001",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses location host in production", () => {
|
||||
expect(resolveWsHost(false, "4200", "myapp.com:8080")).toBe(
|
||||
"myapp.com:8080",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── ChatWebSocket reconnect tests ───────────────────────────────────────────
|
||||
|
||||
interface MockWsInstance {
|
||||
onopen: (() => void) | null;
|
||||
onclose: (() => void) | null;
|
||||
onmessage: ((e: { data: string }) => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
readyState: number;
|
||||
sentMessages: string[];
|
||||
send: (data: string) => void;
|
||||
close: () => void;
|
||||
simulateClose: () => void;
|
||||
simulateMessage: (data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function makeMockWebSocket() {
|
||||
const instances: MockWsInstance[] = [];
|
||||
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
onopen: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
readyState = 0;
|
||||
sentMessages: string[] = [];
|
||||
|
||||
constructor(_url: string) {
|
||||
instances.push(this as unknown as MockWsInstance);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sentMessages.push(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
simulateClose() {
|
||||
this.readyState = 3;
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
simulateMessage(data: Record<string, unknown>) {
|
||||
this.onmessage?.({ data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
|
||||
return { MockWebSocket, instances };
|
||||
}
|
||||
|
||||
describe("ChatWebSocket", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
const { MockWebSocket } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
// Reset shared static state between tests
|
||||
(ChatWebSocket as unknown as { sharedSocket: null }).sharedSocket = null;
|
||||
(ChatWebSocket as unknown as { refCount: number }).refCount = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("schedules reconnect after socket closes unexpectedly", () => {
|
||||
const { MockWebSocket, instances } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const ws = new ChatWebSocket();
|
||||
ws.connect({});
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
|
||||
instances[0].simulateClose();
|
||||
|
||||
// No new socket created yet
|
||||
expect(instances).toHaveLength(1);
|
||||
|
||||
// Advance past the initial 1s reconnect delay
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
// A new socket should now have been created
|
||||
expect(instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("delivers pipeline_state after reconnect", () => {
|
||||
const { MockWebSocket, instances } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const onPipelineState = vi.fn();
|
||||
const ws = new ChatWebSocket();
|
||||
ws.connect({ onPipelineState });
|
||||
|
||||
// Simulate server restart
|
||||
instances[0].simulateClose();
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
// Server pushes pipeline_state on fresh connection
|
||||
const freshState = {
|
||||
backlog: [{ story_id: "1_story_test", name: "Test", error: null }],
|
||||
current: [],
|
||||
qa: [],
|
||||
merge: [],
|
||||
done: [],
|
||||
};
|
||||
instances[1].simulateMessage({ type: "pipeline_state", ...freshState });
|
||||
|
||||
expect(onPipelineState).toHaveBeenCalledWith(freshState);
|
||||
});
|
||||
|
||||
it("does not reconnect after explicit close()", () => {
|
||||
const { MockWebSocket, instances } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const ws = new ChatWebSocket();
|
||||
ws.connect({});
|
||||
|
||||
// Explicit close disables reconnect
|
||||
ws.close();
|
||||
|
||||
// Advance through both the DEV close-defer (250ms) and reconnect window
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
// No new socket should be created
|
||||
expect(instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses exponential backoff on repeated failures", () => {
|
||||
const { MockWebSocket, instances } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const ws = new ChatWebSocket();
|
||||
ws.connect({});
|
||||
|
||||
// First close → reconnects after 1s
|
||||
instances[0].simulateClose();
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(instances).toHaveLength(2);
|
||||
|
||||
// Second close → reconnects after 2s (doubled)
|
||||
instances[1].simulateClose();
|
||||
vi.advanceTimersByTime(1500);
|
||||
// Not yet (delay is now 2s)
|
||||
expect(instances).toHaveLength(2);
|
||||
vi.advanceTimersByTime(600);
|
||||
expect(instances).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("resets reconnect delay after successful open", () => {
|
||||
const { MockWebSocket, instances } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const ws = new ChatWebSocket();
|
||||
ws.connect({});
|
||||
|
||||
// Disconnect and reconnect twice to raise the delay
|
||||
instances[0].simulateClose();
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
instances[1].simulateClose();
|
||||
vi.advanceTimersByTime(2001);
|
||||
|
||||
// Simulate a successful open on third socket — resets delay to 1s
|
||||
instances[2].onopen?.();
|
||||
|
||||
// Close again — should use the reset 1s delay
|
||||
instances[2].simulateClose();
|
||||
vi.advanceTimersByTime(1001);
|
||||
|
||||
expect(instances).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatWebSocket heartbeat", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
const { MockWebSocket } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
(ChatWebSocket as unknown as { sharedSocket: null }).sharedSocket = null;
|
||||
(ChatWebSocket as unknown as { refCount: number }).refCount = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("sends ping after heartbeat interval", () => {
|
||||
const { MockWebSocket, instances } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const ws = new ChatWebSocket();
|
||||
ws.connect({});
|
||||
instances[0].readyState = 1; // OPEN
|
||||
instances[0].onopen?.(); // starts heartbeat
|
||||
|
||||
vi.advanceTimersByTime(29_999);
|
||||
expect(instances[0].sentMessages).toHaveLength(0);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(instances[0].sentMessages).toHaveLength(1);
|
||||
expect(JSON.parse(instances[0].sentMessages[0])).toEqual({ type: "ping" });
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it("closes stale connection when pong is not received", () => {
|
||||
const { MockWebSocket, instances } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const ws = new ChatWebSocket();
|
||||
ws.connect({});
|
||||
instances[0].readyState = 1; // OPEN
|
||||
instances[0].onopen?.(); // starts heartbeat
|
||||
|
||||
// Fire heartbeat — sends ping and starts pong timeout
|
||||
vi.advanceTimersByTime(30_000);
|
||||
|
||||
// No pong received; advance past pong timeout → socket closed → reconnect scheduled
|
||||
vi.advanceTimersByTime(5_000);
|
||||
|
||||
// Advance past reconnect delay
|
||||
vi.advanceTimersByTime(1_001);
|
||||
|
||||
expect(instances).toHaveLength(2);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it("does not close when pong is received before timeout", () => {
|
||||
const { MockWebSocket, instances } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const ws = new ChatWebSocket();
|
||||
ws.connect({});
|
||||
instances[0].readyState = 1; // OPEN
|
||||
instances[0].onopen?.(); // starts heartbeat
|
||||
|
||||
// Fire heartbeat
|
||||
vi.advanceTimersByTime(30_000);
|
||||
|
||||
// Server responds with pong — clears the pong timeout
|
||||
instances[0].simulateMessage({ type: "pong" });
|
||||
|
||||
// Advance past where pong timeout would have fired
|
||||
vi.advanceTimersByTime(5_001);
|
||||
|
||||
// No reconnect triggered
|
||||
expect(instances).toHaveLength(1);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it("stops sending pings after explicit close", () => {
|
||||
const { MockWebSocket, instances } = makeMockWebSocket();
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
const ws = new ChatWebSocket();
|
||||
ws.connect({});
|
||||
instances[0].readyState = 1; // OPEN
|
||||
instances[0].onopen?.(); // starts heartbeat
|
||||
|
||||
ws.close();
|
||||
|
||||
// Advance well past multiple heartbeat intervals
|
||||
vi.advanceTimersByTime(90_000);
|
||||
|
||||
expect(instances[0].sentMessages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,699 +0,0 @@
|
||||
export type WsRequest =
|
||||
| {
|
||||
type: "chat";
|
||||
messages: Message[];
|
||||
config: ProviderConfig;
|
||||
}
|
||||
| {
|
||||
type: "cancel";
|
||||
}
|
||||
| {
|
||||
type: "permission_response";
|
||||
request_id: string;
|
||||
approved: boolean;
|
||||
always_allow: boolean;
|
||||
}
|
||||
| { type: "ping" }
|
||||
| {
|
||||
type: "side_question";
|
||||
question: string;
|
||||
context_messages: Message[];
|
||||
config: ProviderConfig;
|
||||
};
|
||||
|
||||
export interface AgentAssignment {
|
||||
agent_name: string;
|
||||
model: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface PipelineStageItem {
|
||||
story_id: string;
|
||||
name: string | null;
|
||||
error: string | null;
|
||||
merge_failure: string | null;
|
||||
agent: AgentAssignment | null;
|
||||
review_hold: boolean | null;
|
||||
qa: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineState {
|
||||
backlog: PipelineStageItem[];
|
||||
current: PipelineStageItem[];
|
||||
qa: PipelineStageItem[];
|
||||
merge: PipelineStageItem[];
|
||||
done: PipelineStageItem[];
|
||||
}
|
||||
|
||||
export type WsResponse =
|
||||
| { type: "token"; content: string }
|
||||
| { type: "update"; messages: Message[] }
|
||||
| { type: "session_id"; session_id: string }
|
||||
| { type: "error"; message: string }
|
||||
| {
|
||||
type: "pipeline_state";
|
||||
backlog: PipelineStageItem[];
|
||||
current: PipelineStageItem[];
|
||||
qa: PipelineStageItem[];
|
||||
merge: PipelineStageItem[];
|
||||
done: PipelineStageItem[];
|
||||
}
|
||||
| {
|
||||
type: "permission_request";
|
||||
request_id: string;
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
}
|
||||
| { type: "tool_activity"; tool_name: string }
|
||||
| {
|
||||
type: "reconciliation_progress";
|
||||
story_id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
/** `.story_kit/project.toml` was modified; re-fetch the agent roster. */
|
||||
| { type: "agent_config_changed" }
|
||||
/** An agent started, stopped, or changed state; re-fetch agent list. */
|
||||
| { type: "agent_state_changed" }
|
||||
| { type: "tool_activity"; tool_name: string }
|
||||
/** Heartbeat response confirming the connection is alive. */
|
||||
| { type: "pong" }
|
||||
/** Sent on connect when the project still needs onboarding (specs are placeholders). */
|
||||
| { type: "onboarding_status"; needs_onboarding: boolean }
|
||||
/** Streaming thinking token from an extended-thinking block, separate from regular text. */
|
||||
| { type: "thinking_token"; content: string }
|
||||
/** Streaming token from a /btw side question response. */
|
||||
| { type: "side_question_token"; content: string }
|
||||
/** Final signal that the /btw side question has been fully answered. */
|
||||
| { type: "side_question_done"; response: string }
|
||||
/** A single server log entry (bulk on connect, then live). */
|
||||
| { type: "log_entry"; timestamp: string; level: string; message: string };
|
||||
|
||||
export interface ProviderConfig {
|
||||
provider: string;
|
||||
model: string;
|
||||
base_url?: string;
|
||||
enable_tools?: boolean;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
export type Role = "system" | "user" | "assistant" | "tool";
|
||||
|
||||
export interface ToolCall {
|
||||
id?: string;
|
||||
type: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
role: Role;
|
||||
content: string;
|
||||
tool_calls?: ToolCall[];
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
export interface AnthropicModelInfo {
|
||||
id: string;
|
||||
context_window: number;
|
||||
}
|
||||
|
||||
export interface WorkItemContent {
|
||||
content: string;
|
||||
stage: string;
|
||||
name: string | null;
|
||||
agent: string | null;
|
||||
}
|
||||
|
||||
export interface TestCaseResult {
|
||||
name: string;
|
||||
status: "pass" | "fail";
|
||||
details: string | null;
|
||||
}
|
||||
|
||||
export interface TestResultsResponse {
|
||||
unit: TestCaseResult[];
|
||||
integration: TestCaseResult[];
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
kind: "file" | "dir";
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
path: string;
|
||||
matches: number;
|
||||
}
|
||||
|
||||
export interface AgentCostEntry {
|
||||
agent_name: string;
|
||||
model: string | null;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_creation_input_tokens: number;
|
||||
cache_read_input_tokens: number;
|
||||
total_cost_usd: number;
|
||||
}
|
||||
|
||||
export interface TokenCostResponse {
|
||||
total_cost_usd: number;
|
||||
agents: AgentCostEntry[];
|
||||
}
|
||||
|
||||
export interface TokenUsageRecord {
|
||||
story_id: string;
|
||||
agent_name: string;
|
||||
model: string | null;
|
||||
timestamp: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_creation_input_tokens: number;
|
||||
cache_read_input_tokens: number;
|
||||
total_cost_usd: number;
|
||||
}
|
||||
|
||||
export interface AllTokenUsageResponse {
|
||||
records: TokenUsageRecord[];
|
||||
}
|
||||
|
||||
export interface CommandOutput {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exit_code: number;
|
||||
}
|
||||
|
||||
declare const __STORKIT_PORT__: string;
|
||||
|
||||
const DEFAULT_API_BASE = "/api";
|
||||
const DEFAULT_WS_PATH = "/ws";
|
||||
|
||||
export function resolveWsHost(
|
||||
isDev: boolean,
|
||||
envPort: string | undefined,
|
||||
locationHost: string,
|
||||
): string {
|
||||
return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost;
|
||||
}
|
||||
|
||||
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
baseUrl = DEFAULT_API_BASE,
|
||||
): Promise<T> {
|
||||
const res = await fetch(buildApiUrl(path, baseUrl), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getCurrentProject(baseUrl?: string) {
|
||||
return requestJson<string | null>("/project", {}, baseUrl);
|
||||
},
|
||||
getKnownProjects(baseUrl?: string) {
|
||||
return requestJson<string[]>("/projects", {}, baseUrl);
|
||||
},
|
||||
forgetKnownProject(path: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/projects/forget",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
openProject(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
"/project",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
closeProject(baseUrl?: string) {
|
||||
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
|
||||
},
|
||||
getModelPreference(baseUrl?: string) {
|
||||
return requestJson<string | null>("/model", {}, baseUrl);
|
||||
},
|
||||
setModelPreference(model: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/model",
|
||||
{ method: "POST", body: JSON.stringify({ model }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getOllamaModels(baseUrlParam?: string, baseUrl?: string) {
|
||||
const url = new URL(
|
||||
buildApiUrl("/ollama/models", baseUrl),
|
||||
window.location.origin,
|
||||
);
|
||||
if (baseUrlParam) {
|
||||
url.searchParams.set("base_url", baseUrlParam);
|
||||
}
|
||||
return requestJson<string[]>(url.pathname + url.search, {}, "");
|
||||
},
|
||||
getAnthropicApiKeyExists(baseUrl?: string) {
|
||||
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
|
||||
},
|
||||
getAnthropicModels(baseUrl?: string) {
|
||||
return requestJson<AnthropicModelInfo[]>("/anthropic/models", {}, baseUrl);
|
||||
},
|
||||
setAnthropicApiKey(api_key: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/anthropic/key",
|
||||
{ method: "POST", body: JSON.stringify({ api_key }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
readFile(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
"/fs/read",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
writeFile(path: string, content: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/fs/write",
|
||||
{ method: "POST", body: JSON.stringify({ path, content }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
listDirectory(path: string, baseUrl?: string) {
|
||||
return requestJson<FileEntry[]>(
|
||||
"/fs/list",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
listDirectoryAbsolute(path: string, baseUrl?: string) {
|
||||
return requestJson<FileEntry[]>(
|
||||
"/io/fs/list/absolute",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
createDirectoryAbsolute(path: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/io/fs/create/absolute",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getHomeDirectory(baseUrl?: string) {
|
||||
return requestJson<string>("/io/fs/home", {}, baseUrl);
|
||||
},
|
||||
listProjectFiles(baseUrl?: string) {
|
||||
return requestJson<string[]>("/io/fs/files", {}, baseUrl);
|
||||
},
|
||||
searchFiles(query: string, baseUrl?: string) {
|
||||
return requestJson<SearchResult[]>(
|
||||
"/fs/search",
|
||||
{ method: "POST", body: JSON.stringify({ query }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
execShell(command: string, args: string[], baseUrl?: string) {
|
||||
return requestJson<CommandOutput>(
|
||||
"/shell/exec",
|
||||
{ method: "POST", body: JSON.stringify({ command, args }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
cancelChat(baseUrl?: string) {
|
||||
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
|
||||
},
|
||||
getWorkItemContent(storyId: string, baseUrl?: string) {
|
||||
return requestJson<WorkItemContent>(
|
||||
`/work-items/${encodeURIComponent(storyId)}`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getTestResults(storyId: string, baseUrl?: string) {
|
||||
return requestJson<TestResultsResponse | null>(
|
||||
`/work-items/${encodeURIComponent(storyId)}/test-results`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getTokenCost(storyId: string, baseUrl?: string) {
|
||||
return requestJson<TokenCostResponse>(
|
||||
`/work-items/${encodeURIComponent(storyId)}/token-cost`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getAllTokenUsage(baseUrl?: string) {
|
||||
return requestJson<AllTokenUsageResponse>("/token-usage", {}, baseUrl);
|
||||
},
|
||||
/** Trigger a server rebuild and restart. */
|
||||
rebuildAndRestart() {
|
||||
return callMcpTool("rebuild_and_restart", {});
|
||||
},
|
||||
/** Approve a story in QA, moving it to merge. */
|
||||
approveQa(storyId: string) {
|
||||
return callMcpTool("approve_qa", { story_id: storyId });
|
||||
},
|
||||
/** Reject a story in QA, moving it back to current with notes. */
|
||||
rejectQa(storyId: string, notes: string) {
|
||||
return callMcpTool("reject_qa", { story_id: storyId, notes });
|
||||
},
|
||||
/** Launch the QA app for a story's worktree. */
|
||||
launchQaApp(storyId: string) {
|
||||
return callMcpTool("launch_qa_app", { story_id: storyId });
|
||||
},
|
||||
/** Delete a story from the pipeline, stopping any running agent and removing the worktree. */
|
||||
deleteStory(storyId: string) {
|
||||
return callMcpTool("delete_story", { story_id: storyId });
|
||||
},
|
||||
};
|
||||
|
||||
async function callMcpTool(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const res = await fetch("/mcp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: toolName, arguments: args },
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
const text = json.result?.content?.[0]?.text ?? "";
|
||||
return text;
|
||||
}
|
||||
|
||||
export class ChatWebSocket {
|
||||
private static sharedSocket: WebSocket | null = null;
|
||||
private static refCount = 0;
|
||||
private socket?: WebSocket;
|
||||
private onToken?: (content: string) => void;
|
||||
private onThinkingToken?: (content: string) => void;
|
||||
private onUpdate?: (messages: Message[]) => void;
|
||||
private onSessionId?: (sessionId: string) => void;
|
||||
private onError?: (message: string) => void;
|
||||
private onPipelineState?: (state: PipelineState) => void;
|
||||
private onPermissionRequest?: (
|
||||
requestId: string,
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
) => void;
|
||||
private onActivity?: (toolName: string) => void;
|
||||
private onReconciliationProgress?: (
|
||||
storyId: string,
|
||||
status: string,
|
||||
message: string,
|
||||
) => void;
|
||||
private onAgentConfigChanged?: () => void;
|
||||
private onAgentStateChanged?: () => void;
|
||||
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||
private onSideQuestionToken?: (content: string) => void;
|
||||
private onSideQuestionDone?: (response: string) => void;
|
||||
private onLogEntry?: (
|
||||
timestamp: string,
|
||||
level: string,
|
||||
message: string,
|
||||
) => void;
|
||||
private onConnected?: () => void;
|
||||
private connected = false;
|
||||
private closeTimer?: number;
|
||||
private wsPath = DEFAULT_WS_PATH;
|
||||
private reconnectTimer?: number;
|
||||
private reconnectDelay = 1000;
|
||||
private shouldReconnect = false;
|
||||
private heartbeatInterval?: number;
|
||||
private heartbeatTimeout?: number;
|
||||
private static readonly HEARTBEAT_INTERVAL = 30_000;
|
||||
private static readonly HEARTBEAT_TIMEOUT = 5_000;
|
||||
|
||||
private _startHeartbeat(): void {
|
||||
this._stopHeartbeat();
|
||||
this.heartbeatInterval = window.setInterval(() => {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
|
||||
const ping: WsRequest = { type: "ping" };
|
||||
this.socket.send(JSON.stringify(ping));
|
||||
this.heartbeatTimeout = window.setTimeout(() => {
|
||||
// No pong received within timeout; close socket to trigger reconnect.
|
||||
this.socket?.close();
|
||||
}, ChatWebSocket.HEARTBEAT_TIMEOUT);
|
||||
}, ChatWebSocket.HEARTBEAT_INTERVAL);
|
||||
}
|
||||
|
||||
private _stopHeartbeat(): void {
|
||||
window.clearInterval(this.heartbeatInterval);
|
||||
window.clearTimeout(this.heartbeatTimeout);
|
||||
this.heartbeatInterval = undefined;
|
||||
this.heartbeatTimeout = undefined;
|
||||
}
|
||||
|
||||
private _buildWsUrl(): string {
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const wsHost = resolveWsHost(
|
||||
import.meta.env.DEV,
|
||||
typeof __STORKIT_PORT__ !== "undefined" ? __STORKIT_PORT__ : undefined,
|
||||
window.location.host,
|
||||
);
|
||||
return `${protocol}://${wsHost}${this.wsPath}`;
|
||||
}
|
||||
|
||||
private _attachHandlers(): void {
|
||||
if (!this.socket) return;
|
||||
this.socket.onopen = () => {
|
||||
this.reconnectDelay = 1000;
|
||||
this._startHeartbeat();
|
||||
this.onConnected?.();
|
||||
};
|
||||
this.socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as WsResponse;
|
||||
if (data.type === "token") this.onToken?.(data.content);
|
||||
if (data.type === "thinking_token")
|
||||
this.onThinkingToken?.(data.content);
|
||||
if (data.type === "update") this.onUpdate?.(data.messages);
|
||||
if (data.type === "session_id") this.onSessionId?.(data.session_id);
|
||||
if (data.type === "error") this.onError?.(data.message);
|
||||
if (data.type === "pipeline_state")
|
||||
this.onPipelineState?.({
|
||||
backlog: data.backlog,
|
||||
current: data.current,
|
||||
qa: data.qa,
|
||||
merge: data.merge,
|
||||
done: data.done,
|
||||
});
|
||||
if (data.type === "permission_request")
|
||||
this.onPermissionRequest?.(
|
||||
data.request_id,
|
||||
data.tool_name,
|
||||
data.tool_input,
|
||||
);
|
||||
if (data.type === "tool_activity") this.onActivity?.(data.tool_name);
|
||||
if (data.type === "reconciliation_progress")
|
||||
this.onReconciliationProgress?.(
|
||||
data.story_id,
|
||||
data.status,
|
||||
data.message,
|
||||
);
|
||||
if (data.type === "agent_config_changed") this.onAgentConfigChanged?.();
|
||||
if (data.type === "agent_state_changed") this.onAgentStateChanged?.();
|
||||
if (data.type === "onboarding_status")
|
||||
this.onOnboardingStatus?.(data.needs_onboarding);
|
||||
if (data.type === "side_question_token")
|
||||
this.onSideQuestionToken?.(data.content);
|
||||
if (data.type === "side_question_done")
|
||||
this.onSideQuestionDone?.(data.response);
|
||||
if (data.type === "log_entry")
|
||||
this.onLogEntry?.(data.timestamp, data.level, data.message);
|
||||
if (data.type === "pong") {
|
||||
window.clearTimeout(this.heartbeatTimeout);
|
||||
this.heartbeatTimeout = undefined;
|
||||
}
|
||||
} catch (err) {
|
||||
this.onError?.(String(err));
|
||||
}
|
||||
};
|
||||
this.socket.onerror = () => {
|
||||
this.onError?.("WebSocket error");
|
||||
};
|
||||
this.socket.onclose = () => {
|
||||
if (this.shouldReconnect && this.connected) {
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private _scheduleReconnect(): void {
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
const delay = this.reconnectDelay;
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = undefined;
|
||||
const wsUrl = this._buildWsUrl();
|
||||
ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
|
||||
this.socket = ChatWebSocket.sharedSocket;
|
||||
this._attachHandlers();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
connect(
|
||||
handlers: {
|
||||
onToken?: (content: string) => void;
|
||||
onThinkingToken?: (content: string) => void;
|
||||
onUpdate?: (messages: Message[]) => void;
|
||||
onSessionId?: (sessionId: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
onPipelineState?: (state: PipelineState) => void;
|
||||
onPermissionRequest?: (
|
||||
requestId: string,
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
) => void;
|
||||
onActivity?: (toolName: string) => void;
|
||||
onReconciliationProgress?: (
|
||||
storyId: string,
|
||||
status: string,
|
||||
message: string,
|
||||
) => void;
|
||||
onAgentConfigChanged?: () => void;
|
||||
onAgentStateChanged?: () => void;
|
||||
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||
onSideQuestionToken?: (content: string) => void;
|
||||
onSideQuestionDone?: (response: string) => void;
|
||||
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
||||
onConnected?: () => void;
|
||||
},
|
||||
wsPath = DEFAULT_WS_PATH,
|
||||
) {
|
||||
this.onToken = handlers.onToken;
|
||||
this.onThinkingToken = handlers.onThinkingToken;
|
||||
this.onUpdate = handlers.onUpdate;
|
||||
this.onSessionId = handlers.onSessionId;
|
||||
this.onError = handlers.onError;
|
||||
this.onPipelineState = handlers.onPipelineState;
|
||||
this.onPermissionRequest = handlers.onPermissionRequest;
|
||||
this.onActivity = handlers.onActivity;
|
||||
this.onReconciliationProgress = handlers.onReconciliationProgress;
|
||||
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
|
||||
this.onAgentStateChanged = handlers.onAgentStateChanged;
|
||||
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
||||
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
||||
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
||||
this.onLogEntry = handlers.onLogEntry;
|
||||
this.onConnected = handlers.onConnected;
|
||||
this.wsPath = wsPath;
|
||||
this.shouldReconnect = true;
|
||||
|
||||
if (this.connected) {
|
||||
return;
|
||||
}
|
||||
this.connected = true;
|
||||
ChatWebSocket.refCount += 1;
|
||||
|
||||
if (
|
||||
!ChatWebSocket.sharedSocket ||
|
||||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED ||
|
||||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING
|
||||
) {
|
||||
const wsUrl = this._buildWsUrl();
|
||||
ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
|
||||
}
|
||||
this.socket = ChatWebSocket.sharedSocket;
|
||||
this._attachHandlers();
|
||||
}
|
||||
|
||||
sendChat(messages: Message[], config: ProviderConfig) {
|
||||
this.send({ type: "chat", messages, config });
|
||||
}
|
||||
|
||||
sendSideQuestion(
|
||||
question: string,
|
||||
contextMessages: Message[],
|
||||
config: ProviderConfig,
|
||||
) {
|
||||
this.send({
|
||||
type: "side_question",
|
||||
question,
|
||||
context_messages: contextMessages,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.send({ type: "cancel" });
|
||||
}
|
||||
|
||||
sendPermissionResponse(
|
||||
requestId: string,
|
||||
approved: boolean,
|
||||
alwaysAllow = false,
|
||||
) {
|
||||
this.send({
|
||||
type: "permission_response",
|
||||
request_id: requestId,
|
||||
approved,
|
||||
always_allow: alwaysAllow,
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.shouldReconnect = false;
|
||||
this._stopHeartbeat();
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
|
||||
if (!this.connected) return;
|
||||
this.connected = false;
|
||||
ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
if (this.closeTimer) {
|
||||
window.clearTimeout(this.closeTimer);
|
||||
}
|
||||
this.closeTimer = window.setTimeout(() => {
|
||||
if (ChatWebSocket.refCount === 0) {
|
||||
ChatWebSocket.sharedSocket?.close();
|
||||
ChatWebSocket.sharedSocket = null;
|
||||
}
|
||||
this.socket = ChatWebSocket.sharedSocket ?? undefined;
|
||||
this.closeTimer = undefined;
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ChatWebSocket.refCount === 0) {
|
||||
ChatWebSocket.sharedSocket?.close();
|
||||
ChatWebSocket.sharedSocket = null;
|
||||
}
|
||||
this.socket = ChatWebSocket.sharedSocket ?? undefined;
|
||||
}
|
||||
|
||||
private send(payload: WsRequest) {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
this.onError?.("WebSocket is not connected");
|
||||
return;
|
||||
}
|
||||
this.socket.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { settingsApi } from "./settings";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function okResponse(body: unknown) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(status: number, text: string) {
|
||||
return new Response(text, { status });
|
||||
}
|
||||
|
||||
describe("settingsApi", () => {
|
||||
describe("getEditorCommand", () => {
|
||||
it("sends GET to /settings/editor and returns editor settings", async () => {
|
||||
const expected = { editor_command: "zed" };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(expected));
|
||||
|
||||
const result = await settingsApi.getEditorCommand();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings/editor",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns null editor_command when not configured", async () => {
|
||||
const expected = { editor_command: null };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(expected));
|
||||
|
||||
const result = await settingsApi.getEditorCommand();
|
||||
expect(result.editor_command).toBeNull();
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse({ editor_command: "code" }));
|
||||
|
||||
await settingsApi.getEditorCommand("http://localhost:4000/api");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:4000/api/settings/editor",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setEditorCommand", () => {
|
||||
it("sends PUT to /settings/editor with command body", async () => {
|
||||
const expected = { editor_command: "zed" };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(expected));
|
||||
|
||||
const result = await settingsApi.setEditorCommand("zed");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings/editor",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ editor_command: "zed" }),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it("sends PUT with null to clear the editor command", async () => {
|
||||
const expected = { editor_command: null };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(expected));
|
||||
|
||||
const result = await settingsApi.setEditorCommand(null);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings/editor",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ editor_command: null }),
|
||||
}),
|
||||
);
|
||||
expect(result.editor_command).toBeNull();
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse({ editor_command: "vim" }));
|
||||
|
||||
await settingsApi.setEditorCommand("vim", "http://localhost:4000/api");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:4000/api/settings/editor",
|
||||
expect.objectContaining({ method: "PUT" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws with response body text on non-ok response", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(400, "Bad Request"));
|
||||
|
||||
await expect(settingsApi.getEditorCommand()).rejects.toThrow(
|
||||
"Bad Request",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws with status code message when response body is empty", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
|
||||
|
||||
await expect(settingsApi.getEditorCommand()).rejects.toThrow(
|
||||
"Request failed (500)",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on setEditorCommand error", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(403, "Forbidden"));
|
||||
|
||||
await expect(settingsApi.setEditorCommand("code")).rejects.toThrow(
|
||||
"Forbidden",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
export interface EditorSettings {
|
||||
editor_command: string | null;
|
||||
}
|
||||
|
||||
export interface OpenFileResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_API_BASE = "/api";
|
||||
|
||||
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
baseUrl = DEFAULT_API_BASE,
|
||||
): Promise<T> {
|
||||
const res = await fetch(buildApiUrl(path, baseUrl), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
getEditorCommand(baseUrl?: string): Promise<EditorSettings> {
|
||||
return requestJson<EditorSettings>("/settings/editor", {}, baseUrl);
|
||||
},
|
||||
|
||||
setEditorCommand(
|
||||
command: string | null,
|
||||
baseUrl?: string,
|
||||
): Promise<EditorSettings> {
|
||||
return requestJson<EditorSettings>(
|
||||
"/settings/editor",
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ editor_command: command }),
|
||||
},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
|
||||
openFile(
|
||||
path: string,
|
||||
line?: number,
|
||||
baseUrl?: string,
|
||||
): Promise<OpenFileResult> {
|
||||
const params = new URLSearchParams({ path });
|
||||
if (line !== undefined) {
|
||||
params.set("line", String(line));
|
||||
}
|
||||
return requestJson<OpenFileResult>(
|
||||
`/settings/open-file?${params.toString()}`,
|
||||
{ method: "POST" },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user