# Ticket 6.2 — Remaining Error Handling & Edge Cases

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Complete all remaining acceptance criteria from ticket 6.2: OpenCode connection lifecycle (AC1/AC2), browser reconnection (AC3), large tool result truncation (AC5), rate limiting (AC7), and disk space monitoring (AC8). AC4 and AC6 are already implemented.

**Architecture:** AC1/AC2 wires the existing `connection_status` RelayMessage variant into the SSE wiring layer, broadcasting to browsers when OpenCode connects/disconnects, with frontend banner handling. AC3 wires the existing dead-code `buildNewClientMessages()` into `handleClientConnected()` so reconnecting browsers receive pending permissions/questions. AC5 truncates tool results >50KB at the relay-stack layer, stores full content in an in-memory `ToolContentStore`, and adds a "Show full output" button. AC7 adds a sliding-window rate limiter (5 messages/10s) on both client and server. AC8 creates a `StorageMonitor` wired into the Daemon lifecycle with cache eviction on low disk.

**Tech Stack:** TypeScript, Svelte 5, Vitest, Node.js `fs.statfs()`

**Status of existing implementation:**
- `connection_status` type defined in `shared-types.ts` but never sent or handled
- `buildNewClientMessages()` exists in `ws-router.ts:235-274` but never called
- SSE consumer has reconnect logic but never broadcasts status to browsers
- AC4 (permission dedup) and AC6 (invalid WS messages) are fully implemented

---

## AC1/AC2 — OpenCode Connection Lifecycle

### Task 1: Broadcast connection_status from SSE wiring + frontend banner

**Files:**
- Modify: `src/lib/sse-wiring.ts` (~line 213-243 — SSE event handlers for connected/disconnected/reconnecting)
- Modify: `src/lib/public/stores/ws.svelte.ts` (add `connection_status` case to `handleMessage` switch)
- Modify: `src/lib/public/stores/ui.svelte.ts` (add OpenCode connection banner)
- Test: `test/unit/sse-wiring.test.ts`

**Step 1: Write the failing test**

```typescript
// In test/unit/sse-wiring.test.ts, add:
describe("connection_status broadcast (AC1/AC2)", () => {
	it("broadcasts connection_status disconnected when SSE drops", () => {
		// Setup: create a mock wsHandler with broadcast spy
		// Trigger the SSE "disconnected" event
		// Assert wsHandler.broadcast was called with { type: "connection_status", status: "disconnected" }
	});

	it("broadcasts connection_status connected when SSE reconnects", () => {
		// Trigger the SSE "connected" event
		// Assert wsHandler.broadcast with { type: "connection_status", status: "connected" }
	});

	it("broadcasts connection_status reconnecting during reconnect attempts", () => {
		// Trigger the SSE "reconnecting" event
		// Assert wsHandler.broadcast with { type: "connection_status", status: "reconnecting" }
	});
});
```

**Step 2: Run test to verify it fails**

Run: `pnpm vitest run test/unit/sse-wiring.test.ts`
Expected: FAIL

**Step 3: Add broadcasts to SSE wiring**

In `src/lib/sse-wiring.ts`, find the SSE consumer event handlers (~line 213-243). Currently they only log. Add `wsHandler.broadcast()` calls:

```typescript
// In the "disconnected" handler:
wsHandler.broadcast({ type: "connection_status", status: "disconnected" });

// In the "reconnecting" handler:
wsHandler.broadcast({ type: "connection_status", status: "reconnecting" });

// In the "connected" handler (when it reconnects after a drop):
wsHandler.broadcast({ type: "connection_status", status: "connected" });
```

**Step 4: Add frontend handling**

In `src/lib/public/stores/ws.svelte.ts`, add a case in the `handleMessage` switch:

```typescript
case "connection_status": {
	if (msg.status === "disconnected" || msg.status === "reconnecting") {
		showBanner({
			id: "opencode-connection",
			message: msg.status === "disconnected"
				? "OpenCode server disconnected. Retrying..."
				: "Reconnecting to OpenCode...",
			variant: "warn",
		});
	} else if (msg.status === "connected") {
		dismissBanner("opencode-connection");
	}
	break;
}
```

