Compare commits

..
Author SHA1 Message Date
arkonandClaude Opus 4.7 016c23934f chore: version packages
Release 0.7.0. Also syncs CLAUDE.md version line and corrects the
route-handler counts (~130 handlers, sessions 28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:19:15 +02:00
Tenggan ZhangandTeigen 896dc5b177 fix(web): stop auto-sending Ctrl+L from session selection paths (#99)
Claude Code 2.x treats Ctrl+L (\x0c) as a two-step "clear conversation"
command (first press shows the confirmation prompt, second press
clears). The frontend previously fired \x0c from three places to force
Ink to redraw stale CUP-positioned frames in the tailed buffer; if a
page refresh or SSE reconnect ran the same path twice within Claude's
confirmation window the second \x0c silently nuked the user's
conversation.

Removed the \x0c sends from:
- selectSession() — main offender, runs on every tab switch & page reload
- restoreTerminalSize() — manual "restore size" button
- sendPendingCtrlL() — dead code path (pendingCtrlL was never populated)

Trade-off: occasional stale Ink frames immediately after refresh; the
user's first keypress causes Ink to redraw and the artifact vanishes.
Losing the conversation silently is far worse than a brief cosmetic
glitch.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-26 00:12:43 +02:00
Tenggan ZhangandTeigen 196646a7ff feat(web): one-click copy button on response-viewer code blocks (#98)
Wrap every fenced code block in the response viewer with a positioned
.rv-code-wrap toolbar (outside the <pre> scroll container so buttons stay put
during horizontal scroll). All blocks get a copy button; ASCII diagrams keep
their existing line-wrap toggle alongside it.

_copyText() prefers the async Clipboard API and falls back to a hidden-textarea
+ execCommand path, so copy works over plain HTTP too. The button shows a 1.5s
✓ / ✕ feedback state after each attempt.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-26 00:00:37 +02:00
Tenggan ZhangandTeigen 1b652ceb87 test: repair route harness error rendering + stop AI-checker spawning real processes in tests (#97)
* fix(test): share route error handler with test harness + fix stale assertions

The route test harness built a bare Fastify instance without the production
global error handler (server.ts), so structured errors thrown by route helpers
(findSessionOrFail → 404, parseBody → 400) fell through to Fastify's default
handler — yielding a `{statusCode,error,message}` body instead of the
`{success:false,...}` shape, and the tests asserted the old implicit-200
behavior. 51 route tests across 7 files were red.

- Extract the handler into src/web/route-error-handler.ts; server.ts and the
  test harness now install the identical handler (single source of truth).
- Correct stale assertions across route test files: throw-based error paths
  now assert 404 (unknown session) / 400 (invalid body); genuine in-handler
  `return createErrorResponse(...)` paths (200 + success:false) left untouched.
- Reformat a few test files prettier flagged (pre-existing non-compliance).

Route suite: 307/307 passing (was 256/307). No production behavior change.

* test(respawn): mock child_process so AI checker never spawns real processes

respawn-controller.test.ts drives the AI idle checker (ai-checker-base), whose
runCheck() spawns a real `tmux new-session` running `claude -p`. The AI-enabled
tests only assert the ai_checking state transition (then cancel/stop), so the
spawn produced stray real tmux sessions and claude processes on every run — the
reason `npm test` (full suite) was unsafe to run inside a managed session.

Mock node:child_process here (mirroring ai-idle-checker.test.ts), spreading the
real module so `exec` stays intact for transitively-imported modules
(tmux-manager calls promisify(exec) at load). With this, the full non-mobile
suite runs without spawning any real tmux/claude.

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-05-25 23:55:57 +02:00
22 changed files with 461 additions and 299 deletions
+9
View File
@@ -1,5 +1,14 @@
# aicodeman
## 0.7.0
### Minor Changes
- Response viewer & terminal-stability improvements, plus test/error-handling hardening.
- **Copy button on code blocks (#98):** Every fenced code block in the response viewer now has a one-click copy button pinned to its top-right, outside the `<pre>` scroll container so it stays put during horizontal scroll. ASCII diagrams keep their line-wrap toggle alongside it. Copy prefers the async Clipboard API and falls back to a hidden-textarea + `execCommand` path, so it works over plain HTTP (tunnel) too, with a brief ✓/✕ feedback state.
- **Fix: stop auto-sending Ctrl+L from session-selection paths (#99):** A fast page refresh or SSE reconnect could fire two programmatic Ctrl+L (`\x0c`) sends within Claude Code 2.x's "clear conversation" confirmation window, silently wiping the active conversation. Removed the automatic Ctrl+L sends from `selectSession()`, `restoreTerminalSize()`, and the dead `sendPendingCtrlL()` path; redraws now rely on resize/SIGWINCH. User-initiated Ctrl+L still works. Trade-off: an occasional transient stale Ink frame right after refresh that self-heals on the next keypress — far preferable to silent data loss.
- **Test & error-handling hardening (#97):** Repaired route-test harness error rendering via a dedicated `route-error-handler.ts`, and stopped the AI idle/plan checkers from spawning real processes during tests.
## 0.6.12
### Patch Changes
+2 -2
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.6.12 (must match `package.json`)
**Version**: 0.7.0 (must match `package.json`)
## Project Overview
@@ -181,7 +181,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### API Routes
~128 handlers across 15 route files in `src/web/routes/`: system (36), sessions (27), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~130 handlers across 15 route files in `src/web/routes/`: system (36), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
## Adding Features
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "0.6.12",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "0.6.12",
"version": "0.7.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.6.12",
"version": "0.7.0",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+81 -23
View File
@@ -1128,22 +1128,40 @@ class CodemanApp {
const DIAGRAM_CHAR = /[─-╿▀-▟]/;
const tmpl = document.createElement('template');
tmpl.innerHTML = html;
// Every fenced code block gets a positioned wrapper with an action
// toolbar pinned to its top-right corner. The toolbar lives OUTSIDE the
// <pre> scroll container so its buttons stay put during horizontal
// scroll. All blocks get a one-click copy button; ASCII diagrams keep
// the additional line-wrap toggle.
tmpl.content.querySelectorAll('pre > code').forEach((code) => {
if (!DIAGRAM_CHAR.test(code.textContent || '')) return;
const pre = code.parentElement;
pre.classList.add('rv-diagram');
const isDiagram = DIAGRAM_CHAR.test(code.textContent || '');
const wrap = document.createElement('div');
wrap.className = 'rv-diagram-wrap';
wrap.className = isDiagram ? 'rv-code-wrap rv-diagram-wrap' : 'rv-code-wrap';
const btn = document.createElement('button');
btn.className = 'rv-wrap-toggle';
btn.type = 'button';
btn.setAttribute('aria-label', 'Toggle line wrapping');
btn.setAttribute('title', 'Toggle line wrapping');
const actions = document.createElement('div');
actions.className = 'rv-code-actions';
const copyBtn = document.createElement('button');
copyBtn.className = 'rv-copy-btn';
copyBtn.type = 'button';
copyBtn.setAttribute('aria-label', 'Copy code');
copyBtn.setAttribute('title', 'Copy code');
actions.appendChild(copyBtn);
if (isDiagram) {
pre.classList.add('rv-diagram');
const toggle = document.createElement('button');
toggle.className = 'rv-wrap-toggle';
toggle.type = 'button';
toggle.setAttribute('aria-label', 'Toggle line wrapping');
toggle.setAttribute('title', 'Toggle line wrapping');
actions.appendChild(toggle);
}
pre.parentNode.insertBefore(wrap, pre);
wrap.appendChild(btn);
wrap.appendChild(actions);
wrap.appendChild(pre);
});
return tmpl.innerHTML;
@@ -1162,7 +1180,23 @@ class CodemanApp {
_bindResponseViewerInteractions(body) {
if (!body || body.dataset.rvBound === '1') return;
body.dataset.rvBound = '1';
body.addEventListener('click', (ev) => {
body.addEventListener('click', async (ev) => {
// One-click copy: lift the raw source from the sibling <pre><code>.
const copyBtn = ev.target.closest('.rv-copy-btn');
if (copyBtn) {
ev.preventDefault();
ev.stopPropagation();
const code = copyBtn.closest('.rv-code-wrap')?.querySelector('pre code');
const ok = code ? await this._copyText(code.textContent || '') : false;
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
copyBtn.classList.add(ok ? 'rv-copied' : 'rv-copy-failed');
clearTimeout(copyBtn._resetTimer);
copyBtn._resetTimer = setTimeout(() => {
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
}, 1500);
return;
}
const btn = ev.target.closest('.rv-wrap-toggle');
if (!btn) return;
ev.preventDefault();
@@ -1175,6 +1209,34 @@ class CodemanApp {
});
}
/**
* Copy text to the clipboard. Prefers the async Clipboard API (secure
* contexts); falls back to a hidden-textarea + execCommand path so copy
* still works over plain HTTP. Returns true on success.
*/
async _copyText(text) {
if (!text) return false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* secure-context write failed — try the legacy path */ }
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
async toggleResponseViewer() {
const viewer = document.getElementById('responseViewer');
const backdrop = document.getElementById('responseViewerBackdrop');
@@ -2643,19 +2705,15 @@ class CodemanApp {
});
}
// Fire-and-forget resize + Ctrl+L to force Ink redraw.
// Tailed buffers accumulate stale CUP-positioned Ink frames that overlap
// in the viewport (e.g. duplicate "bypass permissions" bars). Ctrl+L
// triggers a full Ink redraw which overwrites all stale frame content.
// sendResize may be a no-op if dimensions match, so Ctrl+L is essential.
this.sendResize(sessionId).then(() => {
if (selectGen !== this._selectGeneration) return;
fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' })
}).catch(() => {});
});
// Fire-and-forget resize to nudge Ink via SIGWINCH on real size changes.
// Previously we also sent Ctrl+L (\x0c) here to force a full Ink redraw,
// but Claude Code 2.x treats Ctrl+L as a two-step "clear conversation"
// command — if a page refresh or SSE reconnect ran selectSession twice
// within Claude's confirmation window, the second \x0c silently wiped the
// conversation. Stale Ink frames in the tailed buffer are a cosmetic
// annoyance that disappear on the user's next keypress; data loss is not
// acceptable. Do NOT re-introduce Ctrl+L here.
this.sendResize(sessionId);
// Defer secondary panel updates so they don't block the main thread
// after terminal content is already visible.
+68
View File
@@ -8223,6 +8223,74 @@ kbd {
content: '⤢';
}
/* ── Code block one-click copy ──────────────────────────────────────────────
Every fenced code block is wrapped in .rv-code-wrap with an action toolbar
pinned to its top-right. Regular blocks get the relative positioning here;
ASCII diagrams already get it from .rv-diagram-wrap (don't clobber its
centering margins). */
.rv-text .rv-code-wrap:not(.rv-diagram-wrap),
.response-viewer-body .rv-code-wrap:not(.rv-diagram-wrap) {
position: relative;
margin: 1em 0;
}
.rv-text .rv-code-wrap:not(.rv-diagram-wrap) > pre,
.response-viewer-body .rv-code-wrap:not(.rv-diagram-wrap) > pre {
margin: 0;
padding-right: 44px; /* reserve room for the copy button */
}
/* Diagrams carry two buttons (copy + wrap toggle) — widen the reserve. */
.rv-text .rv-code-wrap.rv-diagram-wrap > pre.rv-diagram,
.response-viewer-body .rv-code-wrap.rv-diagram-wrap > pre.rv-diagram {
padding-right: 76px;
}
.rv-code-actions {
position: absolute;
top: 6px;
right: 6px;
display: inline-flex;
gap: 4px;
z-index: 2;
}
/* Inside the flex toolbar the wrap toggle flows normally — drop its own pin. */
.rv-code-actions .rv-wrap-toggle {
position: static;
top: auto;
right: auto;
}
.rv-copy-btn {
width: 28px;
height: 24px;
padding: 0;
border: 1px solid #2f2f45;
border-radius: 5px;
background: rgba(20, 20, 32, 0.92);
color: #8b8b97;
font-size: 13px;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s, border-color 0.15s;
}
.rv-copy-btn:hover,
.rv-copy-btn:active {
color: #e0e0ec;
border-color: #4a4a65;
}
.rv-copy-btn::before { content: '\2398'; } /* ⎘ — matches file-preview copy */
.rv-copy-btn.rv-copied { color: #9ece6a; border-color: #3a5a3a; }
.rv-copy-btn.rv-copied::before { content: '\2713'; } /* ✓ */
.rv-copy-btn.rv-copy-failed { color: #f7768e; border-color: #5a3a3a; }
.rv-copy-btn.rv-copy-failed::before { content: '\2715'; } /* ✕ */
.rv-text ul, .rv-text ol,
.response-viewer-body > ul, .response-viewer-body > ol {
margin: 0.6em 0;
+18 -29
View File
@@ -1704,7 +1704,8 @@ Object.assign(CodemanApp.prototype, {
/**
* Restore terminal size to match web UI dimensions.
* Use this after mobile screen attachment has squeezed the terminal.
* Sends resize to PTY and Ctrl+L to trigger Claude to redraw.
* Sends only resize — SIGWINCH triggers Ink redraw on real dimension changes.
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
*/
async restoreTerminalSize() {
if (!this.activeSessionId) {
@@ -1719,16 +1720,10 @@ Object.assign(CodemanApp.prototype, {
}
try {
// Send resize to restore proper dimensions (with minimum enforcement)
// Send resize to restore proper dimensions (with minimum enforcement).
// The PTY's SIGWINCH on real dim change is enough for Ink to redraw.
await this.sendResize(this.activeSessionId);
// Send Ctrl+L to trigger Claude to redraw at new size
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' }),
});
this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
} catch (err) {
console.error('Failed to restore terminal size:', err);
@@ -1736,26 +1731,20 @@ Object.assign(CodemanApp.prototype, {
}
},
// Send Ctrl+L to fix display for newly created sessions once Claude is running
sendPendingCtrlL(sessionId) {
if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) {
return;
}
this.pendingCtrlL.delete(sessionId);
// Only send if this is the active session
if (sessionId !== this.activeSessionId) {
return;
}
// Send resize + Ctrl+L to fix the display (with minimum dimension enforcement)
this.sendResize(sessionId).then(() => {
fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' }),
});
});
// Legacy hook for newly-created sessions; kept as a no-op so the SSE
// idle/working handlers can still call it without conditional guards.
//
// Originally this sent Ctrl+L (\x0c) when a flagged session first reached
// idle/working to scrub mux-init junk from the screen. Two problems:
// 1. `pendingCtrlL` was never actually populated anywhere (dead path).
// 2. Claude Code 2.x interprets Ctrl+L as a two-step "clear conversation"
// command — sending it from background flows risked nuking the user's
// conversation if it coincided with another Ctrl+L (e.g. from
// selectSession on page reload).
// If a per-session display-fix is ever needed again, do it via sendResize
// or an Ink-safe control sequence, NOT \x0c.
sendPendingCtrlL(_sessionId) {
// intentionally empty
},
async copyTerminal() {
+29
View File
@@ -0,0 +1,29 @@
/**
* @fileoverview Shared Fastify error handler for Codeman's HTTP routes.
*
* Route helpers (`findSessionOrFail`, `parseBody` in route-helpers.ts) throw
* structured errors carrying `{ statusCode, body }`. This handler renders them
* into the proper HTTP response. It is installed by BOTH the production server
* and the route test harness so test behavior matches production exactly —
* without it, thrown errors fall through to Fastify's default handler and the
* response body is `{statusCode,error,message}` instead of `{success:false,...}`.
*/
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../types.js';
/**
* Install the global error handler that renders structured route errors.
* Errors thrown with a `statusCode`/`body` (see route-helpers.ts) are sent
* verbatim at that status; anything else becomes a 500 OPERATION_FAILED response.
*/
export function installRouteErrorHandler(app: FastifyInstance): void {
app.setErrorHandler((error, _req, reply) => {
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
const body = (error as { body?: unknown }).body;
if (body) {
reply.code(statusCode).send(body);
} else {
reply.code(statusCode).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(error)));
}
});
}
+4 -12
View File
@@ -89,8 +89,6 @@ const require = createRequire(import.meta.url);
const { version: APP_VERSION } = require('../../package.json');
import {
getErrorMessage,
ApiErrorCode,
createErrorResponse,
type PersistedRespawnConfig,
type NiceConfig,
type ImageDetectedEvent,
@@ -101,6 +99,7 @@ import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.j
import { SseEvent } from './sse-events.js';
import type { ScheduledRun } from './ports/index.js';
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import {
registerPushRoutes,
registerTeamRoutes,
@@ -657,16 +656,9 @@ export class WebServer extends EventEmitter {
reply.code(updated ? 204 : 404).send();
});
// Global error handler for structured errors thrown by findSessionOrFail
this.app.setErrorHandler((error, _req, reply) => {
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
const body = (error as { body?: unknown }).body;
if (body) {
reply.code(statusCode).send(body);
} else {
reply.code(statusCode).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(error)));
}
});
// Global error handler for structured errors thrown by findSessionOrFail /
// parseBody. Shared with the route test harness so test behavior matches prod.
installRouteErrorHandler(this.app);
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them
let _crashBreadcrumbs = '';
+149 -141
View File
@@ -1,4 +1,18 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// The respawn controller drives the AI idle checker, which spawns real `tmux`/`claude`
// via child_process. Neutralize those spawns here (mirrors ai-idle-checker.test.ts) so
// tests never launch real processes. Spread the real module to keep `exec` etc. intact —
// transitively-imported modules (e.g. tmux-manager) call `promisify(exec)` at load time.
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return {
...actual,
execSync: vi.fn(),
spawn: vi.fn(() => ({ unref: vi.fn(), pid: 12345, on: vi.fn() })),
};
});
import { RespawnController, RespawnState, RespawnConfig } from '../src/respawn-controller.js';
import { Session } from '../src/session.js';
import { MockSession } from './mocks/index.js';
@@ -38,7 +52,9 @@ describe('RespawnController', () => {
it('should have default configuration', () => {
const config = controller.getConfig();
expect(config.enabled).toBe(true);
expect(config.updatePrompt).toBe('write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.');
expect(config.updatePrompt).toBe(
'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.'
);
});
it('should allow custom configuration', () => {
@@ -95,9 +111,9 @@ describe('RespawnController', () => {
session.simulateCompletionMessage();
// Wait for log
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
const hasCompletionLog = logMessages.some(msg => msg.includes('Completion message detected'));
const hasCompletionLog = logMessages.some((msg) => msg.includes('Completion message detected'));
expect(hasCompletionLog).toBe(true);
});
@@ -139,7 +155,7 @@ describe('RespawnController', () => {
session.simulateCompletionMessage();
// Wait for completion confirmation (completionConfirmMs=50) + processing
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(cycleStarted).toBe(true);
expect(controller.currentCycle).toBe(1);
@@ -155,7 +171,7 @@ describe('RespawnController', () => {
session.simulateCompletionMessage();
// Wait for completion confirmation + step delay
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
expect(stepSent).toBe('update');
expect(session.writeBuffer.length).toBeGreaterThan(0);
@@ -170,7 +186,7 @@ describe('RespawnController', () => {
session.simulateCompletionMessage();
// Wait for state transitions
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should have transitioned through multiple states (watching -> confirming_idle -> sending_update)
expect(states).toContain('watching');
@@ -239,7 +255,7 @@ describe('RespawnController', () => {
controller.start();
// Wait a bit
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const status = controller.getStatus();
expect(status.timeSinceActivity).toBeGreaterThan(0);
@@ -299,7 +315,7 @@ describe('RespawnController', () => {
});
controller.start();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(events.length).toBeGreaterThan(0);
expect(events[0].state).toBe('watching');
@@ -313,7 +329,7 @@ describe('RespawnController', () => {
controller.start();
expect(logs.length).toBeGreaterThan(0);
expect(logs.some(l => l.includes('Starting'))).toBe(true);
expect(logs.some((l) => l.includes('Starting'))).toBe(true);
});
});
});
@@ -346,13 +362,13 @@ describe('RespawnController Integration', () => {
// Alternate between working and idle
session.simulatePrompt();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
session.simulatePrompt();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
// Should handle transitions gracefully
expect(controller.isRunning).toBe(true);
@@ -467,7 +483,7 @@ describe('RespawnController Integration', () => {
controller.start();
session.simulatePrompt();
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(cycleStarted).toBe(false);
controller.stop();
@@ -560,7 +576,7 @@ describe('RespawnController Configuration', () => {
it('should clamp completionConfirmMs to noOutputTimeoutMs', () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 60000, // Greater than noOutputTimeoutMs
completionConfirmMs: 60000, // Greater than noOutputTimeoutMs
noOutputTimeoutMs: 30000,
});
// completionConfirmMs should be clamped to noOutputTimeoutMs
@@ -646,7 +662,7 @@ describe('RespawnController State Transitions', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(stateHistory).toContain('watching');
expect(stateHistory.length).toBeGreaterThan(1);
@@ -657,7 +673,7 @@ describe('RespawnController State Transitions', () => {
session.simulateCompletionMessage();
// Wait a bit then stop during potential transition
await new Promise(resolve => setTimeout(resolve, 30));
await new Promise((resolve) => setTimeout(resolve, 30));
controller.stop();
expect(controller.state).toBe('stopped');
@@ -672,7 +688,7 @@ describe('RespawnController State Transitions', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
// May or may not complete depending on timing
expect(controller.isRunning).toBe(true);
@@ -692,7 +708,7 @@ describe('RespawnController State Transitions', () => {
session.simulateCompletionMessage();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should detect completion messages
expect(completionCount).toBeGreaterThan(0);
@@ -708,10 +724,10 @@ describe('RespawnController State Transitions', () => {
session.simulateCompletionMessage();
// Before confirmation timer fires, start working
await new Promise(resolve => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 20));
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Cycle should not have started due to working state canceling confirmation
expect(controller.getStatus().workingDetected).toBe(true);
@@ -820,7 +836,7 @@ describe('RespawnController Edge Cases', () => {
// Update config mid-run
controller.updateConfig({ updatePrompt: 'updated' });
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(controller.getConfig().updatePrompt).toBe('updated');
controller.stop();
@@ -840,7 +856,7 @@ describe('RespawnController Edge Cases', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(controller.currentCycle).toBeGreaterThan(0);
controller.stop();
@@ -853,7 +869,7 @@ describe('RespawnController Edge Cases', () => {
controller.start();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const status = controller.getStatus();
// Allow for slight timing variance (timers may fire 1-2ms early)
@@ -868,7 +884,7 @@ describe('RespawnController Edge Cases', () => {
controller.start();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
session.simulateTerminalOutput('new data');
@@ -908,7 +924,7 @@ describe('RespawnController Edge Cases', () => {
session.simulateTerminalOutput('Would you like to proceed?\n❯ 1. Yes\n 2. No\n');
// Wait for autoAcceptDelayMs to expire
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(true);
expect(session.writeBuffer).toContain('\r');
@@ -935,7 +951,7 @@ describe('RespawnController Edge Cases', () => {
session.simulateCompletionMessage();
// Wait for autoAcceptDelayMs
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
autoAcceptController.stop();
@@ -958,7 +974,7 @@ describe('RespawnController Edge Cases', () => {
autoAcceptController.start();
session.simulateTerminalOutput('Plan: Waiting for approval...');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
autoAcceptController.stop();
@@ -981,7 +997,7 @@ describe('RespawnController Edge Cases', () => {
autoAcceptController.start();
// Don't simulate any output - just wait
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
autoAcceptController.stop();
@@ -1006,15 +1022,15 @@ describe('RespawnController Edge Cases', () => {
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
// Wait 100ms (less than 150ms delay), then send more output
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
session.simulateTerminalOutput('More output');
// Wait another 100ms - total 200ms from start but only 100ms from last output
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(autoAcceptFired).toBe(false);
// Wait the remaining time
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(autoAcceptFired).toBe(true);
autoAcceptController.stop();
});
@@ -1038,16 +1054,16 @@ describe('RespawnController Edge Cases', () => {
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
// Wait for first auto-accept
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptCount).toBe(1);
// Wait more - should NOT fire again (hasReceivedOutput is false)
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptCount).toBe(1);
// New output comes in (plan mode again), then silence again - should fire again
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptCount).toBe(2);
autoAcceptController.stop();
@@ -1072,14 +1088,14 @@ describe('RespawnController Edge Cases', () => {
// Trigger a respawn cycle via completion message
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
// Now in sending_update or waiting_update state
expect(autoAcceptController.state).not.toBe('watching');
// Simulate output in the waiting state, then silence
session.simulateTerminalOutput('Processing update...');
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
// Auto-accept should NOT fire because we're not in watching state
expect(autoAcceptFired).toBe(false);
@@ -1107,7 +1123,7 @@ describe('RespawnController Edge Cases', () => {
autoAcceptController.signalElicitation();
// Wait for autoAcceptDelayMs to expire
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Auto-accept should NOT fire because elicitation was signaled
expect(autoAcceptFired).toBe(false);
@@ -1141,7 +1157,7 @@ describe('RespawnController Edge Cases', () => {
// New silence after work - plan mode approval with plan mode UI
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Auto-accept should fire now (elicitation cleared by working pattern)
expect(autoAcceptFired).toBe(true);
@@ -1210,7 +1226,7 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer to fire and AI check to start
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should have transitioned to ai_checking
expect(states).toContain('ai_checking');
@@ -1230,13 +1246,13 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for AI check to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Simulate working patterns during AI check
session.simulateWorking();
// Should be back to watching
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(controller.state).toBe('watching');
controller.stop();
@@ -1254,13 +1270,13 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for AI check to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Simulate substantial output during AI check
session.simulateTerminalOutput('Some meaningful output that is more than 2 chars');
// Should be back to watching
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(controller.state).toBe('watching');
controller.stop();
@@ -1282,7 +1298,7 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for completion confirm and direct idle
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(cycleStarted).toBe(true);
controller.stop();
@@ -1304,7 +1320,7 @@ describe('RespawnController AI Idle Check', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
expect(aiCheckStarted).toBe(true);
controller.stop();
@@ -1347,7 +1363,7 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer + AI check start
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
// Should have triggered ai_checking via completion path
expect(states).toContain('ai_checking');
@@ -1370,7 +1386,7 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for AI check to start and timeout
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
// Should return to watching after timeout (with cooldown)
expect(controller.state).toBe('watching');
@@ -1416,7 +1432,7 @@ describe('RespawnController AI Plan Mode Check', () => {
// Output without plan mode patterns (no numbered list, no selector)
session.simulateTerminalOutput('Claude is just thinking about something...\nSome regular output here.');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Pre-filter should block - no plan mode patterns found
expect(autoAcceptFired).toBe(false);
@@ -1442,13 +1458,10 @@ describe('RespawnController AI Plan Mode Check', () => {
// Output WITH plan mode patterns
session.simulateTerminalOutput(
'Would you like to proceed with this plan?\n' +
'❯ 1. Yes\n' +
' 2. No\n' +
' 3. Type your own\n'
'Would you like to proceed with this plan?\n' + '❯ 1. Yes\n' + ' 2. No\n' + ' 3. Type your own\n'
);
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Pre-filter should pass and send Enter (AI disabled)
expect(autoAcceptFired).toBe(true);
@@ -1473,15 +1486,11 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Plan mode patterns BUT also has working patterns (spinner) in the tail
session.simulateTerminalOutput(
'❯ 1. Yes\n' +
' 2. No\n' +
'Thinking ⠋\n'
);
session.simulateTerminalOutput('❯ 1. Yes\n' + ' 2. No\n' + 'Thinking ⠋\n');
// Wait for autoAcceptDelay - but working pattern resets the timer
// so we need to wait longer and check after working pattern was consumed
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should NOT fire because working patterns detected resets timer
// (the working pattern in handleTerminalData clears timers)
@@ -1508,13 +1517,9 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Output with plan mode patterns to pass pre-filter
session.simulateTerminalOutput(
'Would you like to proceed?\n' +
'❯ 1. Yes\n' +
' 2. No\n'
);
session.simulateTerminalOutput('Would you like to proceed?\n' + '❯ 1. Yes\n' + ' 2. No\n');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Plan check should have been started (pre-filter passed, AI enabled)
expect(planCheckStarted).toBe(true);
@@ -1544,17 +1549,14 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Trigger plan check
session.simulateTerminalOutput(
'❯ 1. Yes\n' +
' 2. No\n'
);
await new Promise(resolve => setTimeout(resolve, 150));
session.simulateTerminalOutput('❯ 1. Yes\n' + ' 2. No\n');
await new Promise((resolve) => setTimeout(resolve, 150));
expect(planCheckStarted).toBe(true);
// New output arrives - should cancel plan check (stale)
session.simulateTerminalOutput('New output from Claude...');
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Auto-accept should NOT have fired (check was cancelled)
expect(autoAcceptFired).toBe(false);
@@ -1580,16 +1582,14 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Plan mode patterns to trigger check
session.simulateTerminalOutput(
'❯ 1. Yes\n 2. No\n'
);
await new Promise(resolve => setTimeout(resolve, 150));
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
await new Promise((resolve) => setTimeout(resolve, 150));
// Output arrives during check - result should be discarded
session.simulateTerminalOutput('Claude started working again');
// Wait for any pending check to complete
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
controller.stop();
@@ -1617,11 +1617,9 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Plan mode patterns
session.simulateTerminalOutput(
'❯ 1. Yes\n 2. No\n'
);
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should send Enter directly (no AI check)
expect(planCheckStarted).toBe(false);
@@ -1663,7 +1661,7 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Don't send any output - hasReceivedOutput should guard
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
controller.stop();
@@ -1861,7 +1859,7 @@ describe('RespawnController Resume Behavior', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer to fire
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should start cycle based on completion message alone
expect(cycleStarted).toBe(true);
@@ -1886,7 +1884,7 @@ describe('RespawnController Resume Behavior', () => {
session.simulateTerminalOutput('Some text output');
// Wait for noOutput fallback
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
// Should eventually trigger via fallback
expect(cycleStarted).toBe(true);
@@ -1915,7 +1913,7 @@ describe('RespawnController Resume Behavior', () => {
// Verify cycle can still start after resume
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(states).toContain('confirming_idle');
controller.stop();
@@ -1933,7 +1931,7 @@ describe('RespawnController Resume Behavior', () => {
session.simulateCompletionMessage();
// Wait for cycle to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Pause during cycle
controller.pause();
@@ -1975,7 +1973,7 @@ describe('RespawnController Step Confirmation', () => {
session.simulateCompletionMessage();
// Wait for full cycle
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
// Should have gone through: watching -> confirming_idle -> sending_update -> waiting_update -> watching
expect(states).toContain('watching');
@@ -1996,12 +1994,12 @@ describe('RespawnController Step Confirmation', () => {
session.simulateCompletionMessage();
// Wait for update to be sent
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
// Simulate continuous output while waiting for update completion
for (let i = 0; i < 5; i++) {
session.simulateTerminalOutput(`Processing step ${i}...`);
await new Promise(resolve => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 20));
}
// Controller should still be functional
@@ -2021,7 +2019,7 @@ describe('RespawnController Step Confirmation', () => {
session.simulateCompletionMessage();
// Wait for cycle to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Continuously emit output to prevent completion detection
const outputInterval = setInterval(() => {
@@ -2029,7 +2027,7 @@ describe('RespawnController Step Confirmation', () => {
}, 50);
// Wait a reasonable time
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
clearInterval(outputInterval);
@@ -2057,7 +2055,7 @@ describe('RespawnController Step Confirmation', () => {
session.simulateCompletionMessage();
// Wait for step to complete
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// After update step completes (via timeout), should emit stepCompleted
// Note: with sendClear/sendInit false, cycle completes after update
@@ -2086,7 +2084,7 @@ describe('RespawnController AI Check Cooldown Behavior', () => {
session.simulateCompletionMessage();
// Wait for AI check to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const detection = controller.getDetectionStatus();
// AI check should have started or be in progress
@@ -2130,7 +2128,7 @@ describe('RespawnController AI Check Cooldown Behavior', () => {
session.simulateCompletionMessage();
// Wait for AI check to timeout
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should have gone through ai_checking and back
expect(states).toContain('ai_checking');
@@ -2151,18 +2149,18 @@ describe('RespawnController AI Check Cooldown Behavior', () => {
// First cycle - should start AI check
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const detection1 = controller.getDetectionStatus();
// AI check was attempted
// Reset by simulating working
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
// Second cycle - might be on cooldown
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Controller should still be functional
expect(controller.isRunning).toBe(true);
@@ -2240,7 +2238,18 @@ describe('RespawnController Working Pattern Detection', () => {
controller.start();
// All spinner characters should indicate working
const spinnerChars = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'];
const spinnerChars = [
'\u280b',
'\u2819',
'\u2839',
'\u2838',
'\u283c',
'\u2834',
'\u2826',
'\u2827',
'\u2807',
'\u280f',
];
for (const char of spinnerChars) {
session.simulateTerminalOutput(char);
}
@@ -2266,7 +2275,7 @@ describe('RespawnController Working Pattern Detection', () => {
// Then completion
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
// Working should be cleared after a bit of silence
status = controller.getStatus();
@@ -2301,7 +2310,7 @@ describe('RespawnController Cycle Count Tracking', () => {
expect(controller.currentCycle).toBe(0);
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
expect(controller.currentCycle).toBeGreaterThan(0);
controller.stop();
@@ -2321,7 +2330,7 @@ describe('RespawnController Cycle Count Tracking', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
expect(cycleNumber).toBe(1);
controller.stop();
@@ -2425,13 +2434,13 @@ describe('RespawnController Timer Cleanup', () => {
session.simulateCompletionMessage();
// Start a timer-based operation
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
// Stop should clean up all timers
controller.stop();
// Wait to ensure no timer fires after stop
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(controller.state).toBe('stopped');
expect(controller.isRunning).toBe(false);
@@ -2448,13 +2457,13 @@ describe('RespawnController Timer Cleanup', () => {
session.simulateCompletionMessage();
// Wait for confirming_idle
await new Promise(resolve => setTimeout(resolve, 30));
await new Promise((resolve) => setTimeout(resolve, 30));
// Interrupt with working pattern
session.simulateWorking();
// Should cancel completion confirm timer and return to watching
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(controller.state).toBe('watching');
controller.stop();
@@ -2469,14 +2478,13 @@ describe('RespawnController Timer Cleanup', () => {
for (let i = 0; i < 10; i++) {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 10));
controller.stop();
}
// Should end in stopped state without errors
expect(controller.state).toBe('stopped');
});
});
// ========== Hook-Based Detection Tests (Phase 1) ==========
@@ -2542,7 +2550,7 @@ describe('RespawnController Hook-Based Idle Detection', () => {
testController.signalStopHook();
// Wait for hook confirmation timer (3s default)
await new Promise(resolve => setTimeout(resolve, 3100));
await new Promise((resolve) => setTimeout(resolve, 3100));
expect(cycleStarted).toHaveBeenCalled();
testController.stop();
@@ -2562,7 +2570,7 @@ describe('RespawnController Hook-Based Idle Detection', () => {
testController.signalIdlePrompt();
// idle_prompt skips confirmation and goes directly to idle
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(cycleStarted).toHaveBeenCalled();
testController.stop();
@@ -2582,11 +2590,11 @@ describe('RespawnController Hook-Based Idle Detection', () => {
testController.signalStopHook();
// Simulate working patterns IMMEDIATELY after hook (before confirmation)
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
session.simulateWorking();
// Wait longer than hook confirmation delay (3s)
await new Promise(resolve => setTimeout(resolve, 3500));
await new Promise((resolve) => setTimeout(resolve, 3500));
// Cycle should NOT have started because working was detected
expect(cycleStarted).not.toHaveBeenCalled();
@@ -2608,7 +2616,7 @@ describe('RespawnController Hook-Based Idle Detection', () => {
testController.start();
testController.signalIdlePrompt(); // Start a cycle
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Now in sending_update state - Stop hook should be ignored
testController.signalStopHook();
@@ -2658,7 +2666,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for completion detection to trigger timer
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should have started at least one timer (completion-confirm or no-output-fallback)
expect(timerEvents.length).toBeGreaterThan(0);
@@ -2690,9 +2698,9 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
// Send output to trigger no-output timer reset
session.simulateTerminalOutput('some output');
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const noOutputTimer = timerEvents.find(e => e.name === 'no-output-fallback');
const noOutputTimer = timerEvents.find((e) => e.name === 'no-output-fallback');
if (noOutputTimer) {
expect(noOutputTimer.durationMs).toBe(noOutputTimeoutMs);
}
@@ -2716,7 +2724,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer to fire (50ms + processing)
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// At least one timer should have completed (completion-confirm)
expect(completedTimers.length).toBeGreaterThan(0);
@@ -2741,11 +2749,11 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for timer to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Simulate working to cancel the completion confirm timer
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Working patterns should have cancelled at least one timer
const hasCancel = cancelledTimers.length > 0;
@@ -2770,13 +2778,13 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
// Trigger Stop hook to start hook-confirm timer
controller.signalStopHook();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Then working patterns should cancel it with a reason
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const hookCancel = cancelledTimers.find(e => e.name === 'hook-confirm');
const hookCancel = cancelledTimers.find((e) => e.name === 'hook-confirm');
if (hookCancel) {
expect(hookCancel.reason).toBeTruthy();
}
@@ -2797,7 +2805,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for timers to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should have active timers
const timersBefore = controller.getActiveTimers();
@@ -2822,7 +2830,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for timers to start
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
controller.stop();
@@ -2835,7 +2843,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
});
// Wait longer than any timer duration
await new Promise(resolve => setTimeout(resolve, 400));
await new Promise((resolve) => setTimeout(resolve, 400));
expect(timerFiredAfterStop).toBe(false);
});
@@ -2852,11 +2860,11 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
// First cycle: start, trigger completion-related timers, stop
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should have completion-confirm timer active
const firstCycleTimers = controller.getActiveTimers();
const hasCompletionConfirm = firstCycleTimers.some(t => t.name === 'completion-confirm');
const hasCompletionConfirm = firstCycleTimers.some((t) => t.name === 'completion-confirm');
expect(hasCompletionConfirm).toBe(true);
controller.stop();
@@ -2866,14 +2874,14 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
expect(controller.state).toBe('watching');
const restartTimers = controller.getActiveTimers();
const staleCompletionConfirm = restartTimers.some(t => t.name === 'completion-confirm');
const staleCompletionConfirm = restartTimers.some((t) => t.name === 'completion-confirm');
expect(staleCompletionConfirm).toBe(false);
// Can still trigger new timers
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const newTimers = controller.getActiveTimers();
const hasNewCompletionConfirm = newTimers.some(t => t.name === 'completion-confirm');
const hasNewCompletionConfirm = newTimers.some((t) => t.name === 'completion-confirm');
expect(hasNewCompletionConfirm).toBe(true);
controller.stop();
@@ -2895,13 +2903,13 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
for (let i = 0; i < 5; i++) {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 20));
controller.stop();
}
// Wait to check no stale timers fire
const countBefore = completedTimers.length;
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
const countAfter = completedTimers.length;
// No new timer completions should happen after final stop
@@ -2920,7 +2928,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const activeTimers = controller.getActiveTimers();
expect(activeTimers.length).toBeGreaterThan(0);
@@ -2946,7 +2954,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const detectionStatus = controller.getDetectionStatus();
expect(Array.isArray(detectionStatus.activeTimers)).toBe(true);
@@ -2966,14 +2974,14 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer to be set up
await new Promise(resolve => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 20));
const timersBefore = controller.getActiveTimers();
const hasCompletionConfirm = timersBefore.some(t => t.name === 'completion-confirm');
const hasCompletionConfirm = timersBefore.some((t) => t.name === 'completion-confirm');
// Wait for the timer to fire
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
const timersAfter = controller.getActiveTimers();
const stillHasCompletionConfirm = timersAfter.some(t => t.name === 'completion-confirm');
const stillHasCompletionConfirm = timersAfter.some((t) => t.name === 'completion-confirm');
// If we caught the timer before it fired, it should be gone now
if (hasCompletionConfirm) {
@@ -2992,7 +3000,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should have timers
const timersBefore = controller.getActiveTimers();
@@ -3000,11 +3008,11 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
// Cancel via working
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// completion-confirm should be removed
const timersAfter = controller.getActiveTimers();
const hasCompletionConfirm = timersAfter.some(t => t.name === 'completion-confirm');
const hasCompletionConfirm = timersAfter.some((t) => t.name === 'completion-confirm');
expect(hasCompletionConfirm).toBe(false);
controller.stop();
@@ -3019,10 +3027,10 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
controller.signalStopHook();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const activeTimers = controller.getActiveTimers();
const hookTimer = activeTimers.find(t => t.name === 'hook-confirm');
const hookTimer = activeTimers.find((t) => t.name === 'hook-confirm');
expect(hookTimer).toBeDefined();
if (hookTimer) {
expect(hookTimer.totalMs).toBeGreaterThan(0);
+5 -1
View File
@@ -7,6 +7,7 @@
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
export interface RouteTestHarness {
app: FastifyInstance;
@@ -24,7 +25,7 @@ export interface RouteTestHarness {
export async function createRouteTestHarness(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerFn: (app: FastifyInstance, ctx: any) => void,
ctxOptions?: { sessionId?: string },
ctxOptions?: { sessionId?: string }
): Promise<RouteTestHarness> {
const app = Fastify({ logger: false });
@@ -34,6 +35,9 @@ export async function createRouteTestHarness(
const ctx = createMockRouteContext(ctxOptions);
registerFn(app, ctx);
// Mirror production: structured errors thrown by route helpers (findSessionOrFail,
// parseBody) are rendered to {success:false} bodies at the right status.
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
+11 -12
View File
@@ -104,9 +104,7 @@ describe('case-routes', () => {
});
it('includes hasClaudeMd flag', async () => {
mockedReaddir.mockResolvedValue([
{ name: 'case-with-md', isDirectory: () => true },
] as never);
mockedReaddir.mockResolvedValue([{ name: 'case-with-md', isDirectory: () => true }] as never);
mockedExistsSync.mockReturnValue(true);
const res = await harness.app.inject({
@@ -120,9 +118,7 @@ describe('case-routes', () => {
it('includes linked cases from linked-cases.json', async () => {
// CASES_DIR readdir returns one case
mockedReaddir.mockResolvedValue([
{ name: 'regular-case', isDirectory: () => true },
] as never);
mockedReaddir.mockResolvedValue([{ name: 'regular-case', isDirectory: () => true }] as never);
// linked-cases.json is read second (after CASES_DIR readdir)
let readCallCount = 0;
mockedReadFile.mockImplementation(async () => {
@@ -158,7 +154,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: { name: 'invalid case name!!' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -169,7 +165,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -180,7 +176,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: { name: '../etc' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -230,7 +226,7 @@ describe('case-routes', () => {
url: '/api/cases/link',
payload: { name: 'bad name!' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -241,7 +237,7 @@ describe('case-routes', () => {
url: '/api/cases/link',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -294,7 +290,10 @@ describe('case-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.case.name).toBe('linked-project');
expect(harness.ctx.broadcast).toHaveBeenCalledWith('case:linked', expect.objectContaining({ name: 'linked-project' }));
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'case:linked',
expect.objectContaining({ name: 'linked-project' })
);
});
});
+2 -1
View File
@@ -312,7 +312,8 @@ describe('file-routes', () => {
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=../../etc/shadow`,
});
expect(res.statusCode).toBe(400);
// Path traversal returns 404 ("File not found") to avoid revealing the target exists.
expect(res.statusCode).toBe(404);
});
it('rejects overly large raw files', async () => {
+6 -9
View File
@@ -38,7 +38,7 @@ describe('hook-event-routes', () => {
expect(body.success).toBe(true);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'hook:stop',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -55,7 +55,7 @@ describe('hook-event-routes', () => {
expect(res.statusCode).toBe(200);
expect(harness.ctx.sendPushNotifications).toHaveBeenCalledWith(
'hook:idle_prompt',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -85,7 +85,7 @@ describe('hook-event-routes', () => {
data: null,
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -98,7 +98,7 @@ describe('hook-event-routes', () => {
event: 'stop',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -180,10 +180,7 @@ describe('hook-event-routes', () => {
},
});
expect(res.statusCode).toBe(200);
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith(
'stop',
expect.any(Object),
);
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object));
});
it('starts transcript watcher when transcript_path is provided', async () => {
@@ -199,7 +196,7 @@ describe('hook-event-routes', () => {
expect(res.statusCode).toBe(200);
expect(harness.ctx.startTranscriptWatcher).toHaveBeenCalledWith(
harness.ctx._sessionId,
'/home/user/.claude/transcript.jsonl',
'/home/user/.claude/transcript.jsonl'
);
});
+3 -3
View File
@@ -103,7 +103,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/start',
payload: { goal: '' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -114,7 +114,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/start',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -228,7 +228,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/reject',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+8 -10
View File
@@ -80,7 +80,7 @@ describe('plan-routes', () => {
url: '/api/cancel-plan-generation',
payload: { orchestratorId: 12345 }, // should be string
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -129,7 +129,7 @@ describe('plan-routes', () => {
expect(body.data.status).toBe('completed');
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planTaskUpdate',
expect.objectContaining({ sessionId: harness.ctx._sessionId, taskId: 'task-1' }),
expect.objectContaining({ sessionId: harness.ctx._sessionId, taskId: 'task-1' })
);
});
@@ -159,7 +159,7 @@ describe('plan-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/plan/task/task-1`,
payload: { status: 'invalid_status' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -208,7 +208,7 @@ describe('plan-routes', () => {
expect(body.data.completedCount).toBe(5);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planCheckpoint',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
});
@@ -279,9 +279,7 @@ describe('plan-routes', () => {
});
it('rolls back to a previous version', async () => {
const mockPlan = [
{ id: 'task-1', content: 'Step 1', status: 'pending' },
];
const mockPlan = [{ id: 'task-1', content: 'Step 1', status: 'pending' }];
harness.ctx._session.ralphTracker = {
rollbackToVersion: vi.fn(() => ({ success: true, plan: mockPlan })),
} as never;
@@ -296,7 +294,7 @@ describe('plan-routes', () => {
expect(body.data).toHaveLength(1);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planRollback',
expect.objectContaining({ sessionId: harness.ctx._sessionId, version: 1 }),
expect.objectContaining({ sessionId: harness.ctx._sessionId, version: 1 })
);
});
@@ -358,7 +356,7 @@ describe('plan-routes', () => {
expect(body.data.content).toBe('New task');
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planTaskAdded',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -372,7 +370,7 @@ describe('plan-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/plan/task`,
payload: { priority: 'P1' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+5 -5
View File
@@ -65,7 +65,7 @@ describe('push-routes', () => {
expect.objectContaining({
endpoint: 'https://push.example.com/send/abc123',
keys: { p256dh: 'test-p256dh-key', auth: 'test-auth-key' },
}),
})
);
});
@@ -87,7 +87,7 @@ describe('push-routes', () => {
expect.objectContaining({
userAgent: 'TestBrowser/1.0',
pushPreferences: { 'session:idle': true, 'session:error': false },
}),
})
);
});
@@ -99,7 +99,7 @@ describe('push-routes', () => {
keys: { p256dh: 'test-p256dh', auth: 'test-auth' },
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -112,7 +112,7 @@ describe('push-routes', () => {
endpoint: 'https://push.example.com/send/abc123',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -160,7 +160,7 @@ describe('push-routes', () => {
url: '/api/push/subscribe/sub-123',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+35 -21
View File
@@ -66,7 +66,9 @@ describe('ralph-routes', () => {
});
it('enables ralph tracker', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -79,7 +81,9 @@ describe('ralph-routes', () => {
});
it('disables ralph tracker', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -99,7 +103,7 @@ describe('ralph-routes', () => {
});
expect((harness.ctx.mux as Record<string, unknown>).updateRalphEnabled).toHaveBeenCalledWith(
harness.ctx._sessionId,
true,
true
);
});
@@ -116,7 +120,9 @@ describe('ralph-routes', () => {
});
it('handles reset option', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -127,7 +133,9 @@ describe('ralph-routes', () => {
});
it('configures completion phrase and max iterations', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -138,7 +146,9 @@ describe('ralph-routes', () => {
});
it('sets max iterations independently', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -154,7 +164,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/ralph-config',
payload: { enabled: true },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -178,13 +188,15 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
payload: { enabled: 'not-boolean' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('handles disableAutoEnable flag', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -208,7 +220,9 @@ describe('ralph-routes', () => {
describe('POST /api/sessions/:id/ralph-circuit-breaker/reset', () => {
it('resets circuit breaker for valid session', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -225,7 +239,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/ralph-circuit-breaker/reset',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -253,7 +267,7 @@ describe('ralph-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/ralph-status',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -279,7 +293,7 @@ describe('ralph-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/fix-plan',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -315,7 +329,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/fix-plan/import',
payload: { content: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -326,7 +340,7 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/import`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -341,7 +355,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/ralph-prompt/write',
payload: { content: 'test prompt' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -365,7 +379,7 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/ralph-prompt/write`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -379,7 +393,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/fix-plan/write',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -405,7 +419,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/fix-plan/read',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -432,7 +446,7 @@ describe('ralph-routes', () => {
url: '/api/ralph-loop/start',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -466,7 +480,7 @@ describe('ralph-routes', () => {
caseName: '../escape-path',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+2 -2
View File
@@ -172,7 +172,7 @@ describe('respawn-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`,
payload: { idleTimeoutMs: 'not-a-number' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -196,7 +196,7 @@ describe('respawn-routes', () => {
expect(mockController.updateConfig).toHaveBeenCalled();
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'respawn:configUpdated',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
+4 -8
View File
@@ -114,7 +114,7 @@ describe('scheduled-routes', () => {
prompt: '',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -125,7 +125,7 @@ describe('scheduled-routes', () => {
url: '/api/scheduled',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -139,7 +139,7 @@ describe('scheduled-routes', () => {
workingDir: '/tmp/test;rm -rf /',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -171,11 +171,7 @@ describe('scheduled-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
// Should default to 60 minutes
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith(
'test',
expect.any(String),
60,
);
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60);
});
});
+13 -13
View File
@@ -103,7 +103,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent',
});
expect(res.statusCode).toBe(200); // returns error in body, not HTTP 404
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.error).toBeDefined();
@@ -174,7 +174,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/name',
payload: { name: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -227,7 +227,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/input',
payload: { input: 'hello' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -238,7 +238,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/input`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -265,7 +265,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 501, rows: 24 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -276,7 +276,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 80, rows: 201 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -287,7 +287,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 0, rows: 24 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -312,7 +312,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/terminal',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -338,7 +338,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/run`,
payload: { prompt: '' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -349,7 +349,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/run',
payload: { prompt: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -386,7 +386,7 @@ describe('session-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/interactive',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -450,7 +450,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/output',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -586,7 +586,7 @@ describe('session-routes', () => {
resumeSessionId: 'not-a-uuid',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+4 -4
View File
@@ -166,7 +166,7 @@ describe('system-routes', () => {
url: '/api/config',
payload: { unknownField: 'invalid' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -361,7 +361,7 @@ describe('system-routes', () => {
url: '/api/settings',
payload: { unknownField: 'bad' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -459,7 +459,7 @@ describe('system-routes', () => {
url: '/api/subagent-window-states',
payload: { minimized: { 'agent-1': 'not-a-boolean' } },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -516,7 +516,7 @@ describe('system-routes', () => {
url: '/api/subagent-parents',
payload: { 'agent-1': 123 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});