diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b7a7c7bf..6184bc25 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 @@ -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 @@ -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 diff --git a/shared/consumer-types.ts b/shared/consumer-types.ts index 70af7f1f..7c90fdf7 100644 --- a/shared/consumer-types.ts +++ b/shared/consumer-types.ts @@ -248,7 +248,7 @@ export type ConsumerMessage = } | { type: "status_change"; - status: "compacting" | "idle" | "running" | null; + status: "compacting" | "idle" | "running" | "retry" | null; metadata?: Record; } | { diff --git a/src/adapters/opencode/opencode-message-translator.test.ts b/src/adapters/opencode/opencode-message-translator.test.ts index 651ac10c..c99c57dd 100644 --- a/src/adapters/opencode/opencode-message-translator.test.ts +++ b/src/adapters/opencode/opencode-message-translator.test.ts @@ -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"); diff --git a/src/adapters/opencode/opencode-message-translator.ts b/src/adapters/opencode/opencode-message-translator.ts index 89966605..c99e0395 100644 --- a/src/adapters/opencode/opencode-message-translator.ts +++ b/src/adapters/opencode/opencode-message-translator.ts @@ -378,6 +378,7 @@ function translateSessionStatus( role: "system", metadata: { session_id: sessionID, + status: "retry", retry: true, attempt: status.attempt, message: status.message, diff --git a/src/core/messaging/unified-message-router.test.ts b/src/core/messaging/unified-message-router.test.ts index c0b1f7e6..fe052e6e 100644 --- a/src/core/messaging/unified-message-router.test.ts +++ b/src/core/messaging/unified-message-router.test.ts @@ -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 ───────────────────────────────────────────────────────── diff --git a/src/core/session/session-runtime.test.ts b/src/core/session/session-runtime.test.ts index 539917ec..dec86c82 100644 --- a/src/core/session/session-runtime.test.ts +++ b/src/core/session/session-runtime.test.ts @@ -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(); diff --git a/src/core/session/session-runtime.ts b/src/core/session/session-runtime.ts index d3b3d957..d780bad7 100644 --- a/src/core/session/session-runtime.ts +++ b/src/core/session/session-runtime.ts @@ -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 { diff --git a/src/types/consumer-messages.ts b/src/types/consumer-messages.ts index 9eeb96ad..6c5615d7 100644 --- a/src/types/consumer-messages.ts +++ b/src/types/consumer-messages.ts @@ -166,7 +166,7 @@ export type ConsumerMessage = } | { type: "status_change"; - status: "compacting" | "idle" | "running" | null; + status: "compacting" | "idle" | "running" | "retry" | null; metadata?: Record; } | { diff --git a/web/src/components/StreamingIndicator.test.tsx b/web/src/components/StreamingIndicator.test.tsx index 0a5bb626..22990322 100644 --- a/web/src/components/StreamingIndicator.test.tsx +++ b/web/src/components/StreamingIndicator.test.tsx @@ -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(); + 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(); + 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(); + expect(screen.queryByRole("button", { name: "Stop generation" })).not.toBeInTheDocument(); + }); + }); }); diff --git a/web/src/components/StreamingIndicator.tsx b/web/src/components/StreamingIndicator.tsx index 1bcb8414..0e19400f 100644 --- a/web/src/components/StreamingIndicator.tsx +++ b/web/src/components/StreamingIndicator.tsx @@ -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); @@ -69,6 +70,17 @@ export function StreamingIndicator({ sessionId }: StreamingIndicatorProps) { setStopping(true); }, [sessionId]); + if (sessionStatus === "retry" && retryInfo) { + return ( +
+
+ + {retryInfo.message} +
+
+ ); + } + if (!streaming && !streamingStartedAt && sessionStatus !== "running") return null; const stats = formatStreamingStats(elapsed, streamingOutputTokens); diff --git a/web/src/store.ts b/web/src/store.ts index 222ca297..8418051e 100644 --- a/web/src/store.ts +++ b/web/src/store.ts @@ -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; state: ConsumerSessionState | null; capabilities: { @@ -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; @@ -193,6 +198,7 @@ function emptySessionData(): SessionData { connectionStatus: "disconnected", cliConnected: false, sessionStatus: null, + retryInfo: null, pendingPermissions: {}, state: null, capabilities: null, @@ -411,6 +417,8 @@ export const useStore = create()((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(); diff --git a/web/src/ws.test.ts b/web/src/ws.test.ts index c533a5cf..e5a518f3 100644 --- a/web/src/ws.test.ts +++ b/web/src/ws.test.ts @@ -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", () => { diff --git a/web/src/ws.ts b/web/src/ws.ts index d150ab55..1d75e772 100644 --- a/web/src/ws.ts +++ b/web/src/ws.ts @@ -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); + } break; case "permission_request":