Add `showBanner` and `dismissBanner` imports from `ui.svelte.js`.

**Step 5: Run type check + tests**

Run: `pnpm check && pnpm test:unit`
Expected: PASS

**Step 6: Commit**

```bash
git add src/lib/sse-wiring.ts src/lib/public/stores/ws.svelte.ts
git commit -m "feat: broadcast OpenCode connection status to browsers (ticket 6.2 AC1+AC2)"
```

---

## AC3 — Browser Reconnection: Pending Permissions/Questions

### Task 2: Wire pending permissions/questions into client init

**Files:**
- Modify: `src/lib/client-init.ts` (add pending permission/question replay)
- Modify: `src/lib/ws-handler.ts` (if deps need updating)
- Test: `test/unit/client-init.test.ts`

**Step 1: Write the failing test**

```typescript
// In test/unit/client-init.test.ts, add:
describe("pending permissions on reconnect (AC3)", () => {
	it("sends pending permission requests to reconnecting client", () => {
		// Setup: add a pending permission to permissionBridge
		// Trigger handleClientConnected
		// Assert that permission_request message was sent to the client
	});

	it("sends pending questions to reconnecting client", () => {
		// Setup: add a pending question to questionBridge
		// Trigger handleClientConnected
		// Assert that ask_user message was sent to the client
	});
});
```

**Step 2: Run test to verify it fails**

Run: `pnpm vitest run test/unit/client-init.test.ts`
Expected: FAIL

**Step 3: Add pending replay to handleClientConnected**

In `src/lib/client-init.ts`, in `handleClientConnected()`, after sending the session state but before sending session list, add:

```typescript
// Replay pending permissions (Ticket 6.2 AC3)
const pendingPerms = deps.permissionBridge.getPending();
for (const perm of pendingPerms) {
	deps.sendTo(clientId, {
		type: "permission_request",
		requestId: perm.requestId,
		toolName: perm.toolName,
		toolInput: perm.toolInput,
	});
}

// Replay pending questions (Ticket 6.2 AC3)
const pendingQuestions = deps.questionBridge.getPending();
for (const q of pendingQuestions) {
	deps.sendTo(clientId, {
		type: "ask_user",
		toolId: q.toolId,
		questions: q.questions,
	});
}
```

Note: Check that `permissionBridge.getPending()` and `questionBridge.getPending()` methods exist. They should based on the exploration — `buildNewClientMessages()` in `ws-router.ts` calls them. If they don't exist, add them.

**Step 4: Run tests**

Run: `pnpm check && pnpm test:unit`
Expected: PASS

**Step 5: Commit**

```bash
git add src/lib/client-init.ts test/unit/client-init.test.ts
git commit -m "feat: replay pending permissions and questions to reconnecting browsers (ticket 6.2 AC3)"
```

---

## AC5 — Large Tool Results Truncation

### Task 3: Create ToolContentStore with tests

**Files:**
- Create: `src/lib/tool-content-store.ts`
- Create: `test/unit/tool-content-store.test.ts`

**Step 1: Write the failing tests**

