Skip to content
40 changes: 38 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,13 +299,34 @@ Temporary exceptions go in `docs/refactor-plan/architecture-waivers.json` with `

```bash
pnpm build
node dist/bin/beamcode.mjs --no-tunnel # start locally
pnpm start --no-tunnel # start locally
curl http://localhost:9414/health # health check
node dist/bin/beamcode.mjs --no-tunnel --port 8080
pnpm start --no-tunnel --port 8080

# With full trace output redirected to file
pnpm start --no-tunnel --verbose --trace --trace-level full --trace-allow-sensitive 2>trace.log
```

`Ctrl+C` once = graceful shutdown. `Ctrl+C` twice = force exit.

### Rebuild and Restart Guide

The frontend HTML (including bundled JS) is loaded from disk **once at startup** and cached in memory (`src/http/consumer-html.ts`). The server never re-reads it while running. A restart is therefore required whenever you rebuild either layer.

| Changed | Build command | Restart required? |
|---------|--------------|:-----------------:|
| Backend only (`src/`) | `pnpm build:lib` | ✅ |
| Frontend only (`web/src/`) | `pnpm build:web` | ✅ |
| Both | `pnpm build` | ✅ |

**Iterating on frontend UI without restarting:**

```bash
pnpm dev:web # Vite dev server on port 5174, HMR enabled
```

Vite proxies the WebSocket to the already-running beamcode server on port 9414, so you get hot-reload on React/CSS changes without touching the server process. Use this for frontend-only iteration; switch to `pnpm build` + restart when you also change backend code.

---

## UnifiedMessage Protocol
Expand All @@ -331,6 +352,20 @@ See **[docs/unified-message-protocol.md](docs/unified-message-protocol.md)** for
| `interrupt` | consumer → backend | — |
| `session_lifecycle` | internal | ✅ |

#### `status_change` values

The `status` field on a `status_change` message reflects the session's current operational state:

| Value | Meaning |
|-------|---------|
| `"running"` | Actively processing a prompt |
| `"idle"` | Ready to accept a new message |
| `"compacting"` | Context window is being compacted |
| `"retry"` | Rate-limited — backend is waiting to retry; `metadata` contains `message`, `attempt`, and `next` (epoch ms until next attempt) |
| `null` | Unknown / transitional |

When `status === "retry"`, the frontend renders the message and attempt count in the streaming indicator and clears it automatically when the backend resumes.

---

## Message Tracing
Expand All @@ -348,6 +383,7 @@ beamcode --trace --trace-level headers

# Full payloads: every message logged as-is (requires explicit opt-in)
beamcode --trace --trace-level full --trace-allow-sensitive
pnpm start --no-tunnel --verbose --trace --trace-level full --trace-allow-sensitive 2>trace.log

# Environment-variable controls (CLI flags override env)
BEAMCODE_TRACE=1 beamcode
Expand Down
2 changes: 1 addition & 1 deletion shared/consumer-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export type ConsumerMessage =
}
| {
type: "status_change";
status: "compacting" | "idle" | "running" | null;
status: "compacting" | "idle" | "running" | "retry" | null;
metadata?: Record<string, unknown>;
}
| {
Expand Down
1 change: 1 addition & 0 deletions src/adapters/opencode/opencode-message-translator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ describe("translateEvent: session.status retry", () => {
const msg = translateEvent(event);
expect(msg).not.toBeNull();
expect(msg!.type).toBe("status_change");
expect(msg!.metadata.status).toBe("retry");
expect(msg!.metadata.retry).toBe(true);
expect(msg!.metadata.attempt).toBe(2);
expect(msg!.metadata.message).toBe("Rate limited");
Expand Down
1 change: 1 addition & 0 deletions src/adapters/opencode/opencode-message-translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ function translateSessionStatus(
role: "system",
metadata: {
session_id: sessionID,
status: "retry",
retry: true,
attempt: status.attempt,
message: status.message,
Expand Down
25 changes: 25 additions & 0 deletions src/core/messaging/unified-message-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,31 @@ describe("UnifiedMessageRouter", () => {
);
expect(sessionUpdateCall).toBeDefined();
});

it("broadcasts status: retry and retains retry metadata", () => {
const m = msg("status_change", {
status: "retry",
retry: true,
attempt: 1,
message: "The usage limit has been reached",
next: 9999999,
});
router.route(session, m);

expect(deps.broadcaster.broadcast).toHaveBeenCalledWith(
session,
expect.objectContaining({
type: "status_change",
status: "retry",
metadata: expect.objectContaining({
retry: true,
attempt: 1,
message: "The usage limit has been reached",
next: 9999999,
}),
}),
);
});
});

// ── assistant ─────────────────────────────────────────────────────────
Expand Down
34 changes: 34 additions & 0 deletions src/core/session/session-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,40 @@ describe("SessionRuntime", () => {
);
});

it("sendSetModel updates session.state.model and broadcasts session_update", () => {
const send = vi.fn();
const session = createMockSession({ id: "s1", backendSession: { send } as any });
session.state = { ...session.state, model: "claude-sonnet-4-6" };
const deps = makeDeps({
tracedNormalizeInbound: vi.fn((_session, msg) => normalizeInbound(msg as any)),
});
const runtime = new SessionRuntime(session, deps);

runtime.sendSetModel("claude-haiku-4-5");

expect(session.state.model).toBe("claude-haiku-4-5");
expect(deps.broadcaster.broadcast).toHaveBeenCalledWith(
session,
expect.objectContaining({
type: "session_update",
session: expect.objectContaining({ model: "claude-haiku-4-5" }),
}),
);
});

it("sendSetModel does not update state or broadcast when backendSession is null", () => {
const session = createMockSession({ id: "s1" });
session.backendSession = null;
session.state = { ...session.state, model: "claude-sonnet-4-6" };
const deps = makeDeps();
const runtime = new SessionRuntime(session, deps);

runtime.sendSetModel("claude-haiku-4-5");

expect(session.state.model).toBe("claude-sonnet-4-6");
expect(deps.broadcaster.broadcast).not.toHaveBeenCalled();
});

it("delegates programmatic slash execution to slash service", async () => {
const session = createMockSession({ id: "s1" });
const deps = makeDeps();
Expand Down
8 changes: 8 additions & 0 deletions src/core/session/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,15 @@ export class SessionRuntime {
}

sendSetModel(model: string): void {
if (!this.session.backendSession) return;
this.sendControlRequest({ type: "set_model", model });
// Optimistically update session state — the backend never sends a
// configuration_change back, so we must reflect the change ourselves.
this.session.state = { ...this.session.state, model };
this.deps.broadcaster.broadcast(this.session, {
type: "session_update",
session: { model },
});
}

sendSetPermissionMode(mode: string): void {
Expand Down
2 changes: 1 addition & 1 deletion src/types/consumer-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export type ConsumerMessage =
}
| {
type: "status_change";
status: "compacting" | "idle" | "running" | null;
status: "compacting" | "idle" | "running" | "retry" | null;
metadata?: Record<string, unknown>;
}
| {
Expand Down
38 changes: 38 additions & 0 deletions web/src/components/StreamingIndicator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,42 @@ describe("StreamingIndicator", () => {
expect(screen.getByText("Generating...")).toBeInTheDocument();
});
});

describe("retry state", () => {
it("shows retry message when session is rate-limited", () => {
store().ensureSessionData(SESSION);
store().setSessionStatus(SESSION, "retry");
store().setRetryInfo(SESSION, {
message: "The usage limit has been reached",
attempt: 1,
next: Date.now() + 5000,
});
render(<StreamingIndicator sessionId={SESSION} />);
expect(screen.getByText("The usage limit has been reached")).toBeInTheDocument();
});

it("does not show attempt count — usage limits are not self-resolving", () => {
store().ensureSessionData(SESSION);
store().setSessionStatus(SESSION, "retry");
store().setRetryInfo(SESSION, {
message: "The usage limit has been reached",
attempt: 3,
next: Date.now() + 5000,
});
render(<StreamingIndicator sessionId={SESSION} />);
expect(screen.queryByText(/attempt/i)).not.toBeInTheDocument();
});

it("hides stop button during retry state", () => {
store().ensureSessionData(SESSION);
store().setSessionStatus(SESSION, "retry");
store().setRetryInfo(SESSION, {
message: "Rate limited",
attempt: 1,
next: Date.now() + 5000,
});
render(<StreamingIndicator sessionId={SESSION} />);
expect(screen.queryByRole("button", { name: "Stop generation" })).not.toBeInTheDocument();
});
});
});
12 changes: 12 additions & 0 deletions web/src/components/StreamingIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function StreamingIndicator({ sessionId }: StreamingIndicatorProps) {
(s) => s.sessionData[sessionId]?.streamingOutputTokens ?? 0,
);
const sessionStatus = useStore((s) => s.sessionData[sessionId]?.sessionStatus ?? null);
const retryInfo = useStore((s) => s.sessionData[sessionId]?.retryInfo ?? null);

const elapsed = useElapsed(streamingStartedAt);
const [stopping, setStopping] = useState(false);
Expand All @@ -69,6 +70,17 @@ export function StreamingIndicator({ sessionId }: StreamingIndicatorProps) {
setStopping(true);
}, [sessionId]);

if (sessionStatus === "retry" && retryInfo) {
return (
<div className="mx-auto w-full max-w-3xl px-3">
<div className="flex items-center gap-2 py-1.5 text-xs text-bc-error">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-bc-error" />
<span>{retryInfo.message}</span>
</div>
</div>
);
}

if (!streaming && !streamingStartedAt && sessionStatus !== "running") return null;

const stats = formatStreamingStats(elapsed, streamingOutputTokens);
Expand Down
12 changes: 10 additions & 2 deletions web/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export interface SessionData {
streamingBlocks: ConsumerContentBlock[];
connectionStatus: "connected" | "connecting" | "disconnected";
cliConnected: boolean;
sessionStatus: "idle" | "running" | "compacting" | null;
sessionStatus: "idle" | "running" | "compacting" | "retry" | null;
retryInfo: { message: string; attempt: number; next: number } | null;
pendingPermissions: Record<string, ConsumerPermissionRequest>;
state: ConsumerSessionState | null;
capabilities: {
Expand Down Expand Up @@ -139,7 +140,11 @@ export interface AppState {
status: "connected" | "connecting" | "disconnected",
) => void;
setCliConnected: (sessionId: string, connected: boolean) => void;
setSessionStatus: (sessionId: string, status: "idle" | "running" | "compacting" | null) => void;
setSessionStatus: (
sessionId: string,
status: "idle" | "running" | "compacting" | "retry" | null,
) => void;
setRetryInfo: (sessionId: string, info: SessionData["retryInfo"]) => void;
addPermission: (sessionId: string, request: ConsumerPermissionRequest) => void;
removePermission: (sessionId: string, requestId: string) => void;
clearPendingPermissions: (sessionId: string) => void;
Expand Down Expand Up @@ -193,6 +198,7 @@ function emptySessionData(): SessionData {
connectionStatus: "disconnected",
cliConnected: false,
sessionStatus: null,
retryInfo: null,
pendingPermissions: {},
state: null,
capabilities: null,
Expand Down Expand Up @@ -411,6 +417,8 @@ export const useStore = create<AppState>()((set, get) => ({
setSessionStatus: (sessionId, status) =>
set((s) => patchSession(s, sessionId, { sessionStatus: status })),

setRetryInfo: (sessionId, info) => set((s) => patchSession(s, sessionId, { retryInfo: info })),

addPermission: (sessionId, request) =>
set((s) => {
const data = s.sessionData[sessionId] ?? emptySessionData();
Expand Down
54 changes: 54 additions & 0 deletions web/src/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,60 @@ describe("handleMessage", () => {
expect(getSessionData()?.sessionStatus).toBe("idle");
});

it("status_change retry: stores retryInfo and sets status to retry", () => {
const ws = openSession();
const nextTs = Date.now() + 5000;

ws.simulateMessage(
JSON.stringify({
type: "status_change",
status: "retry",
metadata: {
retry: true,
attempt: 2,
message: "The usage limit has been reached",
next: nextTs,
},
}),
);

expect(getSessionData()?.sessionStatus).toBe("retry");
expect(getSessionData()?.retryInfo).toEqual({
message: "The usage limit has been reached",
attempt: 2,
next: nextTs,
});
});

it("status_change: clears retryInfo on non-retry status", () => {
const ws = openSession();

ws.simulateMessage(
JSON.stringify({
type: "status_change",
status: "retry",
metadata: { retry: true, attempt: 1, message: "Rate limited", next: 9999 },
}),
);
ws.simulateMessage(JSON.stringify({ type: "status_change", status: "running" }));

expect(getSessionData()?.retryInfo).toBeNull();
});

it("status_change: clears retryInfo when retry metadata is malformed", () => {
const ws = openSession();

ws.simulateMessage(
JSON.stringify({
type: "status_change",
status: "retry",
metadata: { message: 42, attempt: "bad", next: null },
}),
);

expect(getSessionData()?.retryInfo).toBeNull();
});

// ── permission_request / permission_cancelled ───────────────────────────

it("permission_request: adds to pending permissions", () => {
Expand Down
14 changes: 14 additions & 0 deletions web/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,20 @@ function handleMessage(sessionId: string, data: string): void {

case "status_change":
store.setSessionStatus(sessionId, msg.status);
if (msg.status === "retry" && msg.metadata) {
const { message, attempt, next } = msg.metadata;
if (
typeof message === "string" &&
typeof attempt === "number" &&
typeof next === "number"
) {
store.setRetryInfo(sessionId, { message, attempt, next });
} else {
store.setRetryInfo(sessionId, null);
}
} else {
store.setRetryInfo(sessionId, null);
}
Comment on lines +313 to +326
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using type assertions (as string, as number) on values extracted from msg.metadata is unsafe because these values are of type unknown. As per repository guidelines, it's crucial to perform runtime typeof checks on unknown values before using them to ensure type safety and handle potential malformed data gracefully. If the backend sends a retry status with malformed or missing metadata, this will store undefined values in the store, leading to silent UI failures (e.g., an error indicator without a message).

Suggested change
if (msg.status === "retry" && msg.metadata) {
store.setRetryInfo(sessionId, {
message: msg.metadata.message as string,
attempt: msg.metadata.attempt as number,
next: msg.metadata.next as number,
});
} else {
store.setRetryInfo(sessionId, null);
}
if (msg.status === "retry" && msg.metadata) {
const { message, attempt, next } = msg.metadata;
if (
typeof message === "string" &&
typeof attempt === "number" &&
typeof next === "number"
) {
store.setRetryInfo(sessionId, { message, attempt, next });
} else {
store.setRetryInfo(sessionId, null);
}
} else {
store.setRetryInfo(sessionId, null);
}
References
  1. Before using or casting a value of type unknown, perform a runtime typeof check to ensure its type and prevent unsafe operations.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 459ffbc — replaced the as casts with runtime typeof checks matching the suggestion exactly. Also added a test that sends malformed retry metadata (wrong types) and asserts retryInfo stays null.

break;

case "permission_request":
Expand Down