Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions test/runtime/terminal/session-manager-auto-restart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,4 +195,50 @@ describe("TerminalSessionManager auto-restart", () => {
expect(session.write).toHaveBeenCalledWith(deferredStartupInput);
expect(session.write).toHaveBeenCalledTimes(1);
});

it("auto-restarts up to MAX_AUTO_RESTARTS_PER_WINDOW times, then refuses the next exit in the same window", async () => {
const spawnedSessions: Array<ReturnType<typeof createMockPtySession>> = [];
ptySessionSpawnMock.mockImplementation((request: MockSpawnRequest) => {
const session = createMockPtySession(100 + spawnedSessions.length, request);
spawnedSessions.push(session);
return session;
});

const manager = new TerminalSessionManager();
manager.attach("task-1", {
onState: vi.fn(),
onOutput: vi.fn(),
onExit: vi.fn(),
});

await manager.startTaskSession({
taskId: "task-1",
agentId: "codex",
binary: "codex",
args: [],
cwd: "/tmp/task-1",
prompt: "Fix the bug",
});

expect(ptySessionSpawnMock).toHaveBeenCalledTimes(1);

// First 3 qualifying exits (within the trailing AUTO_RESTART_WINDOW_MS) each auto-restart.
for (let restart = 1; restart <= 3; restart += 1) {
spawnedSessions[spawnedSessions.length - 1]?.triggerExit(1);
await vi.waitFor(() => {
expect(ptySessionSpawnMock).toHaveBeenCalledTimes(restart + 1);
});
expect(manager.getSummary("task-1")?.state).toBe("running");
}

// The 4th qualifying exit within the same window hits MAX_AUTO_RESTARTS_PER_WINDOW and is refused.
spawnedSessions[spawnedSessions.length - 1]?.triggerExit(1);
await Promise.resolve();
await Promise.resolve();

expect(ptySessionSpawnMock).toHaveBeenCalledTimes(4);
expect(manager.getSummary("task-1")?.state).toBe("awaiting_review");
expect(manager.getSummary("task-1")?.reviewReason).toBe("error");
expect(manager.getSummary("task-1")?.pid).toBeNull();
});
});
150 changes: 150 additions & 0 deletions test/runtime/terminal/session-state-machine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";

import type { RuntimeTaskSessionSummary } from "../../../src/core/api-contract";
import { reduceSessionTransition, type SessionTransitionEvent } from "../../../src/terminal/session-state-machine";

function createSummary(overrides: Partial<RuntimeTaskSessionSummary> = {}): RuntimeTaskSessionSummary {
return {
taskId: "task-1",
state: "running",
agentId: "claude",
workspacePath: "/tmp/worktree",
pid: 1234,
startedAt: Date.now(),
updatedAt: Date.now(),
lastOutputAt: Date.now(),
reviewReason: null,
exitCode: null,
lastHookAt: null,
latestHookActivity: null,
...overrides,
};
}