```typescript
// test/unit/tool-content-store.test.ts
import { describe, expect, it } from "vitest";
import { ToolContentStore } from "../../src/lib/tool-content-store.js";

describe("ToolContentStore", () => {
	it("stores and retrieves full content by tool ID", () => {
		const store = new ToolContentStore();
		store.store("tool-1", "full content here");
		expect(store.get("tool-1")).toBe("full content here");
	});

	it("returns undefined for unknown tool ID", () => {
		const store = new ToolContentStore();
		expect(store.get("unknown")).toBeUndefined();
	});

	it("clears content for a session", () => {
		const store = new ToolContentStore();
		store.store("tool-1", "content", "session-a");
		store.store("tool-2", "content", "session-a");
		store.store("tool-3", "content", "session-b");
		store.clearSession("session-a");
		expect(store.get("tool-1")).toBeUndefined();
		expect(store.get("tool-2")).toBeUndefined();
		expect(store.get("tool-3")).toBe("content");
	});

	it("evicts oldest entries when over capacity", () => {
		const store = new ToolContentStore({ maxEntries: 3 });
		store.store("t1", "a");
		store.store("t2", "b");
		store.store("t3", "c");
		store.store("t4", "d"); // should evict t1
		expect(store.get("t1")).toBeUndefined();
		expect(store.get("t4")).toBe("d");
	});

	it("reports entry count", () => {
		const store = new ToolContentStore();
		store.store("t1", "a");
		store.store("t2", "b");
		expect(store.size).toBe(2);
	});
});
```

**Step 2: Run tests to verify they fail**

Run: `pnpm vitest run test/unit/tool-content-store.test.ts`
Expected: FAIL — module not found

**Step 3: Implement ToolContentStore**

```typescript
// src/lib/tool-content-store.ts

/** In-memory store for full tool result content (before truncation). */
export interface ToolContentStoreOptions {
	/** Max entries before evicting oldest. Default: 500. */
	maxEntries?: number;
}

interface Entry {
	content: string;
	sessionId?: string;
	storedAt: number;
}

export class ToolContentStore {
	private readonly entries = new Map<string, Entry>();
	private readonly maxEntries: number;

	constructor(options?: ToolContentStoreOptions) {
		this.maxEntries = options?.maxEntries ?? 500;
	}

	store(toolId: string, content: string, sessionId?: string): void {
		this.entries.set(toolId, { content, sessionId, storedAt: Date.now() });
		this.evictIfNeeded();
	}

	get(toolId: string): string | undefined {
		return this.entries.get(toolId)?.content;
	}

	clearSession(sessionId: string): void {
		for (const [id, entry] of this.entries) {
			if (entry.sessionId === sessionId) {
				this.entries.delete(id);
			}
		}
	}

	get size(): number {
		return this.entries.size;
	}

	private evictIfNeeded(): void {
		while (this.entries.size > this.maxEntries) {
			const oldest = this.entries.keys().next().value;
			if (oldest !== undefined) this.entries.delete(oldest);
		}
	}
}
```

**Step 4: Run tests to verify they pass**

Run: `pnpm vitest run test/unit/tool-content-store.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add src/lib/tool-content-store.ts test/unit/tool-content-store.test.ts
git commit -m "feat: add ToolContentStore for full tool result content (ticket 6.2 AC5)"
```

---

### Task 4: Update shared types + truncation utility

**Files:**
- Modify: `src/lib/shared-types.ts`
- Create: `src/lib/truncate-content.ts`
- Create: `test/unit/truncate-content.test.ts`

**Step 1: Add truncation fields to `tool_result` variant in shared-types.ts**

Find the `tool_result` variant (~line 111-117). Add `isTruncated?: boolean` and `fullContentLength?: number`.

Add a new `tool_content` variant to RelayMessage:

```typescript
| { type: "tool_content"; toolId: string; content: string }
```

**Step 2: Write truncation utility tests**

```typescript
// test/unit/truncate-content.test.ts
import { describe, expect, it } from "vitest";
import { truncateContent, truncateToolResult, TRUNCATION_THRESHOLD } from "../../src/lib/truncate-content.js";

describe("truncateContent", () => {
	it("returns content unchanged when under threshold", () => {
		const result = truncateContent("short content");
		expect(result).toEqual({ content: "short content", isTruncated: false });
	});

	it("truncates content at threshold boundary", () => {
		const big = "x".repeat(TRUNCATION_THRESHOLD + 1000);
		const result = truncateContent(big);
		expect(result.isTruncated).toBe(true);
		expect(result.content.length).toBe(TRUNCATION_THRESHOLD);
		expect(result.fullContentLength).toBe(big.length);
	});

	it("handles empty string", () => {
		expect(truncateContent("")).toEqual({ content: "", isTruncated: false });
	});

	it("accepts custom threshold", () => {
		expect(truncateContent("abcdef", 3)).toEqual({
			content: "abc", isTruncated: true, fullContentLength: 6,
		});
	});
});

describe("truncateToolResult", () => {
	it("truncates tool_result content over threshold and returns full content", () => {
		const bigContent = "x".repeat(60_000);
		const { truncated, fullContent } = truncateToolResult({
			type: "tool_result", id: "t1", content: bigContent, is_error: false,
		});
		expect(truncated.content.length).toBe(TRUNCATION_THRESHOLD);
		expect(truncated.isTruncated).toBe(true);
		expect(truncated.fullContentLength).toBe(60_000);
		expect(fullContent).toBe(bigContent);
	});

	it("passes through small tool_result unchanged", () => {
		const { truncated, fullContent } = truncateToolResult({
			type: "tool_result", id: "t2", content: "small", is_error: false,
		});
		expect(truncated.content).toBe("small");
		expect(truncated.isTruncated).toBeUndefined();
		expect(fullContent).toBeUndefined();
	});
});
```

**Step 3: Implement truncation utility**

```typescript
// src/lib/truncate-content.ts
import type { RelayMessage } from "./types.js";

export const TRUNCATION_THRESHOLD = 50_000;

export interface TruncationResult {
	content: string;
	isTruncated: boolean;
	fullContentLength?: number;
}

export function truncateContent(content: string, threshold = TRUNCATION_THRESHOLD): TruncationResult {
	if (content.length <= threshold) return { content, isTruncated: false };
	return { content: content.slice(0, threshold), isTruncated: true, fullContentLength: content.length };
}

type ToolResultMessage = Extract<RelayMessage, { type: "tool_result" }>;

export interface ToolResultTruncation {
	truncated: ToolResultMessage;
	fullContent: string | undefined;
}

export function truncateToolResult(msg: ToolResultMessage, threshold = TRUNCATION_THRESHOLD): ToolResultTruncation {
	const result = truncateContent(msg.content, threshold);
	if (!result.isTruncated) return { truncated: msg, fullContent: undefined };
	return {
		truncated: { ...msg, content: result.content, isTruncated: true, fullContentLength: result.fullContentLength },
		fullContent: msg.content,
	};
}
```

**Step 4: Run tests**

Run: `pnpm check && pnpm vitest run test/unit/truncate-content.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add src/lib/shared-types.ts src/lib/truncate-content.ts test/unit/truncate-content.test.ts
git commit -m "feat: add truncation types and utility with 50KB threshold (ticket 6.2 AC5)"
```

---

### Task 5: Wire truncation into relay-stack + add get_tool_content handler

**Files:**
- Modify: `src/lib/relay-stack.ts`
- Modify: `src/lib/ws-router.ts` (add `get_tool_content` to valid types)
- Modify: `src/lib/handlers/` (add handler for get_tool_content)
- Modify: `src/lib/message-handlers.ts` (register handler)

**Step 1: Wire truncation in relay-stack event processing**

In `src/lib/relay-stack.ts`, import `truncateToolResult` and `ToolContentStore`. Create the store in `createProjectRelay()`. In the event processing loop (after translation, before cache+broadcast):

```typescript
if (msg.type === "tool_result") {
	const { truncated, fullContent } = truncateToolResult(msg);
	if (fullContent !== undefined) {
		toolContentStore.store(msg.id, fullContent, activeSessionId);
	}
	broadcastMsg = truncated;
}
```

**Step 2: Add get_tool_content to ws-router**

Add `"get_tool_content"` to `VALID_MESSAGE_TYPES`.

**Step 3: Add handler**

Create handler in appropriate `src/lib/handlers/` file:

```typescript
async function handleGetToolContent(msg: { toolId: string }, deps: HandlerDeps): Promise<void> {
	const content = deps.toolContentStore.get(msg.toolId);
	if (content) {
		deps.sendTo({ type: "tool_content", toolId: msg.toolId, content });
	} else {
		deps.sendTo({ type: "error", code: "NOT_FOUND", message: "Full tool content not available" });
	}
}
```