describe("reduceSessionTransition", () => {
it("hook.to_review moves a running session to awaiting_review with reason hook", () => {
const summary = createSummary({ state: "running" });
const result = reduceSessionTransition(summary, { type: "hook.to_review" });

expect(result.changed).toBe(true);
expect(result.patch).toEqual({ state: "awaiting_review", reviewReason: "hook" });
expect(result.clearAttentionBuffer).toBe(true);
});

it("hook.to_review is a no-op when the session is not running", () => {
const summary = createSummary({ state: "awaiting_review", reviewReason: "attention" });
const result = reduceSessionTransition(summary, { type: "hook.to_review" });

expect(result).toEqual({ changed: false, patch: {}, clearAttentionBuffer: false });
});

it("hook.to_in_progress returns an awaiting_review session with a resumable reason to running", () => {
const summary = createSummary({ state: "awaiting_review", reviewReason: "attention" });
const result = reduceSessionTransition(summary, { type: "hook.to_in_progress" });

expect(result.changed).toBe(true);
expect(result.patch).toEqual({ state: "running", reviewReason: null });
expect(result.clearAttentionBuffer).toBe(true);
});

it("hook.to_in_progress treats reviewReason error as resumable and returns the session to running", () => {
const summary = createSummary({ state: "awaiting_review", reviewReason: "error" });
const result = reduceSessionTransition(summary, { type: "hook.to_in_progress" });

expect(result.changed).toBe(true);
expect(result.patch).toEqual({ state: "running", reviewReason: null });
expect(result.clearAttentionBuffer).toBe(true);
});

it("hook.to_in_progress is a no-op when the review reason cannot return to running", () => {
const summary = createSummary({ state: "awaiting_review", reviewReason: "exit" });
const result = reduceSessionTransition(summary, { type: "hook.to_in_progress" });

expect(result).toEqual({ changed: false, patch: {}, clearAttentionBuffer: false });
});

it("hook.to_in_progress is a no-op when the session is not awaiting review", () => {
const summary = createSummary({ state: "running", reviewReason: null });
const result = reduceSessionTransition(summary, { type: "hook.to_in_progress" });

expect(result).toEqual({ changed: false, patch: {}, clearAttentionBuffer: false });
});

it("agent.prompt-ready returns an awaiting_review session with a resumable reason to running", () => {
const summary = createSummary({ state: "awaiting_review", reviewReason: "hook" });
const result = reduceSessionTransition(summary, { type: "agent.prompt-ready" });

expect(result.changed).toBe(true);
expect(result.patch).toEqual({ state: "running", reviewReason: null });
expect(result.clearAttentionBuffer).toBe(true);
});

it("agent.prompt-ready is a no-op when the review reason cannot return to running", () => {
const summary = createSummary({ state: "awaiting_review", reviewReason: "interrupted" });
const result = reduceSessionTransition(summary, { type: "agent.prompt-ready" });

expect(result).toEqual({ changed: false, patch: {}, clearAttentionBuffer: false });
});

it("process.exit with exit code 0 and no interruption sets reviewReason exit", () => {
const summary = createSummary({ state: "running" });
const result = reduceSessionTransition(summary, { type: "process.exit", exitCode: 0, interrupted: false });

expect(result.changed).toBe(true);
expect(result.patch).toEqual({ state: "awaiting_review", reviewReason: "exit", exitCode: 0, pid: null });
expect(result.clearAttentionBuffer).toBe(false);
});

it("process.exit with exit code 1 and no interruption sets reviewReason error", () => {
const summary = createSummary({ state: "running" });
const result = reduceSessionTransition(summary, { type: "process.exit", exitCode: 1, interrupted: false });

expect(result.changed).toBe(true);
expect(result.patch).toEqual({ state: "awaiting_review", reviewReason: "error", exitCode: 1, pid: null });
expect(result.clearAttentionBuffer).toBe(false);
});

it("process.exit with exit code 137 and no interruption sets reviewReason error", () => {
const summary = createSummary({ state: "running" });
const result = reduceSessionTransition(summary, { type: "process.exit", exitCode: 137, interrupted: false });

expect(result.changed).toBe(true);
expect(result.patch).toEqual({ state: "awaiting_review", reviewReason: "error", exitCode: 137, pid: null });
expect(result.clearAttentionBuffer).toBe(false);
});

it("process.exit with a null exit code and no interruption sets reviewReason error", () => {
const summary = createSummary({ state: "running" });
const result = reduceSessionTransition(summary, { type: "process.exit", exitCode: null, interrupted: false });

expect(result.changed).toBe(true);
expect(result.patch).toEqual({ state: "awaiting_review", reviewReason: "error", exitCode: null, pid: null });
expect(result.clearAttentionBuffer).toBe(false);
});

it("process.exit with interrupted true overrides a zero exit code to reviewReason interrupted", () => {
const summary = createSummary({ state: "running" });
const result = reduceSessionTransition(summary, { type: "process.exit", exitCode: 0, interrupted: true });

expect(result.changed).toBe(true);
expect(result.patch).toEqual({ state: "interrupted", reviewReason: "interrupted", exitCode: 0, pid: null });
expect(result.clearAttentionBuffer).toBe(false);
});

it("process.exit with interrupted true overrides a nonzero exit code to reviewReason interrupted", () => {
const summary = createSummary({ state: "running" });
const result = reduceSessionTransition(summary, { type: "process.exit", exitCode: 1, interrupted: true });

expect(result.changed).toBe(true);
expect(result.patch).toEqual({ state: "interrupted", reviewReason: "interrupted", exitCode: 1, pid: null });
expect(result.clearAttentionBuffer).toBe(false);
});

it("is a no-op for an event type outside the known union", () => {
const summary = createSummary({ state: "running" });
const unknownEvent = { type: "unknown.event" } as unknown as SessionTransitionEvent;
const result = reduceSessionTransition(summary, unknownEvent);

expect(result).toEqual({ changed: false, patch: {}, clearAttentionBuffer: false });
});
});