Register in dispatch table.

**Step 4: Run type check + tests**

Run: `pnpm check && pnpm test:unit`
Expected: PASS

**Step 5: Commit**

```bash
git add src/lib/relay-stack.ts src/lib/ws-router.ts src/lib/handlers/ src/lib/message-handlers.ts
git commit -m "feat: wire tool result truncation + get_tool_content handler (ticket 6.2 AC5)"
```

---

### Task 6: Frontend truncation UI

**Files:**
- Modify: `src/lib/public/types.ts` (add `isTruncated`, `fullContentLength` to `ToolMessage`)
- Modify: `src/lib/public/stores/chat.svelte.ts` (propagate metadata in `handleToolResult`)
- Modify: `src/lib/public/stores/ws.svelte.ts` (handle `tool_content` response)
- Modify: `src/lib/public/components/chat/ToolItem.svelte` (add "Show full output" button)

**Step 1: Update ToolMessage type** — Add `isTruncated?: boolean` and `fullContentLength?: number`.

**Step 2: Update handleToolResult** — Propagate `msg.isTruncated` and `msg.fullContentLength` to the ToolMessage.

**Step 3: Add tool_content case in ws dispatch** — When received, update the ToolMessage with full content and clear `isTruncated`.

**Step 4: Update ToolItem.svelte** — When `message.isTruncated`, show size info and a "Show full output" button that calls `wsSend({ type: "get_tool_content", toolId: message.id })`.

**Step 5: Run type check + tests**

Run: `pnpm check && pnpm test:unit`

**Step 6: Commit**

```bash
git add src/lib/public/
git commit -m "feat: frontend truncation indicator with 'Show full output' button (ticket 6.2 AC5)"
```

---

## AC7 — Rate Limiting on Message Sends

### Task 7: Create RateLimiter utility + tests

**Files:**
- Create: `src/lib/rate-limiter.ts`
- Create: `test/unit/rate-limiter.test.ts`

**Step 1: Write tests** — Test sliding window (5 messages/10s), rejection with retryAfterMs, window expiry, independent client tracking, remaining count, stale cleanup. Use injectable `now()` clock.

**Step 2: Implement RateLimiter** — Sliding window with per-client `Map<string, number[]>`, injectable clock, `check()` returns `{ allowed, retryAfterMs? }`, `remaining()`, `cleanup()`.

**Step 3: Run tests**

Run: `pnpm vitest run test/unit/rate-limiter.test.ts`

**Step 4: Commit**

```bash
git add src/lib/rate-limiter.ts test/unit/rate-limiter.test.ts
git commit -m "feat: add RateLimiter with sliding window + injectable clock (ticket 6.2 AC7)"
```

---

### Task 8: Wire server-side rate limiting

**Files:**
- Modify: `src/lib/relay-stack.ts` (add rate limit check in message handler)
- Modify: `src/lib/shared-types.ts` (add `RATE_LIMITED` to ErrorCode if applicable)

**Step 1: Add rate limiting in relay-stack message handler**

In the `wsHandler.on("message", ...)` handler, before dispatching chat messages:

```typescript
if (parsed.type === "message") {
	const result = rateLimiter.check(clientId);
	if (!result.allowed) {
		wsHandler.sendTo(clientId, {
			type: "error",
			code: "RATE_LIMITED",
			message: `Rate limited. Try again in ${Math.ceil((result.retryAfterMs ?? 1000) / 1000)}s`,
		});
		return;
	}
}
```

**Step 2: Run tests**

Run: `pnpm check && pnpm test:unit`

**Step 3: Commit**

```bash
git add src/lib/relay-stack.ts src/lib/shared-types.ts
git commit -m "feat: server-side rate limiting on chat messages (ticket 6.2 AC7)"
```

---

### Task 9: Client-side message queue + UI feedback

**Files:**
- Modify: `src/lib/public/stores/ws.svelte.ts` (queue infrastructure around `wsSend`)

**Step 1: Add queue logic**

Split `wsSend` into `rawSend` (actual WebSocket send) and `wsSend` (rate-limited wrapper). Track `sendTimestamps[]`, queue excess chat messages, drain on a timer, show toast via `showToast("Message queued", { variant: "warn" })`.

Only rate-limit `type: "message"` — pass through control messages immediately.

**Step 2: Run tests**

Run: `pnpm check && pnpm test:unit`

**Step 3: Commit**

```bash
git add src/lib/public/stores/ws.svelte.ts
git commit -m "feat: client-side message queue with rate limiting + toast feedback (ticket 6.2 AC7)"
```

---

## AC8 — Disk Space Monitoring

### Task 10: Create StorageMonitor + tests

**Files:**
- Create: `src/lib/storage-monitor.ts`
- Create: `test/unit/storage-monitor.test.ts`

**Step 1: Write tests** — Test low_disk_space emission below threshold, no emission above threshold, disk_space_ok on recovery, idempotent stop. Use injectable `_statfs`.

**Step 2: Implement StorageMonitor** — EventEmitter with periodic `fs.statfs()` polling, configurable threshold (default 100MB), configurable interval (default 5min), `start()`/`stop()` lifecycle, timer unref'd.

**Step 3: Run tests**

Run: `pnpm vitest run test/unit/storage-monitor.test.ts`

**Step 4: Commit**

```bash
git add src/lib/storage-monitor.ts test/unit/storage-monitor.test.ts
git commit -m "feat: add StorageMonitor with periodic disk space polling (ticket 6.2 AC8)"
```

---

### Task 11: Wire StorageMonitor into Daemon

**Files:**
- Modify: `src/lib/daemon.ts`

**Step 1: Add StorageMonitor field and import** — Create in `start()`, wire `low_disk_space` → `console.warn`, wire `disk_space_ok` → `console.log`. Stop in `stop()`.

**Step 2: Run tests**

Run: `pnpm vitest run test/unit/daemon.test.ts`

**Step 3: Commit**

```bash
git add src/lib/daemon.ts
git commit -m "feat: wire StorageMonitor into daemon — warns at 100MB free (ticket 6.2 AC8)"
```

---

### Task 12: MessageCache size-aware eviction

**Files:**
- Modify: `src/lib/message-cache.ts`
- Modify: `test/unit/message-cache.test.ts`

**Step 1: Write tests** — Test `approximateBytes()`, `evictOldestSession()` by last access time, null when empty.

**Step 2: Add byte tracking to SessionCache** — Track `approxBytes` and `lastAccessedAt`. Add `approximateBytes()` and `evictOldestSession()` public methods.

**Step 3: Wire eviction to daemon's low_disk_space handler** — Evict oldest sessions from all project relays when disk is low.

**Step 4: Run full test suite**

Run: `pnpm check && pnpm test:unit`

**Step 5: Commit**

```bash
git add src/lib/message-cache.ts src/lib/daemon.ts test/unit/message-cache.test.ts
git commit -m "feat: size-aware MessageCache eviction triggered by low disk space (ticket 6.2 AC8)"
```

---

## Final Verification

### Task 13: Full verification pass

**Step 1: Run full suite**

```bash
pnpm check && pnpm lint && pnpm test:unit
```

**Step 2: Verify all ACs**

- AC1/AC2: `connection_status` broadcast from SSE wiring, frontend banner shown/hidden
- AC3: Pending permissions/questions sent to reconnecting clients
- AC5: Tool results truncated at 50KB, "Show full output" button works
- AC7: Server rejects >5 msgs/10s, client queues with toast
- AC8: StorageMonitor warns at 100MB, triggers cache eviction

**Step 3: Commit any fixups**

```bash
git add -A
git commit -m "test: final verification for ticket 6.2 — all remaining ACs complete"
```
