refactor(server): decompose session.ts into per-domain subsystems (#1646)

* refactor(server): move checkout write handlers into CheckoutSession

The checkout read side moved into session/checkout/checkout-session.ts in
#1644; the 17 inline write/PR/stash handlers stayed on the Session god-object.
This carves them into the same subsystem behind an expanded CheckoutSessionHost,
so dispatchCheckoutMessage becomes pure delegation and session.ts drops ~800
lines.

Moved into CheckoutSession (verbatim, only this.X -> this.host.X rewiring):
switch/rename branch, commit, merge, merge-from-base, pull, push, PR
create/merge, github auto-merge/check-details, PR status/timeline, github
search, stash save/pop/list, plus resolveCurrentPullRequest and the
PR-timeline helpers.

CheckoutSessionHost gains the Session-owned, non-checkout collaborators these
handlers orchestrate (notifyGitMutation, emitWorkspaceUpdateForCwd,
handleWorkspaceGitBranchSnapshot, renameCurrentBranch, checkoutExistingBranch,
and the LLM commit/PR-text generators); paseoHome/worktreesRoot join the
options bag. GitMutationRefreshReason moves to utils/checkout-git.ts so both
the shell and the subsystem share one canonical type.

session.test.ts checkout tests now drive the handlers through the public
handleMessage boundary instead of reaching into private methods; new mock-free
fake-host tests cover the moved handlers directly in checkout-session.test.ts.

Behavior-preserving: full session.test.ts + checkout-session.test.ts green,
typecheck/lint/format clean.

* refactor(server): extract chat/schedule/loop handlers into ChatScheduleLoopSession

Move the 7 chat/*, 9 schedule/*, and 5 loop/* request handlers, the three
rpc-error emitters (kept separate), and toScheduleSummary out of the Session
god-object into session/chat/chat-schedule-loop-session.ts, mirroring the shipped
CheckoutSession/VoiceSession deep-module-with-narrow-Host pattern. The least-coupled
remaining domain: stateless request/response over chatService/scheduleService/
loopService, reaching the shell only through a narrow ChatScheduleLoopSessionHost
(emit + agent-roster reads + the mention-fanout send). session.ts drops ~590 lines.

The two dispatch switches collapse into one: schedule/* was previously reached only
via the chat dispatcher's default fall-through arm — now all 21 types are explicit
delegation cases, removing that fragility.

Behavior-preserving: a new routing test drives the real Session.handleMessage for all
21 types (guards against a silently-dropped case), green before and after the move; a
collocated subsystem test covers the mention-fanout send seam, the fanout-limit error
code, the self->agent target remap, and the toScheduleSummary runs-stripping.

* refactor(server): extract provider catalog handlers into ProviderCatalogSession

Move the 8 provider-catalog handlers (model/mode/feature listing, providers
snapshot pull/refresh, diagnostic, usage) plus emitProviderDisabledResponse,
getProviderSnapshotEntryForRead, buildDraftAgentSessionConfig, the two mode-icon
downgrade helpers, and the providers_snapshot_update PUSH wiring out of session.ts
into session/provider/provider-catalog-session.ts behind a narrow
ProviderCatalogSessionHost seam — the same deep-module pattern as ChatScheduleLoopSession,
CheckoutSession, and VoiceSession.

The PUSH (start) and every PULL gate provider visibility and downgrade mode icons
through the SAME injected predicates (isProviderVisibleToClient + supportsCustomModeIcons),
both reading appVersion/clientCapabilities live — the COMPAT invariant the shell could
only enforce by code proximity before. dispatchProviderMessage collapses to delegation;
start()/dispose() wire into subscribeToOptionalManagers/cleanup at the same ordinal.

session.ts drops ~372 lines. Behavior-preservation gate (provider routing through the
real Session.handleMessage) green before and after; full session.test.ts 115/115; new
collocated subsystem test covers the PUSH/PULL parity seam, the disabled-provider path,
and the usage/feature error envelopes.

* refactor(server): extract workspace file-access handlers into WorkspaceFilesSession

Move the file-explorer, file-upload, file-transfer-frame, project-icon and
file-download-token handlers out of session.ts into
session/files/workspace-files-session.ts behind a narrow WorkspaceFilesSessionHost
seam (emit/emitBinary/hasBinaryChannel), matching the deep-module-with-Host shape
used by CheckoutSession/ChatScheduleLoopSession/ProviderCatalogSession.

The new module owns the FileUploadStore and is injected the daemon's
DownloadTokenStore; the shell's dispatch cases and the file_transfer binary route
collapse to delegation. The five handlers touch no workspace-git observer,
registry or subscription state, so this is a clean self-contained slice of the
workspace domain. session.ts drops 240 lines (7316 -> 7076).

Behavior-preservation tests (file explorer list/read/binary, download token
success + empty-cwd, project icon, upload round-trip) added to session.test.ts and
run green against the pre- and post-refactor Session; a collocated
workspace-files-session.test.ts exercises the module via a fake host with real
upload/download stores (no module mocks).

* refactor(server): extract agent-config setters into AgentConfigSession

Move the four agent-config setters (set_agent_mode/model/feature/thinking)
out of the session god-object into session/agent-config/, collapsing their
four near-identical try/log/emit envelopes into one applyConfigChange helper
behind a narrow { emit } host plus an AgentConfigOperations port.

Behavior-preserving: the dispatch cases delegate unchanged, setAgentModeCommand
still routes the mode path, and model/feature still emit no notice. Adds the
previously-absent failure-envelope coverage: 8 gate tests in session.test.ts
(success + forced failure per setter, asserting the *_response envelope and the
activity_log-before-response ordering) green before and after the carve, plus a
collocated agent-config-session.test.ts (typed fake operations + fake host, no
module mocks). session.ts drops ~173 lines.

* refactor(server): extract project-config handlers into ProjectConfigSession

Move read/write_project_config handlers, their failure emitters, and the
root-resolution helpers (resolveKnownProjectRootForConfig, canonicalizeConfigRoot,
stripTrailingPathSeparators) out of session.ts into session/project-config/, behind
a narrow { emit } host plus a projectRegistry port — matching the established
session/<domain>/ pattern. Also corrects a misfiling: project-config was dispatched
inside dispatchAgentConfigMessage despite being its own domain.

session.ts drops 155 lines (6903 -> 6748). The handleMessage-driven gate in
session.test.ts (project config RPC authorization) is green before and after; new
collocated project-config-session.test.ts covers the subsystem with a fake host and
fake registry over a real filesystem (no module mocks).

* refactor(server): extract daemon status/pairing handlers into DaemonSession

Move the two daemon.* RPC handlers (daemon.get_status, daemon.get_pairing_offer)
out of session.ts into session/daemon/daemon-session.ts behind a narrow
{ emit } host + injected reads (paseoHome, serverId, daemonVersion,
daemonRuntimeConfig, listProviderAvailability). These were misfiled inside
dispatchAgentConfigMessage despite being daemon- not agent-scoped; the slice
also removes the now-dead serverId/daemonVersion/daemonRuntimeConfig fields and
the getPidLockInfo/generateLocalPairingOffer imports from the god-file, and
collapses the inline daemonRuntimeConfig type into a named DaemonRuntimeConfig.

The daemon-config get/set handlers stay inline: daemonConfigStore is a
listener-bearing, cross-domain hub (read by structured-generation,
websocket-server, bootstrap, archive-if-safe), so moving it would double-own it.

session.ts: 6748 -> 6662 lines. Gate: new session.test.ts daemon round-trips
(status success + listing-rejects fallback, pairing relay-disabled) pass before
and after; new collocated daemon-session.test.ts (fake host + injected reads
over a real temp dir, no module mocks) 4/4. typecheck/lint/format clean.
This commit is contained in:
Mohamed Boudra
2026-06-22 10:44:07 +08:00
committed by GitHub
parent 2b5cc727f0
commit 26b2f25050
17 changed files with 5300 additions and 2591 deletions
+716 -29
View File
@@ -9,13 +9,17 @@ import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
decodeFileTransferFrame,
encodeFileTransferFrame,
FileTransferOpcode,
type FileTransferFrame,
} from "@getpaseo/protocol/binary-frames/index";
import { Session } from "./session.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import type { SessionOptions } from "./session.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
import {
asSessionInternals as asSessionInternalsHelper,
asAgentManager,
@@ -212,6 +216,10 @@ interface SessionForTestOptions {
stt?: SessionOptions["stt"];
voice?: SessionOptions["voice"];
paseoHome?: string;
serverId?: SessionOptions["serverId"];
daemonVersion?: SessionOptions["daemonVersion"];
daemonRuntimeConfig?: SessionOptions["daemonRuntimeConfig"];
downloadTokenStore?: SessionOptions["downloadTokenStore"];
messages?: unknown[];
binaryMessages?: Uint8Array[];
}
@@ -246,7 +254,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
onMessage: (message) => messages.push(message),
onBinaryMessage: createBinaryMessageHandler(options.binaryMessages),
logger,
downloadTokenStore: asDownloadTokenStore(),
downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
paseoHome: options.paseoHome ?? "/tmp/paseo-home",
agentManager: asAgentManager({
@@ -294,6 +302,9 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
getDaemonTcpPort: options.getDaemonTcpPort,
getDaemonTcpHost: options.getDaemonTcpHost,
voice: options.voice,
serverId: options.serverId,
daemonVersion: options.daemonVersion,
daemonRuntimeConfig: options.daemonRuntimeConfig,
});
}
@@ -394,6 +405,200 @@ describe("file explorer binary responses", () => {
});
});
describe("workspace file access (behavior preservation)", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeDir(prefix: string): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
tempDirs.push(dir);
return dir;
}
function uploadFrame(args: Parameters<typeof encodeFileTransferFrame>[0]): FileTransferFrame {
const frame = decodeFileTransferFrame(encodeFileTransferFrame(args));
if (!frame) {
throw new Error("Expected a file transfer frame");
}
return frame;
}
test("file_explorer list returns directory entries", async () => {
const cwd = makeDir("file-access-list-");
writeFileSync(join(cwd, "a.txt"), "alpha");
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({ messages });
await session.handleMessage({
type: "file_explorer_request",
cwd,
path: ".",
mode: "list",
requestId: "req-list",
});
expect(messages).toHaveLength(1);
const message = messages[0];
if (message.type !== "file_explorer_response") {
throw new Error(`expected file_explorer_response, got ${message.type}`);
}
expect(message.payload.error).toBeNull();
expect(message.payload.directory).not.toBeNull();
});
test("file_explorer rejects an empty cwd with an error envelope", async () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({ messages });
await session.handleMessage({
type: "file_explorer_request",
cwd: " ",
path: ".",
mode: "list",
requestId: "req-empty",
});
expect(messages).toEqual([
{
type: "file_explorer_response",
payload: expect.objectContaining({
error: "cwd is required",
directory: null,
file: null,
requestId: "req-empty",
}),
},
]);
});
test("file_download_token issues a token for a real file", async () => {
const cwd = makeDir("file-access-token-");
writeFileSync(join(cwd, "report.txt"), "hello world");
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
downloadTokenStore: new DownloadTokenStore({ ttlMs: 60_000 }),
});
await session.handleMessage({
type: "file_download_token_request",
cwd,
path: "report.txt",
requestId: "req-token",
});
expect(messages).toHaveLength(1);
const message = messages[0];
if (message.type !== "file_download_token_response") {
throw new Error(`expected file_download_token_response, got ${message.type}`);
}
expect(message.payload.error).toBeNull();
expect(typeof message.payload.token).toBe("string");
expect(message.payload.fileName).toBe("report.txt");
expect(message.payload.size).toBe(11);
});
test("file_download_token rejects an empty cwd with an error envelope", async () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({ messages });
await session.handleMessage({
type: "file_download_token_request",
cwd: "",
path: "report.txt",
requestId: "req-token-empty",
});
expect(messages).toEqual([
{
type: "file_download_token_response",
payload: expect.objectContaining({
token: null,
error: "cwd is required",
requestId: "req-token-empty",
}),
},
]);
});
test("project_icon responds for a workspace cwd", async () => {
const cwd = makeDir("file-access-icon-");
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({ messages });
await session.handleMessage({
type: "project_icon_request",
cwd,
requestId: "req-icon",
});
expect(messages).toHaveLength(1);
const message = messages[0];
if (message.type !== "project_icon_response") {
throw new Error(`expected project_icon_response, got ${message.type}`);
}
expect(message.payload.cwd).toBe(cwd);
expect(message.payload.error).toBeNull();
});
test("file upload round-trips bytes through binary frames", async () => {
const paseoHome = makeDir("file-access-upload-");
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({ messages, paseoHome });
await session.handleMessage({
type: "file.upload.request",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-upload",
});
await session.handleBinaryFrame({
kind: "file_transfer",
frame: uploadFrame({
opcode: FileTransferOpcode.FileBegin,
requestId: "req-upload",
metadata: {
mime: "text/plain",
size: 11,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
fileName: "notes.txt",
},
}),
});
await session.handleBinaryFrame({
kind: "file_transfer",
frame: uploadFrame({
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode("hello world"),
}),
});
await session.handleBinaryFrame({
kind: "file_transfer",
frame: uploadFrame({
opcode: FileTransferOpcode.FileEnd,
requestId: "req-upload",
}),
});
const response = messages.find((message) => message.type === "file.upload.response");
if (response?.type !== "file.upload.response") {
throw new Error("expected a file.upload.response message");
}
expect(response.payload.error).toBeNull();
expect(response.payload.file?.fileName).toBe("notes.txt");
expect(response.payload.file?.size).toBe(11);
});
});
function createStoredAgentRecord(
overrides: Pick<StoredAgentRecord, "id" | "cwd"> & Partial<StoredAgentRecord>,
): StoredAgentRecord {
@@ -774,6 +979,132 @@ describe("project config RPC authorization", () => {
});
});
describe("daemon status + pairing RPC", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeHome(): string {
const home = realpathSync(mkdtempSync(join(tmpdir(), "daemon-session-test-")));
tempDirs.push(home);
return home;
}
test("daemon.get_status.request reports identity, runtime config, and mapped providers", async () => {
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
paseoHome: makeHome(),
serverId: "srv-test",
daemonVersion: "9.9.9",
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
agentManager: {
listProviderAvailability: vi.fn().mockResolvedValue([
{ provider: "claude", available: true },
{ provider: "codex", available: false, error: "boom" },
]),
},
});
await session.handleMessage({ type: "daemon.get_status.request", requestId: "status-1" });
expect(messages).toEqual([
{
type: "daemon.get_status.response",
payload: {
requestId: "status-1",
serverId: "srv-test",
version: "9.9.9",
pid: process.pid,
nodePath: process.execPath,
startedAt: null,
listen: "127.0.0.1:6767",
relay: null,
providers: [
{ provider: "claude", available: true, error: null },
{ provider: "codex", available: false, error: "boom" },
],
},
},
]);
});
test("daemon.get_status.request falls back to a null/empty status when provider listing fails", async () => {
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
paseoHome: makeHome(),
serverId: "srv-test",
daemonVersion: "9.9.9",
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
agentManager: {
listProviderAvailability: vi.fn().mockRejectedValue(new Error("provider listing failed")),
},
});
await session.handleMessage({
type: "daemon.get_status.request",
requestId: "status-fallback-1",
});
expect(messages).toEqual([
{
type: "daemon.get_status.response",
payload: {
requestId: "status-fallback-1",
serverId: "srv-test",
version: "9.9.9",
pid: process.pid,
nodePath: process.execPath,
startedAt: null,
listen: null,
relay: null,
providers: [],
},
},
]);
});
test("daemon.get_pairing_offer.request returns an empty offer when relay is disabled", async () => {
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
paseoHome: makeHome(),
daemonRuntimeConfig: {
listen: "127.0.0.1:6767",
relay: {
enabled: false,
endpoint: "relay.paseo.sh:443",
publicEndpoint: "relay.paseo.sh:443",
useTls: true,
publicUseTls: true,
},
},
});
await session.handleMessage({
type: "daemon.get_pairing_offer.request",
requestId: "pairing-1",
});
expect(messages).toEqual([
{
type: "daemon.get_pairing_offer.response",
payload: {
requestId: "pairing-1",
url: "",
qr: null,
relayEnabled: false,
},
},
]);
});
});
function createWorkspaceGitSnapshot(
cwd: string,
overrides?: {
@@ -1063,7 +1394,7 @@ describe("session checkout merge handling", () => {
checkoutGitMocks.mergeToBase.mockResolvedValue("/tmp/base-worktree");
await asSessionInternals(session).handleCheckoutMergeRequest({
await session.handleMessage({
type: "checkout_merge_request",
cwd: "/tmp/request-worktree",
baseRef: "main",
@@ -1111,7 +1442,7 @@ describe("session checkout merge handling", () => {
};
const session = createSessionForTest({ workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutMergeFromBaseRequest({
await session.handleMessage({
type: "checkout_merge_from_base_request",
cwd: "/tmp/request-worktree",
baseRef: "main",
@@ -1149,7 +1480,7 @@ describe("session checkout merge handling", () => {
const session = createSessionForTest({ github, workspaceGitService, messages });
checkoutGitMocks.mergeFromBase.mockResolvedValue(undefined);
await asSessionInternals(session).handleCheckoutMergeFromBaseRequest({
await session.handleMessage({
type: "checkout_merge_from_base_request",
cwd: "/tmp/request-worktree",
baseRef: "main",
@@ -1241,7 +1572,7 @@ diff --git a/file.txt b/file.txt
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
const session = createSessionForTest({ workspaceGitService });
await asSessionInternals(session).handleCheckoutCommitRequest({
await session.handleMessage({
type: "checkout_commit_request",
cwd: join(repoRoot, "nested"),
message: "",
@@ -1262,7 +1593,7 @@ diff --git a/file.txt b/file.txt
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
await asSessionInternals(session).handleCheckoutCommitRequest({
await session.handleMessage({
type: "checkout_commit_request",
cwd: "/tmp/request-worktree",
message: "Ship it",
@@ -1316,7 +1647,7 @@ diff --git a/file.txt b/file.txt
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
const session = createSessionForTest({ workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutCommitRequest({
await session.handleMessage({
type: "checkout_commit_request",
cwd: "/tmp/request-worktree",
message: "",
@@ -1421,7 +1752,7 @@ diff --git a/file.txt b/file.txt
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
const session = createSessionForTest({ workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutCommitRequest({
await session.handleMessage({
type: "checkout_commit_request",
cwd: "/tmp/request-worktree",
message: "",
@@ -1450,7 +1781,7 @@ diff --git a/file.txt b/file.txt
const session = createSessionForTest({ workspaceGitService, messages });
checkoutGitMocks.commitChanges.mockRejectedValue(new Error("nothing to commit"));
await asSessionInternals(session).handleCheckoutCommitRequest({
await session.handleMessage({
type: "checkout_commit_request",
cwd: "/tmp/request-worktree",
message: "Ship it",
@@ -1540,7 +1871,7 @@ diff --git a/file.txt b/file.txt
});
const session = createSessionForTest({ workspaceGitService });
await asSessionInternals(session).handleCheckoutPrCreateRequest({
await session.handleMessage({
type: "checkout_pr_create_request",
cwd: join(repoRoot, "nested"),
baseRef: "main",
@@ -1585,7 +1916,7 @@ diff --git a/file.txt b/file.txt
});
const session = createSessionForTest({ workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrCreateRequest({
await session.handleMessage({
type: "checkout_pr_create_request",
cwd: "/tmp/request-worktree",
baseRef: "main",
@@ -1724,7 +2055,7 @@ diff --git a/file.txt b/file.txt
});
const session = createSessionForTest({ workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrCreateRequest({
await session.handleMessage({
type: "checkout_pr_create_request",
cwd: "/tmp/request-worktree",
baseRef: "main",
@@ -1766,7 +2097,7 @@ diff --git a/file.txt b/file.txt
});
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrCreateRequest({
await session.handleMessage({
type: "checkout_pr_create_request",
cwd: "/tmp/request-worktree",
baseRef: "main",
@@ -1828,7 +2159,7 @@ describe("session checkout pull request merge", () => {
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrMergeRequest({
await session.handleMessage({
type: "checkout_pr_merge_request",
cwd: "/tmp/request-worktree",
mergeMethod: "squash",
@@ -1925,7 +2256,7 @@ describe("session checkout pull request merge", () => {
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrMergeRequest({
await session.handleMessage({
type: "checkout_pr_merge_request",
cwd: "/tmp/request-worktree",
mergeMethod: "squash",
@@ -1978,7 +2309,7 @@ describe("session checkout pull request merge", () => {
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrMergeRequest({
await session.handleMessage({
type: "checkout_pr_merge_request",
cwd: "/tmp/request-worktree",
mergeMethod: "squash",
@@ -2040,7 +2371,7 @@ describe("session checkout pull request merge", () => {
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrMergeRequest({
await session.handleMessage({
type: "checkout_pr_merge_request",
cwd: "/tmp/request-worktree",
mergeMethod: "merge",
@@ -2103,7 +2434,7 @@ describe("session checkout pull request auto-merge", () => {
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
await session.handleMessage({
type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/request-worktree",
enabled: true,
@@ -2169,7 +2500,7 @@ describe("session checkout pull request auto-merge", () => {
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
await session.handleMessage({
type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/request-worktree",
enabled: false,
@@ -2232,7 +2563,7 @@ describe("session checkout pull request auto-merge", () => {
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
await session.handleMessage({
type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/request-worktree",
enabled: true,
@@ -2281,7 +2612,7 @@ describe("session checkout pull request auto-merge", () => {
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
await session.handleMessage({
type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/request-worktree",
enabled: true,
@@ -2337,7 +2668,7 @@ describe("session checkout pull request auto-merge", () => {
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
await session.handleMessage({
type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/request-worktree",
enabled: false,
@@ -2392,7 +2723,7 @@ describe("session checkout pull request auto-merge", () => {
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
await session.handleMessage({
type: "checkout.github.set_auto_merge.request",
cwd: "/tmp/request-worktree",
enabled: false,
@@ -2426,7 +2757,7 @@ describe("session checkout pull and push handling", () => {
const session = createSessionForTest({ github, workspaceGitService, messages });
checkoutGitMocks.pullCurrentBranch.mockResolvedValue(undefined);
await asSessionInternals(session).handleCheckoutPullRequest({
await session.handleMessage({
type: "checkout_pull_request",
cwd: "/tmp/request-worktree",
requestId: "request-pull",
@@ -2456,7 +2787,7 @@ describe("session checkout pull and push handling", () => {
const session = createSessionForTest({ github, workspaceGitService, messages });
checkoutGitMocks.pushCurrentBranch.mockResolvedValue(undefined);
await asSessionInternals(session).handleCheckoutPushRequest({
await session.handleMessage({
type: "checkout_push_request",
cwd: "/tmp/request-worktree",
requestId: "request-push",
@@ -3034,7 +3365,7 @@ describe("session checkout switch branch handling", () => {
const session = createSessionForTest({ github, workspaceGitService, messages });
checkoutGitMocks.checkoutResolvedBranch.mockResolvedValue({ source: "local" });
await asSessionInternals(session).handleCheckoutSwitchBranchRequest({
await session.handleMessage({
type: "checkout_switch_branch_request",
cwd: "/tmp/repo",
branch: "release",
@@ -3323,7 +3654,7 @@ describe("session stash list handling", () => {
};
const session = createSessionForTest({ workspaceGitService, messages });
await asSessionInternals(session).handleStashListRequest({
await session.handleMessage({
type: "stash_list_request",
cwd: "/tmp/repo",
paseoOnly: true,
@@ -3354,7 +3685,7 @@ describe("session stash mutation handling", () => {
truncated: false,
});
await asSessionInternals(session).handleStashSaveRequest({
await session.handleMessage({
type: "stash_save_request",
cwd: "/tmp/repo",
branch: "feature",
@@ -3388,7 +3719,7 @@ describe("session stash mutation handling", () => {
truncated: false,
});
await asSessionInternals(session).handleStashPopRequest({
await session.handleMessage({
type: "stash_pop_request",
cwd: "/tmp/repo",
stashIndex: 0,
@@ -3818,3 +4149,359 @@ describe("session pull request timeline handling", () => {
});
});
});
describe("chat/schedule/loop dispatch routing (behavior preservation)", () => {
// Each chat/*, loop/*, and schedule/* type must reach its domain handler. The
// injected service stubs are unstubbed, so every handler's own try/catch fires
// and emits its domain rpc_error code — proving the message routed (a dropped
// case would silently no-op and emit nothing). schedule/* historically routed
// via the chat dispatcher's fall-through arm; this guards that path explicitly.
// handleMessage receives already-parsed messages, so these fixtures only need to
// satisfy the TS union here — zod parsing happens upstream at the transport.
const routingCases: Array<{ msg: SessionInboundMessage; code: string }> = [
{
msg: { type: "chat/create", requestId: "rt-chat-create", name: "room" },
code: "chat_request_failed",
},
{ msg: { type: "chat/list", requestId: "rt-chat-list" }, code: "chat_request_failed" },
{
msg: { type: "chat/inspect", requestId: "rt-chat-inspect", room: "room" },
code: "chat_request_failed",
},
{
msg: { type: "chat/delete", requestId: "rt-chat-delete", room: "room" },
code: "chat_request_failed",
},
{
msg: { type: "chat/post", requestId: "rt-chat-post", room: "room", body: "hi" },
code: "chat_request_failed",
},
{
msg: { type: "chat/read", requestId: "rt-chat-read", room: "room" },
code: "chat_request_failed",
},
{
msg: { type: "chat/wait", requestId: "rt-chat-wait", room: "room" },
code: "chat_request_failed",
},
{
msg: { type: "loop/run", requestId: "rt-loop-run", prompt: "p", cwd: "/tmp/loop" },
code: "loop_request_failed",
},
{ msg: { type: "loop/list", requestId: "rt-loop-list" }, code: "loop_request_failed" },
{
msg: { type: "loop/inspect", requestId: "rt-loop-inspect", id: "loop-1" },
code: "loop_request_failed",
},
{
msg: { type: "loop/logs", requestId: "rt-loop-logs", id: "loop-1" },
code: "loop_request_failed",
},
{
msg: { type: "loop/stop", requestId: "rt-loop-stop", id: "loop-1" },
code: "loop_request_failed",
},
{
msg: {
type: "schedule/create",
requestId: "rt-sched-create",
prompt: "p",
cadence: { type: "every", everyMs: 1000 },
target: { type: "agent", agentId: "00000000-0000-0000-0000-000000000000" },
},
code: "schedule_request_failed",
},
{ msg: { type: "schedule/list", requestId: "rt-sched-list" }, code: "schedule_request_failed" },
{
msg: { type: "schedule/inspect", requestId: "rt-sched-inspect", scheduleId: "s1" },
code: "schedule_request_failed",
},
{
msg: { type: "schedule/logs", requestId: "rt-sched-logs", scheduleId: "s1" },
code: "schedule_request_failed",
},
{
msg: { type: "schedule/pause", requestId: "rt-sched-pause", scheduleId: "s1" },
code: "schedule_request_failed",
},
{
msg: { type: "schedule/resume", requestId: "rt-sched-resume", scheduleId: "s1" },
code: "schedule_request_failed",
},
{
msg: { type: "schedule/delete", requestId: "rt-sched-delete", scheduleId: "s1" },
code: "schedule_request_failed",
},
{
msg: { type: "schedule/run-once", requestId: "rt-sched-run-once", scheduleId: "s1" },
code: "schedule_request_failed",
},
{
msg: { type: "schedule/update", requestId: "rt-sched-update", scheduleId: "s1", name: "new" },
code: "schedule_request_failed",
},
];
test.each(routingCases)("routes $msg.type to its domain handler", async ({ msg, code }) => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({ messages });
await session.handleMessage(msg);
const routed = messages
.filter(
(m): m is Extract<SessionOutboundMessage, { type: "rpc_error" }> => m.type === "rpc_error",
)
.find((m) => m.payload.requestId === msg.requestId);
expect(routed, `${msg.type} did not route to a handler (silent no-op)`).toBeDefined();
expect(routed?.payload.code).toBe(code);
});
});
describe("agent config setters", () => {
test("set_agent_mode_request: success emits accepted response carrying the notice", async () => {
const messages: SessionOutboundMessage[] = [];
const notice = { type: "info", message: "Switched to plan mode" } as const;
const session = createSessionForTest({
messages,
agentManager: { setAgentMode: vi.fn().mockResolvedValue(notice) },
});
await session.handleMessage({
type: "set_agent_mode_request",
agentId: "agent-1",
modeId: "plan",
requestId: "req-mode-ok",
});
expect(messages).toEqual([
{
type: "set_agent_mode_response",
payload: {
requestId: "req-mode-ok",
agentId: "agent-1",
accepted: true,
error: null,
notice,
},
},
]);
});
test("set_agent_mode_request: failure emits the activity_log error frame before the rejected response", async () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
agentManager: { setAgentMode: vi.fn().mockRejectedValue(new Error("mode boom")) },
});
await session.handleMessage({
type: "set_agent_mode_request",
agentId: "agent-1",
modeId: "plan",
requestId: "req-mode-err",
});
expect(messages.map((m) => m.type)).toEqual(["activity_log", "set_agent_mode_response"]);
expect(messages[0]).toEqual({
type: "activity_log",
payload: {
id: expect.any(String),
timestamp: expect.any(Date),
type: "error",
content: "Failed to set agent mode: mode boom",
},
});
expect(messages[1]).toEqual({
type: "set_agent_mode_response",
payload: {
requestId: "req-mode-err",
agentId: "agent-1",
accepted: false,
error: "mode boom",
},
});
});
test("set_agent_model_request: success emits accepted response with no notice", async () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
agentManager: { setAgentModel: vi.fn().mockResolvedValue(undefined) },
});
await session.handleMessage({
type: "set_agent_model_request",
agentId: "agent-1",
modelId: "claude-opus-4-8",
requestId: "req-model-ok",
});
expect(messages).toEqual([
{
type: "set_agent_model_response",
payload: { requestId: "req-model-ok", agentId: "agent-1", accepted: true, error: null },
},
]);
});
test("set_agent_model_request: failure emits the activity_log error frame before the rejected response", async () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
agentManager: { setAgentModel: vi.fn().mockRejectedValue(new Error("model boom")) },
});
await session.handleMessage({
type: "set_agent_model_request",
agentId: "agent-1",
modelId: "claude-opus-4-8",
requestId: "req-model-err",
});
expect(messages.map((m) => m.type)).toEqual(["activity_log", "set_agent_model_response"]);
expect(messages[0]).toEqual({
type: "activity_log",
payload: {
id: expect.any(String),
timestamp: expect.any(Date),
type: "error",
content: "Failed to set agent model: model boom",
},
});
expect(messages[1]).toEqual({
type: "set_agent_model_response",
payload: {
requestId: "req-model-err",
agentId: "agent-1",
accepted: false,
error: "model boom",
},
});
});
test("set_agent_feature_request: success emits accepted response with no notice", async () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
agentManager: { setAgentFeature: vi.fn().mockResolvedValue(undefined) },
});
await session.handleMessage({
type: "set_agent_feature_request",
agentId: "agent-1",
featureId: "web_search",
value: true,
requestId: "req-feature-ok",
});
expect(messages).toEqual([
{
type: "set_agent_feature_response",
payload: { requestId: "req-feature-ok", agentId: "agent-1", accepted: true, error: null },
},
]);
});
test("set_agent_feature_request: failure emits the activity_log error frame before the rejected response", async () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
agentManager: { setAgentFeature: vi.fn().mockRejectedValue(new Error("feature boom")) },
});
await session.handleMessage({
type: "set_agent_feature_request",
agentId: "agent-1",
featureId: "web_search",
value: true,
requestId: "req-feature-err",
});
expect(messages.map((m) => m.type)).toEqual(["activity_log", "set_agent_feature_response"]);
expect(messages[0]).toEqual({
type: "activity_log",
payload: {
id: expect.any(String),
timestamp: expect.any(Date),
type: "error",
content: "Failed to set agent feature: feature boom",
},
});
expect(messages[1]).toEqual({
type: "set_agent_feature_response",
payload: {
requestId: "req-feature-err",
agentId: "agent-1",
accepted: false,
error: "feature boom",
},
});
});
test("set_agent_thinking_request: success emits accepted response carrying the notice", async () => {
const messages: SessionOutboundMessage[] = [];
const notice = { type: "warning", message: "Thinking budget reduced" } as const;
const session = createSessionForTest({
messages,
agentManager: { setAgentThinkingOption: vi.fn().mockResolvedValue(notice) },
});
await session.handleMessage({
type: "set_agent_thinking_request",
agentId: "agent-1",
thinkingOptionId: "high",
requestId: "req-thinking-ok",
});
expect(messages).toEqual([
{
type: "set_agent_thinking_response",
payload: {
requestId: "req-thinking-ok",
agentId: "agent-1",
accepted: true,
error: null,
notice,
},
},
]);
});
test("set_agent_thinking_request: failure emits the activity_log error frame before the rejected response", async () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
agentManager: {
setAgentThinkingOption: vi.fn().mockRejectedValue(new Error("thinking boom")),
},
});
await session.handleMessage({
type: "set_agent_thinking_request",
agentId: "agent-1",
thinkingOptionId: "high",
requestId: "req-thinking-err",
});
expect(messages.map((m) => m.type)).toEqual(["activity_log", "set_agent_thinking_response"]);
expect(messages[0]).toEqual({
type: "activity_log",
payload: {
id: expect.any(String),
timestamp: expect.any(Date),
type: "error",
content: "Failed to set agent thinking option: thinking boom",
},
});
expect(messages[1]).toEqual({
type: "set_agent_thinking_response",
payload: {
requestId: "req-thinking-err",
agentId: "agent-1",
accepted: false,
error: "thinking boom",
},
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,260 @@
import { describe, expect, test } from "vitest";
import pino from "pino";
import {
AgentConfigSession,
type AgentConfigOperations,
type AgentConfigSessionHost,
} from "./agent-config-session.js";
import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js";
import type { SessionOutboundMessage } from "../../messages.js";
class FakeAgentConfigOperations implements AgentConfigOperations {
readonly modeCalls: Array<{ agentId: string; modeId: string }> = [];
readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = [];
readonly featureCalls: Array<{ agentId: string; featureId: string; value: unknown }> = [];
readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = [];
modeNotice: AgentProviderNotice | null = null;
thinkingNotice: AgentProviderNotice | null = null;
failWith: Error | null = null;
async setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
this.modeCalls.push({ agentId, modeId });
if (this.failWith) throw this.failWith;
return this.modeNotice;
}
async setModel(agentId: string, modelId: string | null): Promise<void> {
this.modelCalls.push({ agentId, modelId });
if (this.failWith) throw this.failWith;
}
async setFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
this.featureCalls.push({ agentId, featureId, value });
if (this.failWith) throw this.failWith;
}
async setThinking(
agentId: string,
thinkingOptionId: string | null,
): Promise<AgentProviderNotice | null> {
this.thinkingCalls.push({ agentId, thinkingOptionId });
if (this.failWith) throw this.failWith;
return this.thinkingNotice;
}
}
function makeSubsystem() {
const emitted: SessionOutboundMessage[] = [];
const operations = new FakeAgentConfigOperations();
const host: AgentConfigSessionHost = { emit: (msg) => emitted.push(msg) };
const subsystem = new AgentConfigSession({
host,
operations,
logger: pino({ level: "silent" }),
});
return { subsystem, emitted, operations };
}
describe("AgentConfigSession", () => {
test("set mode: forwards the args and emits an accepted response carrying the notice", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
operations.modeNotice = { type: "info", message: "Switched to plan mode" };
await subsystem.handleSetAgentModeRequest({
type: "set_agent_mode_request",
agentId: "agent-1",
modeId: "plan",
requestId: "req-1",
});
expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
expect(emitted).toEqual([
{
type: "set_agent_mode_response",
payload: {
requestId: "req-1",
agentId: "agent-1",
accepted: true,
error: null,
notice: { type: "info", message: "Switched to plan mode" },
},
},
]);
});
test("set mode: a failed mutation emits the activity_log error frame before the rejected response", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
operations.failWith = new Error("mode boom");
await subsystem.handleSetAgentModeRequest({
type: "set_agent_mode_request",
agentId: "agent-1",
modeId: "plan",
requestId: "req-1",
});
expect(emitted.map((m) => m.type)).toEqual(["activity_log", "set_agent_mode_response"]);
expect(emitted[0]).toEqual({
type: "activity_log",
payload: {
id: expect.any(String),
timestamp: expect.any(Date),
type: "error",
content: "Failed to set agent mode: mode boom",
},
});
expect(emitted[1]).toEqual({
type: "set_agent_mode_response",
payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "mode boom" },
});
});
test("set model: emits an accepted response with no notice", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
await subsystem.handleSetAgentModelRequest({
type: "set_agent_model_request",
agentId: "agent-1",
modelId: "claude-opus-4-8",
requestId: "req-1",
});
expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: "claude-opus-4-8" }]);
expect(emitted).toEqual([
{
type: "set_agent_model_response",
payload: { requestId: "req-1", agentId: "agent-1", accepted: true, error: null },
},
]);
});
test("set model: a failed mutation reports the model-specific failure text", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
operations.failWith = new Error("model boom");
await subsystem.handleSetAgentModelRequest({
type: "set_agent_model_request",
agentId: "agent-1",
modelId: "claude-opus-4-8",
requestId: "req-1",
});
expect(emitted.map((m) => m.type)).toEqual(["activity_log", "set_agent_model_response"]);
expect(emitted[0]).toEqual({
type: "activity_log",
payload: {
id: expect.any(String),
timestamp: expect.any(Date),
type: "error",
content: "Failed to set agent model: model boom",
},
});
expect(emitted[1]).toEqual({
type: "set_agent_model_response",
payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "model boom" },
});
});
test("set feature: forwards the feature value and emits an accepted response with no notice", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
await subsystem.handleSetAgentFeatureRequest({
type: "set_agent_feature_request",
agentId: "agent-1",
featureId: "web_search",
value: true,
requestId: "req-1",
});
expect(operations.featureCalls).toEqual([
{ agentId: "agent-1", featureId: "web_search", value: true },
]);
expect(emitted).toEqual([
{
type: "set_agent_feature_response",
payload: { requestId: "req-1", agentId: "agent-1", accepted: true, error: null },
},
]);
});
test("set feature: a failed mutation reports the feature-specific failure text", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
operations.failWith = new Error("feature boom");
await subsystem.handleSetAgentFeatureRequest({
type: "set_agent_feature_request",
agentId: "agent-1",
featureId: "web_search",
value: true,
requestId: "req-1",
});
expect(emitted.map((m) => m.type)).toEqual(["activity_log", "set_agent_feature_response"]);
expect(emitted[0]).toEqual({
type: "activity_log",
payload: {
id: expect.any(String),
timestamp: expect.any(Date),
type: "error",
content: "Failed to set agent feature: feature boom",
},
});
expect(emitted[1]).toEqual({
type: "set_agent_feature_response",
payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "feature boom" },
});
});
test("set thinking: forwards the args and emits an accepted response carrying the notice", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
operations.thinkingNotice = { type: "warning", message: "Thinking budget reduced" };
await subsystem.handleSetAgentThinkingRequest({
type: "set_agent_thinking_request",
agentId: "agent-1",
thinkingOptionId: "high",
requestId: "req-1",
});
expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
expect(emitted).toEqual([
{
type: "set_agent_thinking_response",
payload: {
requestId: "req-1",
agentId: "agent-1",
accepted: true,
error: null,
notice: { type: "warning", message: "Thinking budget reduced" },
},
},
]);
});
test("set thinking: a failed mutation reports the thinking-specific failure text", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
operations.failWith = new Error("thinking boom");
await subsystem.handleSetAgentThinkingRequest({
type: "set_agent_thinking_request",
agentId: "agent-1",
thinkingOptionId: "high",
requestId: "req-1",
});
expect(emitted.map((m) => m.type)).toEqual(["activity_log", "set_agent_thinking_response"]);
expect(emitted[0]).toEqual({
type: "activity_log",
payload: {
id: expect.any(String),
timestamp: expect.any(Date),
type: "error",
content: "Failed to set agent thinking option: thinking boom",
},
});
expect(emitted[1]).toEqual({
type: "set_agent_thinking_response",
payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "thinking boom" },
});
});
});
@@ -0,0 +1,163 @@
import type pino from "pino";
import { v4 as uuidv4 } from "uuid";
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
/**
* The four agent-config response messages share one payload shape; deriving the
* type keeps it pinned to the protocol schema instead of restating it.
*/
type AgentActionResponsePayload = Extract<
SessionOutboundMessage,
{ type: "set_agent_mode_response" }
>["payload"];
export interface AgentConfigSessionHost {
emit(msg: SessionOutboundMessage): void;
}
/**
* The per-agent config mutations this subsystem drives. The shell adapts these
* onto the live AgentManager (mode still routes through setAgentModeCommand);
* tests wire an in-memory fake. Mode and thinking yield a provider notice; model
* and feature do not.
*/
export interface AgentConfigOperations {
setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>;
setModel(agentId: string, modelId: string | null): Promise<void>;
setFeature(agentId: string, featureId: string, value: unknown): Promise<void>;
setThinking(
agentId: string,
thinkingOptionId: string | null,
): Promise<AgentProviderNotice | null>;
}
export interface AgentConfigSessionOptions {
host: AgentConfigSessionHost;
operations: AgentConfigOperations;
logger: pino.Logger;
}
interface ConfigChange {
agentId: string;
requestId: string;
logLabel: string;
logFields: Record<string, unknown>;
failureText: string;
run: () => Promise<AgentProviderNotice | null | undefined>;
emitResponse: (payload: AgentActionResponsePayload) => void;
}
/**
* A client's per-agent config surface: set mode, model, feature, and thinking
* option. Each request shares one envelope — log, run the mutation, then emit the
* accepted response, or on failure emit an activity_log error frame followed by
* the rejected response. Reaches no state beyond the injected operations and the
* outbound channel.
*/
export class AgentConfigSession {
private readonly host: AgentConfigSessionHost;
private readonly operations: AgentConfigOperations;
private readonly logger: pino.Logger;
constructor(options: AgentConfigSessionOptions) {
this.host = options.host;
this.operations = options.operations;
this.logger = options.logger;
}
handleSetAgentModeRequest(
msg: Extract<SessionInboundMessage, { type: "set_agent_mode_request" }>,
): Promise<void> {
const { agentId, modeId, requestId } = msg;
return this.applyConfigChange({
agentId,
requestId,
logLabel: "set_agent_mode_request",
logFields: { agentId, modeId, requestId },
failureText: "Failed to set agent mode",
run: () => this.operations.setMode(agentId, modeId),
emitResponse: (payload) => this.host.emit({ type: "set_agent_mode_response", payload }),
});
}
handleSetAgentModelRequest(
msg: Extract<SessionInboundMessage, { type: "set_agent_model_request" }>,
): Promise<void> {
const { agentId, modelId, requestId } = msg;
return this.applyConfigChange({
agentId,
requestId,
logLabel: "set_agent_model_request",
logFields: { agentId, modelId, requestId },
failureText: "Failed to set agent model",
run: async () => {
await this.operations.setModel(agentId, modelId);
return undefined;
},
emitResponse: (payload) => this.host.emit({ type: "set_agent_model_response", payload }),
});
}
handleSetAgentFeatureRequest(
msg: Extract<SessionInboundMessage, { type: "set_agent_feature_request" }>,
): Promise<void> {
const { agentId, featureId, value, requestId } = msg;
return this.applyConfigChange({
agentId,
requestId,
logLabel: "set_agent_feature_request",
logFields: { agentId, featureId, value, requestId },
failureText: "Failed to set agent feature",
run: async () => {
await this.operations.setFeature(agentId, featureId, value);
return undefined;
},
emitResponse: (payload) => this.host.emit({ type: "set_agent_feature_response", payload }),
});
}
handleSetAgentThinkingRequest(
msg: Extract<SessionInboundMessage, { type: "set_agent_thinking_request" }>,
): Promise<void> {
const { agentId, thinkingOptionId, requestId } = msg;
return this.applyConfigChange({
agentId,
requestId,
logLabel: "set_agent_thinking_request",
logFields: { agentId, thinkingOptionId, requestId },
failureText: "Failed to set agent thinking option",
run: () => this.operations.setThinking(agentId, thinkingOptionId),
emitResponse: (payload) => this.host.emit({ type: "set_agent_thinking_response", payload }),
});
}
private async applyConfigChange(change: ConfigChange): Promise<void> {
const { agentId, requestId, logLabel, logFields, failureText, run, emitResponse } = change;
this.logger.info(logFields, `session: ${logLabel}`);
try {
const notice = await run();
this.logger.info(logFields, `session: ${logLabel} success`);
emitResponse({ requestId, agentId, accepted: true, error: null, notice });
} catch (error) {
this.logger.error({ err: error, ...logFields }, `session: ${logLabel} error`);
this.host.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `${failureText}: ${getErrorMessage(error)}`,
},
});
emitResponse({
requestId,
agentId,
accepted: false,
error: getErrorMessageOr(error, failureText),
});
}
}
}
@@ -0,0 +1,228 @@
import { describe, expect, it } from "vitest";
import pino from "pino";
import {
ChatScheduleLoopSession,
type ChatScheduleLoopSessionHost,
} from "./chat-schedule-loop-session.js";
import { createStub } from "../../test-utils/class-mocks.js";
import { findByType } from "../../test-utils/session-stubs.js";
import type { SessionOutboundMessage } from "../../messages.js";
import type { FileBackedChatService } from "../../chat/chat-service.js";
import type { ScheduleService } from "../../schedule/service.js";
import type { LoopService } from "../../loop-service.js";
type ChatMessageFixture = Awaited<ReturnType<FileBackedChatService["dispatchMessage"]>>;
interface MakeOptions {
chat?: { [K in keyof FileBackedChatService]?: unknown };
schedule?: { [K in keyof ScheduleService]?: unknown };
loop?: { [K in keyof LoopService]?: unknown };
host?: Partial<ChatScheduleLoopSessionHost>;
}
function makeSubsystem(options: MakeOptions = {}) {
const emitted: SessionOutboundMessage[] = [];
const sentAgentMessages: Array<{ agentId: string; text: string }> = [];
let onSend: (() => void) | null = null;
const host: ChatScheduleLoopSessionHost = {
emit: (msg) => emitted.push(msg),
listStoredAgents: async () => [],
listLiveAgents: () => [],
resolveAgentIdentifier: async (identifier) => ({ ok: true, agentId: identifier }),
sendAgentMessage: async (agentId, text) => {
sentAgentMessages.push({ agentId, text });
onSend?.();
},
...options.host,
};
const subsystem = new ChatScheduleLoopSession({
host,
chatService: createStub<FileBackedChatService>(options.chat ?? {}),
scheduleService: createStub<ScheduleService>(options.schedule ?? {}),
loopService: createStub<LoopService>(options.loop ?? {}),
clientId: "client-1",
logger: pino({ level: "silent" }),
});
// notifyChatMentions is fire-and-forget; arm the signal before dispatching so the
// mentioned-agent send is observed deterministically without polling.
function waitForSend(): Promise<void> {
return new Promise((resolve) => {
onSend = resolve;
});
}
return { subsystem, emitted, sentAgentMessages, waitForSend };
}
describe("ChatScheduleLoopSession", () => {
it("chat/post emits the stored message and does not fan out without mentions", async () => {
const message: ChatMessageFixture = {
id: "m1",
roomId: "r1",
authorAgentId: "client-1",
body: "hello",
replyToMessageId: null,
mentionAgentIds: [],
createdAt: "2026-01-01T00:00:00.000Z",
};
const { subsystem, emitted, sentAgentMessages } = makeSubsystem({
chat: { dispatchMessage: async () => message, listRoomPosterAgentIds: async () => [] },
});
await subsystem.handleChatPostRequest({
type: "chat/post",
requestId: "p1",
room: "r1",
body: "hello",
});
const res = findByType(emitted, "chat/post/response");
expect(res?.payload.message).toEqual(message);
expect(res?.payload.error).toBeNull();
expect(sentAgentMessages).toEqual([]);
});
it("chat/post notifies a mentioned agent through the host send seam", async () => {
const message: ChatMessageFixture = {
id: "m2",
roomId: "r1",
authorAgentId: "client-1",
body: "@agent-2 ping",
replyToMessageId: null,
mentionAgentIds: ["agent-2"],
createdAt: "2026-01-01T00:00:00.000Z",
};
const { subsystem, emitted, sentAgentMessages, waitForSend } = makeSubsystem({
chat: { dispatchMessage: async () => message, listRoomPosterAgentIds: async () => [] },
});
const sent = waitForSend();
await subsystem.handleChatPostRequest({
type: "chat/post",
requestId: "p2",
room: "r1",
body: "@agent-2 ping",
});
await sent;
expect(findByType(emitted, "chat/post/response")?.payload.error).toBeNull();
expect(sentAgentMessages).toHaveLength(1);
expect(sentAgentMessages[0]?.agentId).toBe("agent-2");
expect(sentAgentMessages[0]?.text).toContain('in room "r1"');
});
it("chat/post rejects @everyone past the fanout limit with the chat error code", async () => {
const posters = Array.from({ length: 26 }, (_, i) => `poster-${i}`);
const { subsystem, emitted } = makeSubsystem({
chat: { listRoomPosterAgentIds: async () => posters },
});
await subsystem.handleChatPostRequest({
type: "chat/post",
requestId: "p3",
room: "r1",
body: "@everyone go",
});
const err = findByType(emitted, "rpc_error");
expect(err?.payload.code).toBe("chat_mention_fanout_limit_exceeded");
expect(err?.payload.requestId).toBe("p3");
});
it("schedule/create returns a summary with the runs stripped", async () => {
const stored = {
id: "s1",
name: null,
prompt: "p",
cadence: { type: "every" as const, everyMs: 1000 },
target: { type: "agent" as const, agentId: "a" },
status: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
nextRunAt: null,
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
runs: [
{
id: "run-1",
scheduledFor: "2026-01-01T00:00:00.000Z",
startedAt: "2026-01-01T00:00:00.000Z",
endedAt: null,
status: "running" as const,
agentId: null,
output: null,
error: null,
},
],
};
const { subsystem, emitted } = makeSubsystem({ schedule: { create: async () => stored } });
await subsystem.handleScheduleCreateRequest({
type: "schedule/create",
requestId: "sc1",
prompt: "p",
cadence: { type: "every", everyMs: 1000 },
target: { type: "agent", agentId: "a" },
});
const res = findByType(emitted, "schedule/create/response");
expect(res?.payload.schedule).toBeDefined();
expect(res?.payload.schedule).not.toHaveProperty("runs");
expect(res?.payload.schedule.id).toBe("s1");
});
it("schedule/create remaps a self target to an agent target before creating", async () => {
let received: Parameters<ScheduleService["create"]>[0] | undefined;
const stored = {
id: "s2",
name: null,
prompt: "p",
cadence: { type: "every" as const, everyMs: 1000 },
target: { type: "agent" as const, agentId: "agent-9" },
status: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
nextRunAt: null,
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
runs: [],
};
const { subsystem, emitted } = makeSubsystem({
schedule: {
create: async (input: Parameters<ScheduleService["create"]>[0]) => {
received = input;
return stored;
},
},
});
await subsystem.handleScheduleCreateRequest({
type: "schedule/create",
requestId: "sc2",
prompt: "p",
cadence: { type: "every", everyMs: 1000 },
target: { type: "self", agentId: "agent-9" },
});
expect(received?.target).toEqual({ type: "agent", agentId: "agent-9" });
expect(findByType(emitted, "schedule/create/response")?.payload.error).toBeNull();
});
it("loop/run emits the loop summary from the loop service", async () => {
const loop = { id: "loop-1", status: "running" };
const { subsystem, emitted } = makeSubsystem({ loop: { runLoop: async () => loop } });
await subsystem.handleLoopRunRequest({
type: "loop/run",
requestId: "l1",
prompt: "p",
cwd: "/tmp/loop",
});
const res = findByType(emitted, "loop/run/response");
expect(res?.payload.loop).toEqual(loop);
});
});
@@ -0,0 +1,609 @@
import type pino from "pino";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import type { StoredAgentRecord } from "../../agent/agent-storage.js";
import type { ManagedAgent } from "../../agent/agent-manager.js";
import {
ChatServiceError,
type FileBackedChatService,
parseMentionAgentIds,
} from "../../chat/chat-service.js";
import { notifyChatMentions, prepareChatMentionFanout } from "../../chat/chat-mentions.js";
import type { LoopService } from "../../loop-service.js";
import type { ScheduleService } from "../../schedule/service.js";
/**
* The collaborators a chat command reaches that are NOT part of the chat/schedule/loop
* domain: the agent roster reads and the agent-message send used only by chat/post
* mention fanout. The Session shell owns the agent lifecycle; this subsystem orchestrates
* a notification through it but does not own it.
*/
export interface ChatScheduleLoopSessionHost {
emit(msg: SessionOutboundMessage): void;
listStoredAgents(): Promise<StoredAgentRecord[]>;
listLiveAgents(): ManagedAgent[];
resolveAgentIdentifier(
identifier: string,
): Promise<{ ok: true; agentId: string } | { ok: false; error: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
}
export interface ChatScheduleLoopSessionOptions {
host: ChatScheduleLoopSessionHost;
chatService: FileBackedChatService;
scheduleService: ScheduleService;
loopService: LoopService;
clientId: string;
logger: pino.Logger;
}
/**
* A client's chat, schedule, and loop request surface. The three families are the
* least-coupled in the session: each is a stateless request/response over its own
* service (chat rooms, cron routines, autonomous loops), with no shared observer,
* git, or voice state and no subscriptions to tear down. They live in one subsystem
* because they are dispatched together — schedule/* was historically reached through
* the chat dispatcher's fall-through arm. The three rpc-error emitters stay separate:
* they differ by default code, and only the chat one reads ChatServiceError.code.
*/
export class ChatScheduleLoopSession {
private readonly host: ChatScheduleLoopSessionHost;
private readonly chatService: FileBackedChatService;
private readonly scheduleService: ScheduleService;
private readonly loopService: LoopService;
private readonly clientId: string;
private readonly logger: pino.Logger;
constructor(options: ChatScheduleLoopSessionOptions) {
this.host = options.host;
this.chatService = options.chatService;
this.scheduleService = options.scheduleService;
this.loopService = options.loopService;
this.clientId = options.clientId;
this.logger = options.logger;
}
private emitChatRpcError(request: { requestId: string; type: string }, error: unknown): void {
const message = error instanceof Error ? error.message : "Chat request failed";
const code = error instanceof ChatServiceError ? error.code : "chat_request_failed";
this.logger.error({ err: error, requestType: request.type }, "Chat request failed");
this.host.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code,
},
});
}
async handleChatCreateRequest(
request: Extract<SessionInboundMessage, { type: "chat/create" }>,
): Promise<void> {
try {
const room = await this.chatService.createRoom({
name: request.name,
purpose: request.purpose,
});
this.host.emit({
type: "chat/create/response",
payload: {
requestId: request.requestId,
room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatListRequest(
request: Extract<SessionInboundMessage, { type: "chat/list" }>,
): Promise<void> {
try {
const rooms = await this.chatService.listRooms();
this.host.emit({
type: "chat/list/response",
payload: {
requestId: request.requestId,
rooms,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatInspectRequest(
request: Extract<SessionInboundMessage, { type: "chat/inspect" }>,
): Promise<void> {
try {
const result = await this.chatService.inspectRoom({
room: request.room,
});
this.host.emit({
type: "chat/inspect/response",
payload: {
requestId: request.requestId,
room: result.room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatDeleteRequest(
request: Extract<SessionInboundMessage, { type: "chat/delete" }>,
): Promise<void> {
try {
const result = await this.chatService.deleteRoom({
room: request.room,
});
this.host.emit({
type: "chat/delete/response",
payload: {
requestId: request.requestId,
room: result.room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatPostRequest(
request: Extract<SessionInboundMessage, { type: "chat/post" }>,
): Promise<void> {
try {
const authorAgentId = request.authorAgentId?.trim() || this.clientId;
const mentionAgentIds = parseMentionAgentIds(request.body);
const storedAgents = await this.host.listStoredAgents();
const liveAgents = this.host.listLiveAgents();
const fanout = await prepareChatMentionFanout({
authorAgentId,
mentionAgentIds,
storedAgents,
liveAgents,
listRoomPosterAgentIds: () =>
this.chatService.listRoomPosterAgentIds({ room: request.room }),
});
if (!fanout.ok) {
throw new ChatServiceError("chat_mention_fanout_limit_exceeded", fanout.error);
}
const message = await this.chatService.dispatchMessage({
room: request.room,
authorAgentId,
body: request.body,
replyToMessageId: request.replyToMessageId,
});
this.host.emit({
type: "chat/post/response",
payload: {
requestId: request.requestId,
message,
error: null,
},
});
void notifyChatMentions({
room: request.room,
authorAgentId,
body: request.body,
mentionAgentIds: message.mentionAgentIds,
logger: this.logger,
storedAgents,
liveAgents,
prepared: fanout.prepared,
resolveAgentIdentifier: (identifier) => this.host.resolveAgentIdentifier(identifier),
sendAgentMessage: (agentId, text) => this.host.sendAgentMessage(agentId, text),
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatReadRequest(
request: Extract<SessionInboundMessage, { type: "chat/read" }>,
): Promise<void> {
try {
const messages = await this.chatService.readMessages({
room: request.room,
limit: request.limit,
since: request.since,
authorAgentId: request.authorAgentId,
});
this.host.emit({
type: "chat/read/response",
payload: {
requestId: request.requestId,
messages,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatWaitRequest(
request: Extract<SessionInboundMessage, { type: "chat/wait" }>,
): Promise<void> {
try {
const messages = await this.chatService.waitForMessages({
room: request.room,
afterMessageId: request.afterMessageId,
timeoutMs: request.timeoutMs,
});
this.host.emit({
type: "chat/wait/response",
payload: {
requestId: request.requestId,
messages,
timedOut: messages.length === 0,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private toScheduleSummary(
schedule: Awaited<ReturnType<ScheduleService["inspect"]>>,
): Extract<
SessionOutboundMessage,
{ type: "schedule/list/response" }
>["payload"]["schedules"][number] {
const { runs: _runs, ...summary } = schedule;
return summary;
}
private emitScheduleRpcError(
request: Extract<
SessionInboundMessage,
{
type:
| "schedule/create"
| "schedule/list"
| "schedule/inspect"
| "schedule/logs"
| "schedule/pause"
| "schedule/resume"
| "schedule/delete"
| "schedule/run-once"
| "schedule/update";
}
>,
error: unknown,
): void {
const message = error instanceof Error ? error.message : String(error);
this.logger.error({ err: error, requestType: request.type }, "Schedule request failed");
this.host.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code: "schedule_request_failed",
},
});
}
async handleScheduleCreateRequest(
request: Extract<SessionInboundMessage, { type: "schedule/create" }>,
): Promise<void> {
try {
const target =
request.target.type === "self"
? { type: "agent" as const, agentId: request.target.agentId }
: request.target;
const schedule = await this.scheduleService.create({
prompt: request.prompt,
name: request.name,
cadence: request.cadence,
target,
maxRuns: request.maxRuns,
expiresAt: request.expiresAt,
runOnCreate: request.runOnCreate,
});
this.host.emit({
type: "schedule/create/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleListRequest(
request: Extract<SessionInboundMessage, { type: "schedule/list" }>,
): Promise<void> {
try {
const schedules = await this.scheduleService.list();
this.host.emit({
type: "schedule/list/response",
payload: {
requestId: request.requestId,
schedules: schedules.map((schedule) => this.toScheduleSummary(schedule)),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleInspectRequest(
request: Extract<SessionInboundMessage, { type: "schedule/inspect" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.inspect(request.scheduleId);
this.host.emit({
type: "schedule/inspect/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleLogsRequest(
request: Extract<SessionInboundMessage, { type: "schedule/logs" }>,
): Promise<void> {
try {
const runs = await this.scheduleService.logs(request.scheduleId);
this.host.emit({
type: "schedule/logs/response",
payload: {
requestId: request.requestId,
runs,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleSchedulePauseRequest(
request: Extract<SessionInboundMessage, { type: "schedule/pause" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.pause(request.scheduleId);
this.host.emit({
type: "schedule/pause/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleResumeRequest(
request: Extract<SessionInboundMessage, { type: "schedule/resume" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.resume(request.scheduleId);
this.host.emit({
type: "schedule/resume/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleDeleteRequest(
request: Extract<SessionInboundMessage, { type: "schedule/delete" }>,
): Promise<void> {
try {
await this.scheduleService.delete(request.scheduleId);
this.host.emit({
type: "schedule/delete/response",
payload: {
requestId: request.requestId,
scheduleId: request.scheduleId,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleRunOnceRequest(
request: Extract<SessionInboundMessage, { type: "schedule/run-once" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.runOnce(request.scheduleId);
this.host.emit({
type: "schedule/run-once/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleUpdateRequest(
request: Extract<SessionInboundMessage, { type: "schedule/update" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.update({
id: request.scheduleId,
...(request.name !== undefined ? { name: request.name } : {}),
...(request.prompt !== undefined ? { prompt: request.prompt } : {}),
...(request.cadence !== undefined ? { cadence: request.cadence } : {}),
...(request.newAgentConfig !== undefined ? { newAgentConfig: request.newAgentConfig } : {}),
...(request.maxRuns !== undefined ? { maxRuns: request.maxRuns } : {}),
...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}),
});
this.host.emit({
type: "schedule/update/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private emitLoopRpcError(
request: Extract<
SessionInboundMessage,
{
type: "loop/run" | "loop/list" | "loop/inspect" | "loop/logs" | "loop/stop";
}
>,
error: unknown,
): void {
const message = error instanceof Error ? error.message : String(error);
this.logger.error({ err: error, requestType: request.type }, "Loop request failed");
this.host.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code: "loop_request_failed",
},
});
}
async handleLoopRunRequest(
request: Extract<SessionInboundMessage, { type: "loop/run" }>,
): Promise<void> {
try {
const loop = await this.loopService.runLoop({
prompt: request.prompt,
cwd: request.cwd,
provider: request.provider,
model: request.model,
modeId: request.modeId,
workerProvider: request.workerProvider,
workerModel: request.workerModel,
verifierProvider: request.verifierProvider,
verifierModel: request.verifierModel,
verifierModeId: request.verifierModeId,
verifyPrompt: request.verifyPrompt,
verifyChecks: request.verifyChecks,
archive: request.archive,
name: request.name,
sleepMs: request.sleepMs,
maxIterations: request.maxIterations,
maxTimeMs: request.maxTimeMs,
});
this.host.emit({
type: "loop/run/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
async handleLoopListRequest(
request: Extract<SessionInboundMessage, { type: "loop/list" }>,
): Promise<void> {
try {
const loops = await this.loopService.listLoops();
this.host.emit({
type: "loop/list/response",
payload: {
requestId: request.requestId,
loops,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
async handleLoopInspectRequest(
request: Extract<SessionInboundMessage, { type: "loop/inspect" }>,
): Promise<void> {
try {
const loop = await this.loopService.inspectLoop(request.id);
this.host.emit({
type: "loop/inspect/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
async handleLoopLogsRequest(
request: Extract<SessionInboundMessage, { type: "loop/logs" }>,
): Promise<void> {
try {
const result = await this.loopService.getLoopLogs(request.id, request.afterSeq ?? 0);
this.host.emit({
type: "loop/logs/response",
payload: {
requestId: request.requestId,
loop: result.loop,
entries: result.entries,
nextCursor: result.nextCursor,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
async handleLoopStopRequest(
request: Extract<SessionInboundMessage, { type: "loop/stop" }>,
): Promise<void> {
try {
const loop = await this.loopService.stopLoop(request.id);
this.host.emit({
type: "loop/stop/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
}
@@ -5,7 +5,7 @@ import {
CheckoutSession,
type CheckoutSessionHost,
} from "./checkout-session.js";
import { createGitHubService } from "../../../services/github-service.js";
import { createGitHubService, type GitHubService } from "../../../services/github-service.js";
import type { SessionOutboundMessage } from "../../messages.js";
import type {
CheckoutDiffCompareInput,
@@ -54,24 +54,84 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) {
return { subscriber, subscriptions, refreshedCwds };
}
interface RecordedHostCalls {
notifyGitMutation: Array<{
cwd: string;
reason: string;
options?: { invalidateGithub?: boolean };
}>;
emitWorkspaceUpdateForCwd: string[];
handleWorkspaceGitBranchSnapshot: Array<{ cwd: string; branchName: string | null }>;
renameCurrentBranch: Array<{ cwd: string; branch: string }>;
checkoutExistingBranch: Array<{ cwd: string; branch: string }>;
generateCommitMessage: string[];
generatePullRequestText: Array<{ cwd: string; baseRef?: string }>;
}
function makeCheckoutSession(options?: {
git?: Partial<WorkspaceGitService>;
diff?: CheckoutDiffSubscriber;
github?: Partial<GitHubService>;
host?: Partial<CheckoutSessionHost>;
}) {
const emitted: SessionOutboundMessage[] = [];
const host: CheckoutSessionHost = { emit: (msg) => emitted.push(msg) };
const hostCalls: RecordedHostCalls = {
notifyGitMutation: [],
emitWorkspaceUpdateForCwd: [],
handleWorkspaceGitBranchSnapshot: [],
renameCurrentBranch: [],
checkoutExistingBranch: [],
generateCommitMessage: [],
generatePullRequestText: [],
};
const host: CheckoutSessionHost = {
emit: (msg) => emitted.push(msg),
notifyGitMutation: async (cwd, reason, opts) => {
hostCalls.notifyGitMutation.push({ cwd, reason, options: opts });
},
emitWorkspaceUpdateForCwd: async (cwd) => {
hostCalls.emitWorkspaceUpdateForCwd.push(cwd);
},
handleWorkspaceGitBranchSnapshot: (cwd, branchName) => {
hostCalls.handleWorkspaceGitBranchSnapshot.push({ cwd, branchName });
},
renameCurrentBranch: async (cwd, branch) => {
hostCalls.renameCurrentBranch.push({ cwd, branch });
return { previousBranch: null, currentBranch: branch };
},
checkoutExistingBranch: async (cwd, branch) => {
hostCalls.checkoutExistingBranch.push({ cwd, branch });
return { source: "local" };
},
generateCommitMessage: async (cwd) => {
hostCalls.generateCommitMessage.push(cwd);
return "";
},
generatePullRequestText: async (cwd, baseRef) => {
hostCalls.generatePullRequestText.push({ cwd, baseRef });
return { title: "", body: "" };
},
...options?.host,
};
const github: GitHubService = { ...createGitHubService(), ...options?.github };
const checkout = new CheckoutSession({
host,
workspaceGitService: createNoopWorkspaceGitService(options?.git),
github: createGitHubService(),
github,
checkoutDiffManager:
options?.diff ?? createFakeDiffSubscriber({ cwd: "", files: [], error: null }).subscriber,
paseoHome: "/tmp/paseo-home",
worktreesRoot: undefined,
logger: pino({ level: "silent" }),
});
return { checkout, emitted };
return { checkout, emitted, hostCalls };
}
function createGitSnapshot(cwd: string, currentBranch: string): WorkspaceGitRuntimeSnapshot {
function createGitSnapshot(
cwd: string,
currentBranch: string,
overrides?: { isDirty?: boolean },
): WorkspaceGitRuntimeSnapshot {
return {
cwd,
git: {
@@ -81,7 +141,7 @@ function createGitSnapshot(cwd: string, currentBranch: string): WorkspaceGitRunt
currentBranch,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: false,
isDirty: overrides?.isDirty ?? false,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
@@ -455,4 +515,315 @@ describe("CheckoutSession", () => {
]);
});
});
describe("switch branch", () => {
it("checks out the branch, refreshes the diff and workspace, then confirms success", async () => {
const { subscriber, refreshedCwds } = createFakeDiffSubscriber({
cwd: "",
files: [],
error: null,
});
const { checkout, emitted, hostCalls } = makeCheckoutSession({ diff: subscriber });
await checkout.handleCheckoutSwitchBranchRequest({
type: "checkout_switch_branch_request",
cwd: "/repo",
branch: "feature",
requestId: "sw1",
});
expect(hostCalls.checkoutExistingBranch).toEqual([{ cwd: "/repo", branch: "feature" }]);
expect(refreshedCwds).toEqual(["/repo"]);
expect(hostCalls.emitWorkspaceUpdateForCwd).toEqual(["/repo"]);
expect(emitted).toEqual([
{
type: "checkout_switch_branch_response",
payload: {
cwd: "/repo",
success: true,
branch: "feature",
source: "local",
error: null,
requestId: "sw1",
},
},
]);
});
it("emits an error response when the checkout fails", async () => {
const { checkout, emitted } = makeCheckoutSession({
host: {
checkoutExistingBranch: async () => {
throw new Error("branch missing");
},
},
});
await checkout.handleCheckoutSwitchBranchRequest({
type: "checkout_switch_branch_request",
cwd: "/repo",
branch: "ghost",
requestId: "sw2",
});
expect(emitted).toEqual([
{
type: "checkout_switch_branch_response",
payload: {
cwd: "/repo",
success: false,
branch: "ghost",
error: { code: "UNKNOWN", message: "branch missing" },
requestId: "sw2",
},
},
]);
});
});
describe("rename branch", () => {
it("rejects an invalid slug without renaming", async () => {
const { checkout, emitted, hostCalls } = makeCheckoutSession();
await checkout.handleCheckoutRenameBranchRequest({
type: "checkout.rename_branch.request",
cwd: "/repo",
branch: "bad branch!",
requestId: "rn1",
});
expect(hostCalls.renameCurrentBranch).toEqual([]);
expect(emitted).toHaveLength(1);
expect(emitted[0]).toMatchObject({
type: "checkout.rename_branch.response",
payload: { cwd: "/repo", success: false, currentBranch: null, requestId: "rn1" },
});
});
it("renames, refreshes git state, and confirms the new branch", async () => {
const { subscriber, refreshedCwds } = createFakeDiffSubscriber({
cwd: "",
files: [],
error: null,
});
const { checkout, emitted, hostCalls } = makeCheckoutSession({ diff: subscriber });
await checkout.handleCheckoutRenameBranchRequest({
type: "checkout.rename_branch.request",
cwd: "/repo",
branch: "feature-renamed",
requestId: "rn2",
});
expect(hostCalls.renameCurrentBranch).toEqual([{ cwd: "/repo", branch: "feature-renamed" }]);
expect(hostCalls.notifyGitMutation).toEqual([
{ cwd: "/repo", reason: "rename-branch", options: { invalidateGithub: true } },
]);
expect(refreshedCwds).toEqual(["/repo"]);
expect(hostCalls.handleWorkspaceGitBranchSnapshot).toEqual([
{ cwd: "/repo", branchName: "feature-renamed" },
]);
expect(hostCalls.emitWorkspaceUpdateForCwd).toEqual(["/repo"]);
expect(emitted).toEqual([
{
type: "checkout.rename_branch.response",
payload: {
cwd: "/repo",
success: true,
currentBranch: "feature-renamed",
error: null,
requestId: "rn2",
},
},
]);
});
});
describe("commit", () => {
it("fails when no message is supplied and none can be generated", async () => {
const { checkout, emitted, hostCalls } = makeCheckoutSession();
await checkout.handleCheckoutCommitRequest({
type: "checkout_commit_request",
cwd: "/repo",
message: "",
addAll: true,
requestId: "c1",
});
expect(hostCalls.generateCommitMessage).toEqual(["/repo"]);
expect(emitted).toEqual([
{
type: "checkout_commit_response",
payload: {
cwd: "/repo",
success: false,
error: { code: "UNKNOWN", message: "Commit message is required" },
requestId: "c1",
},
},
]);
});
});
describe("merge preflight", () => {
it("fails when the target is not a git repository", async () => {
const { checkout, emitted } = makeCheckoutSession({
git: { getSnapshot: async (cwd) => createNoGitWorkspaceRuntimeSnapshot(cwd) },
});
await checkout.handleCheckoutMergeRequest({
type: "checkout_merge_request",
cwd: "/repo",
baseRef: "main",
requestId: "m1",
});
expect(emitted).toEqual([
{
type: "checkout_merge_response",
payload: {
cwd: "/repo",
success: false,
error: { code: "UNKNOWN", message: "Not a git repository: /repo" },
requestId: "m1",
},
},
]);
});
it("fails a clean-required merge when the working tree is dirty", async () => {
const { checkout, emitted } = makeCheckoutSession({
git: { getSnapshot: async () => createGitSnapshot("/repo", "feature", { isDirty: true }) },
});
await checkout.handleCheckoutMergeRequest({
type: "checkout_merge_request",
cwd: "/repo",
baseRef: "main",
requireCleanTarget: true,
requestId: "m2",
});
expect(emitted).toEqual([
{
type: "checkout_merge_response",
payload: {
cwd: "/repo",
success: false,
error: { code: "UNKNOWN", message: "Working directory has uncommitted changes." },
requestId: "m2",
},
},
]);
});
});
describe("pr merge", () => {
it("fails when no pull request number can be determined", async () => {
const { checkout, emitted } = makeCheckoutSession({
git: { getSnapshot: async (cwd) => createGitSnapshot(cwd, "feature") },
});
await checkout.handleCheckoutPrMergeRequest({
type: "checkout_pr_merge_request",
cwd: "/repo",
mergeMethod: "merge",
requestId: "pm1",
});
expect(emitted).toEqual([
{
type: "checkout_pr_merge_response",
payload: {
cwd: "/repo",
success: false,
error: {
code: "UNKNOWN",
message: "Unable to determine GitHub pull request number for merge",
},
requestId: "pm1",
},
},
]);
});
});
describe("stash list", () => {
it("returns stash entries scoped to paseo stashes by default", async () => {
const listStashesCalls: Array<{ cwd: string; paseoOnly: boolean | undefined }> = [];
const { checkout, emitted } = makeCheckoutSession({
git: {
listStashes: async (cwd, opts) => {
listStashesCalls.push({ cwd, paseoOnly: opts?.paseoOnly });
return [];
},
},
});
await checkout.handleStashListRequest({
type: "stash_list_request",
cwd: "/repo",
requestId: "sl1",
});
expect(listStashesCalls).toEqual([{ cwd: "/repo", paseoOnly: true }]);
expect(emitted).toEqual([
{
type: "stash_list_response",
payload: { cwd: "/repo", entries: [], error: null, requestId: "sl1" },
},
]);
});
});
describe("pr status", () => {
it("builds a pr status response from the git snapshot", async () => {
const { checkout, emitted } = makeCheckoutSession({
git: { getSnapshot: async (cwd) => createGitSnapshot(cwd, "main") },
});
await checkout.handleCheckoutPrStatusRequest({
type: "checkout_pr_status_request",
cwd: "/repo",
requestId: "ps1",
});
expect(emitted).toEqual([
{
type: "checkout_pr_status_response",
payload: expect.objectContaining({ cwd: "/repo", requestId: "ps1" }),
},
]);
});
});
describe("github search", () => {
it("returns search results and the github-features flag", async () => {
const { checkout, emitted } = makeCheckoutSession({
github: {
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: false }),
},
});
await checkout.handleGitHubSearchRequest({
type: "github_search_request",
cwd: "/repo",
query: "fix",
requestId: "gs1",
});
expect(emitted).toEqual([
{
type: "github_search_response",
payload: {
items: [],
githubFeaturesEnabled: false,
error: null,
requestId: "gs1",
},
},
]);
});
});
});
@@ -1,9 +1,12 @@
import type pino from "pino";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
import type {
BranchSuggestionsRequest,
CheckoutRefreshRequest,
CheckoutRenameBranchRequest,
CheckoutStatusRequest,
SessionInboundMessage,
SessionOutboundMessage,
SubscribeCheckoutDiffRequest,
UnsubscribeCheckoutDiffRequest,
@@ -21,15 +24,59 @@ import {
import type {
WorkspaceGitRuntimeSnapshot,
WorkspaceGitService,
WorkspaceGitSnapshotOptions,
} from "../../workspace-git-service.js";
import { assertSafeGitRef } from "../../worktree-session.js";
import type { GitHubService } from "../../../services/github-service.js";
import {
assertPullRequestAutoMergeDisableReady,
assertPullRequestAutoMergeEnableReady,
type GitHubService,
type PullRequestTimelineItem,
} from "../../../services/github-service.js";
import {
type CheckoutExistingBranchResult,
commitChanges,
createPullRequest,
type GitMutationRefreshReason,
mergeFromBase,
mergeToBase,
pullCurrentBranch,
pushCurrentBranch,
} from "../../../utils/checkout-git.js";
import { execCommand } from "../../../utils/spawn.js";
import { expandTilde } from "../../../utils/path.js";
/**
* The collaborators a checkout command reaches that are NOT part of the checkout
* domain: the git-mutation refresh primitive and workspace-update emitters owned
* by the Session shell (also used by worktree/workspace creation), the injected
* branch operations, and the LLM-backed commit/PR text generators. CheckoutSession
* orchestrates them but does not own them.
*/
export interface CheckoutSessionHost {
emit(msg: SessionOutboundMessage): void;
notifyGitMutation(
cwd: string,
reason: GitMutationRefreshReason,
options?: { invalidateGithub?: boolean },
): Promise<void>;
emitWorkspaceUpdateForCwd(cwd: string): Promise<void>;
handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void;
renameCurrentBranch(
cwd: string,
branch: string,
): Promise<{ previousBranch: string | null; currentBranch: string | null }>;
checkoutExistingBranch(cwd: string, branch: string): Promise<CheckoutExistingBranchResult>;
generateCommitMessage(cwd: string): Promise<string>;
generatePullRequestText(cwd: string, baseRef?: string): Promise<{ title: string; body: string }>;
}
type CurrentWorkspacePullRequest = NonNullable<
WorkspaceGitRuntimeSnapshot["github"]["pullRequest"]
> & {
number: number;
};
/**
* The slice of CheckoutDiffManager that CheckoutSession needs: open a live diff
* subscription, and nudge open subscriptions to recompute after a mutation. The
@@ -48,24 +95,30 @@ export interface CheckoutSessionOptions {
workspaceGitService: WorkspaceGitService;
github: GitHubService;
checkoutDiffManager: CheckoutDiffSubscriber;
paseoHome: string;
worktreesRoot: string | undefined;
logger: pino.Logger;
}
/**
* The read & live-stream side of a client's checkout view: status queries,
* branch validation/suggestions, manual refresh, and the live git-diff and
* checkout-status subscriptions.
* A client's checkout view, both sides: the read & live-stream side (status
* queries, branch validation/suggestions, manual refresh, live git-diff and
* checkout-status subscriptions) and the command side (switch/rename/commit/
* merge/pull/push/stash and the GitHub-PR operations).
*
* The command operations (switch/rename/commit/merge/pull/push/stash) and the
* GitHub-PR operations still live on Session; they keep the diff in sync by
* calling scheduleDiffRefresh(), and the workspace git observer streams branch
* changes through emitStatusUpdate().
* Command operations keep the live diff in sync by calling scheduleDiffRefresh()
* and refresh the workspace git snapshot through host.notifyGitMutation(); the
* workspace git observer streams branch changes through emitStatusUpdate().
*/
export class CheckoutSession {
private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:";
private readonly host: CheckoutSessionHost;
private readonly workspaceGitService: WorkspaceGitService;
private readonly github: GitHubService;
private readonly checkoutDiffManager: CheckoutDiffSubscriber;
private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined;
private readonly logger: pino.Logger;
private readonly diffSubscriptions = new Map<string, () => void>();
@@ -74,6 +127,8 @@ export class CheckoutSession {
this.workspaceGitService = options.workspaceGitService;
this.github = options.github;
this.checkoutDiffManager = options.checkoutDiffManager;
this.paseoHome = options.paseoHome;
this.worktreesRoot = options.worktreesRoot;
this.logger = options.logger;
}
@@ -301,13 +356,755 @@ export class CheckoutSession {
/**
* Notify the live diff subscriptions that the working tree at `cwd` changed.
* Called by the command handlers that still live on Session after they mutate
* the repository.
* Called by the command handlers below after they mutate the repository.
*/
scheduleDiffRefresh(cwd: string): void {
private scheduleDiffRefresh(cwd: string): void {
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
}
// ---------------------------------------------------------------------------
// Command operations (writes) and GitHub-PR operations
// ---------------------------------------------------------------------------
async handleCheckoutSwitchBranchRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
): Promise<void> {
const { cwd, branch, requestId } = msg;
try {
const checkoutResult = await this.host.checkoutExistingBranch(cwd, branch);
this.scheduleDiffRefresh(cwd);
// Push a workspace_update immediately so the sidebar/header reflect
// the new branch name without waiting for the background git watcher.
await this.host.emitWorkspaceUpdateForCwd(cwd);
this.host.emit({
type: "checkout_switch_branch_response",
payload: {
cwd,
success: true,
branch,
source: checkoutResult.source,
error: null,
requestId,
},
});
} catch (error) {
this.host.emit({
type: "checkout_switch_branch_response",
payload: {
cwd,
success: false,
branch,
error: toCheckoutError(error),
requestId,
},
});
}
}
async handleCheckoutRenameBranchRequest(msg: CheckoutRenameBranchRequest): Promise<void> {
const { cwd, branch, requestId } = msg;
const validation = validateBranchSlug(branch);
if (!validation.valid) {
this.host.emit({
type: "checkout.rename_branch.response",
payload: {
cwd,
success: false,
currentBranch: null,
error: toCheckoutError(new Error(validation.error ?? "Invalid branch name")),
requestId,
},
});
return;
}
try {
const result = await this.host.renameCurrentBranch(cwd, branch);
await this.host.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true });
this.scheduleDiffRefresh(cwd);
this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
// Branch is a git fact derived per-descriptor from each workspace's own
// live git snapshot (id → cwd); the reconciliation pass re-persists the
// `branch` field per workspace from its own cwd. No cwd → ids fan-out here.
// TODO(K10): PR-binding on branch rename is deferred — see plan K10.
// Push a workspace_update immediately so the sidebar/header reflect
// the new branch name without waiting for the background git watcher.
await this.host.emitWorkspaceUpdateForCwd(cwd);
this.host.emit({
type: "checkout.rename_branch.response",
payload: {
cwd,
success: true,
currentBranch: result.currentBranch,
error: null,
requestId,
},
});
} catch (error) {
this.host.emit({
type: "checkout.rename_branch.response",
payload: {
cwd,
success: false,
currentBranch: null,
error: toCheckoutError(error),
requestId,
},
});
}
}
async handleStashSaveRequest(
msg: Extract<SessionInboundMessage, { type: "stash_save_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
const branchLabel = msg.branch?.trim() ?? "";
const message = branchLabel
? `${CheckoutSession.PASEO_STASH_PREFIX} ${branchLabel}`
: `${CheckoutSession.PASEO_STASH_PREFIX} unnamed`;
await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], {
cwd,
});
await this.host.notifyGitMutation(cwd, "stash-push");
this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "stash_save_response",
payload: { cwd, success: true, error: null, requestId },
});
} catch (error) {
this.host.emit({
type: "stash_save_response",
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
});
}
}
async handleStashPopRequest(
msg: Extract<SessionInboundMessage, { type: "stash_pop_request" }>,
): Promise<void> {
const { cwd, stashIndex, requestId } = msg;
try {
await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], {
cwd,
});
await this.host.notifyGitMutation(cwd, "stash-pop");
this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "stash_pop_response",
payload: { cwd, success: true, error: null, requestId },
});
} catch (error) {
this.host.emit({
type: "stash_pop_response",
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
});
}
}
async handleStashListRequest(
msg: Extract<SessionInboundMessage, { type: "stash_list_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
const paseoOnly = msg.paseoOnly !== false;
try {
const entries = await this.workspaceGitService.listStashes(cwd, { paseoOnly });
this.host.emit({
type: "stash_list_response",
payload: { cwd, entries, error: null, requestId },
});
} catch (error) {
this.host.emit({
type: "stash_list_response",
payload: { cwd, entries: [], error: toCheckoutError(error), requestId },
});
}
}
async handleCheckoutCommitRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_commit_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
let message = msg.message?.trim() ?? "";
if (!message) {
message = await this.host.generateCommitMessage(cwd);
}
if (!message) {
throw new Error("Commit message is required");
}
await commitChanges(cwd, {
message,
addAll: msg.addAll ?? true,
});
await this.host.notifyGitMutation(cwd, "commit-changes");
this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "checkout_commit_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.host.emit({
type: "checkout_commit_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
async handleCheckoutMergeRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_merge_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
if (!snapshot.git.isGit) {
throw new Error(`Not a git repository: ${cwd}`);
}
if (msg.requireCleanTarget) {
if (snapshot.git.isDirty) {
throw new Error("Working directory has uncommitted changes.");
}
}
let baseRef = msg.baseRef ?? snapshot.git.baseRef;
if (!baseRef) {
throw new Error("Base branch is required for merge");
}
if (baseRef.startsWith("origin/")) {
baseRef = baseRef.slice("origin/".length);
}
const mutatedCwd = await mergeToBase(
cwd,
{
baseRef,
mode: msg.strategy === "squash" ? "squash" : "merge",
},
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot },
);
await Promise.all([
this.host.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }),
...(mutatedCwd !== cwd ? [this.host.notifyGitMutation(cwd, "merge-to-base")] : []),
]);
this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "checkout_merge_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.host.emit({
type: "checkout_merge_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
async handleCheckoutMergeFromBaseRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_merge_from_base_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
if (msg.requireCleanTarget ?? true) {
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
if (snapshot.git.isDirty) {
throw new Error("Working directory has uncommitted changes.");
}
}
await mergeFromBase(cwd, {
baseRef: msg.baseRef,
requireCleanTarget: msg.requireCleanTarget ?? true,
});
await this.host.notifyGitMutation(cwd, "merge-from-base", { invalidateGithub: true });
this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "checkout_merge_from_base_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.host.emit({
type: "checkout_merge_from_base_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
async handleCheckoutPullRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pull_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
await pullCurrentBranch(cwd);
await this.host.notifyGitMutation(cwd, "pull", { invalidateGithub: true });
this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "checkout_pull_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.host.emit({
type: "checkout_pull_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
async handleCheckoutPushRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_push_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
await pushCurrentBranch(cwd);
await this.host.notifyGitMutation(cwd, "push", { invalidateGithub: true });
this.host.emit({
type: "checkout_push_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.host.emit({
type: "checkout_push_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
async handleCheckoutPrCreateRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_create_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
let title = msg.title?.trim() ?? "";
let body = msg.body?.trim() ?? "";
if (!title || !body) {
const generated = await this.host.generatePullRequestText(cwd, msg.baseRef);
if (!title) title = generated.title;
if (!body) body = generated.body;
}
const result = await createPullRequest(
cwd,
{
title,
body,
base: msg.baseRef,
},
this.github,
);
await this.host.notifyGitMutation(cwd, "create-pr", { invalidateGithub: true });
this.host.emit({
type: "checkout_pr_create_response",
payload: {
cwd,
url: result.url ?? null,
number: result.number ?? null,
error: null,
requestId,
},
});
} catch (error) {
this.host.emit({
type: "checkout_pr_create_response",
payload: {
cwd,
url: null,
number: null,
error: toCheckoutError(error),
requestId,
},
});
}
}
async handleCheckoutPrMergeRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_merge_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
const pullRequest = await this.resolveCurrentPullRequest(cwd, "merge", {
force: true,
includeGitHub: true,
reason: "merge-pr-validation",
});
this.assertCurrentPullRequestHasGithubMergeFacts(pullRequest);
await this.github.mergePullRequest({
cwd,
prNumber: pullRequest.number,
mergeMethod: msg.mergeMethod,
status: pullRequest,
});
await this.host.notifyGitMutation(cwd, "merge-pr", { invalidateGithub: true });
this.host.emit({
type: "checkout_pr_merge_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.host.emit({
type: "checkout_pr_merge_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private assertCurrentPullRequestHasGithubMergeFacts(
pullRequest: CurrentWorkspacePullRequest,
): void {
if (!pullRequest.github) {
throw new Error("GitHub merge facts are unavailable for this pull request");
}
}
async handleCheckoutGithubSetAutoMergeRequest(
msg: Extract<SessionInboundMessage, { type: "checkout.github.set_auto_merge.request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
const pullRequest = await this.resolveCurrentPullRequest(cwd, "auto-merge", {
force: true,
includeGitHub: true,
reason: "auto-merge-validation",
});
if (msg.enabled) {
const mergeMethod = msg.mergeMethod;
if (!mergeMethod) {
throw new Error("mergeMethod is required when enabling auto-merge");
}
assertPullRequestAutoMergeEnableReady({
mergeMethod,
status: pullRequest,
});
await this.github.enablePullRequestAutoMerge({
cwd,
prNumber: pullRequest.number,
mergeMethod,
status: pullRequest,
});
} else {
if (msg.mergeMethod) {
throw new Error("mergeMethod is not allowed when disabling auto-merge");
}
assertPullRequestAutoMergeDisableReady({ status: pullRequest });
await this.github.disablePullRequestAutoMerge({
cwd,
prNumber: pullRequest.number,
status: pullRequest,
});
}
await this.host.notifyGitMutation(
cwd,
msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge",
{
invalidateGithub: true,
},
);
this.host.emit({
type: "checkout.github.set_auto_merge.response",
payload: {
cwd,
enabled: msg.enabled,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.host.emit({
type: "checkout.github.set_auto_merge.response",
payload: {
cwd,
enabled: msg.enabled,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async resolveCurrentPullRequest(
cwd: string,
operation: "merge" | "auto-merge",
options?: WorkspaceGitSnapshotOptions,
): Promise<CurrentWorkspacePullRequest> {
const snapshot = await this.workspaceGitService.getSnapshot(cwd, options);
const pullRequest = snapshot.github.pullRequest;
if (!pullRequest || typeof pullRequest.number !== "number") {
throw new Error(`Unable to determine GitHub pull request number for ${operation}`);
}
return { ...pullRequest, number: pullRequest.number };
}
async handleCheckoutPrStatusRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_status_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
this.host.emit({
type: "checkout_pr_status_response",
payload: buildCheckoutPrStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
});
} catch (error) {
this.host.emit({
type: "checkout_pr_status_response",
payload: {
cwd,
status: null,
githubFeaturesEnabled: true,
error: toCheckoutError(error),
requestId,
},
});
}
}
async handlePullRequestTimelineRequest(
msg: Extract<SessionInboundMessage, { type: "pull_request_timeline_request" }>,
): Promise<void> {
const { cwd, prNumber, repoOwner, repoName, requestId } = msg;
if (!isValidPullRequestTimelineIdentity({ prNumber, repoOwner, repoName })) {
this.host.emit({
type: "pull_request_timeline_response",
payload: {
cwd,
prNumber,
items: [],
truncated: false,
error: {
kind: "unknown",
message: "Pull request timeline request has invalid PR identity",
},
requestId,
githubFeaturesEnabled: true,
},
});
return;
}
const githubFeaturesEnabled = await this.github.isAuthenticated({ cwd });
if (!githubFeaturesEnabled) {
this.host.emit({
type: "pull_request_timeline_response",
payload: {
cwd,
prNumber,
items: [],
truncated: false,
error: {
kind: "unknown",
message: "GitHub CLI is unavailable or not authenticated",
},
requestId,
githubFeaturesEnabled: false,
},
});
return;
}
try {
const timeline = await this.github.getPullRequestTimeline({
cwd,
prNumber,
repoOwner,
repoName,
});
this.host.emit({
type: "pull_request_timeline_response",
payload: {
cwd,
prNumber: timeline.prNumber,
items: timeline.items.map(toPullRequestTimelinePayloadItem),
truncated: timeline.truncated,
error: timeline.error,
requestId,
githubFeaturesEnabled: true,
},
});
} catch (error) {
this.host.emit({
type: "pull_request_timeline_response",
payload: {
cwd,
prNumber,
items: [],
truncated: false,
error: {
kind: "unknown",
message: error instanceof Error ? error.message : String(error),
},
requestId,
githubFeaturesEnabled: true,
},
});
}
}
async handleCheckoutGithubGetCheckDetailsRequest(
msg: Extract<SessionInboundMessage, { type: "checkout.github.get_check_details.request" }>,
): Promise<void> {
const { cwd, repoOwner, repoName, checkRunId, workflowRunId, requestId } = msg;
try {
const details = await this.github.getGitHubCheckDetails({
cwd,
repoOwner,
repoName,
checkRunId,
workflowRunId,
});
this.host.emit({
type: "checkout.github.get_check_details.response",
payload: {
cwd,
success: true,
details,
error: null,
requestId,
},
});
} catch (error) {
this.host.emit({
type: "checkout.github.get_check_details.response",
payload: {
cwd,
success: false,
details: null,
error: {
code: "UNKNOWN",
message: error instanceof Error ? error.message : String(error),
},
requestId,
},
});
}
}
async handleGitHubSearchRequest(
msg: Extract<SessionInboundMessage, { type: "github_search_request" }>,
): Promise<void> {
const { cwd, query, limit, kinds, requestId } = msg;
try {
const resolvedCwd = expandTilde(cwd);
const result = await this.github.searchIssuesAndPrs({
cwd: resolvedCwd,
query,
limit,
kinds,
});
this.host.emit({
type: "github_search_response",
payload: {
items: result.items,
githubFeaturesEnabled: result.githubFeaturesEnabled,
error: null,
requestId,
},
});
} catch (error) {
this.host.emit({
type: "github_search_response",
payload: {
items: [],
githubFeaturesEnabled: true,
error: error instanceof Error ? error.message : String(error),
requestId,
},
});
}
}
cleanup(): void {
for (const unsubscribe of this.diffSubscriptions.values()) {
unsubscribe();
@@ -315,3 +1112,30 @@ export class CheckoutSession {
this.diffSubscriptions.clear();
}
}
type PullRequestTimelinePayload = Extract<
SessionOutboundMessage,
{ type: "pull_request_timeline_response" }
>["payload"];
type PullRequestTimelinePayloadItem = PullRequestTimelinePayload["items"][number];
function isValidPullRequestTimelineIdentity(options: {
prNumber: number;
repoOwner: string;
repoName: string;
}): boolean {
if (!Number.isInteger(options.prNumber) || options.prNumber <= 0) {
return false;
}
return isValidGitHubRepoSegment(options.repoOwner) && isValidGitHubRepoSegment(options.repoName);
}
function isValidGitHubRepoSegment(value: string): boolean {
return /^[A-Za-z0-9._-]+$/.test(value);
}
function toPullRequestTimelinePayloadItem(
item: PullRequestTimelineItem,
): PullRequestTimelinePayloadItem {
return item;
}
@@ -0,0 +1,171 @@
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import pino from "pino";
import {
DaemonSession,
type DaemonRuntimeConfig,
type DaemonSessionHost,
} from "./daemon-session.js";
import type { ProviderAvailability } from "../../agent/agent-manager.js";
import type { SessionOutboundMessage } from "../../messages.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeHome(): string {
const home = realpathSync(mkdtempSync(join(tmpdir(), "daemon-session-test-")));
tempDirs.push(home);
return home;
}
function makeSubsystem(overrides: {
serverId?: string;
daemonVersion?: string;
daemonRuntimeConfig?: DaemonRuntimeConfig;
listProviderAvailability?: () => Promise<ProviderAvailability[]>;
}) {
const emitted: SessionOutboundMessage[] = [];
const host: DaemonSessionHost = { emit: (msg) => emitted.push(msg) };
const subsystem = new DaemonSession({
host,
paseoHome: makeHome(),
serverId: overrides.serverId,
daemonVersion: overrides.daemonVersion,
daemonRuntimeConfig: overrides.daemonRuntimeConfig,
listProviderAvailability: overrides.listProviderAvailability ?? (async () => []),
logger: pino({ level: "silent" }),
});
return { subsystem, emitted };
}
describe("DaemonSession", () => {
test("status reports identity, runtime config, and providers with errors normalized to null", async () => {
const { subsystem, emitted } = makeSubsystem({
serverId: "srv-1",
daemonVersion: "1.2.3",
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
listProviderAvailability: async () => [
{ provider: "claude", available: true, error: null },
{ provider: "codex", available: false, error: "boom" },
],
});
await subsystem.handleGetStatusRequest({ type: "daemon.get_status.request", requestId: "s-1" });
expect(emitted).toEqual([
{
type: "daemon.get_status.response",
payload: {
requestId: "s-1",
serverId: "srv-1",
version: "1.2.3",
pid: process.pid,
nodePath: process.execPath,
startedAt: null,
listen: "127.0.0.1:6767",
relay: null,
providers: [
{ provider: "claude", available: true, error: null },
{ provider: "codex", available: false, error: "boom" },
],
},
},
]);
});
test("status falls back to null fields and an empty provider list when listing rejects", async () => {
const { subsystem, emitted } = makeSubsystem({
serverId: "srv-1",
daemonVersion: "1.2.3",
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
listProviderAvailability: async () => {
throw new Error("provider listing failed");
},
});
await subsystem.handleGetStatusRequest({ type: "daemon.get_status.request", requestId: "s-2" });
expect(emitted).toEqual([
{
type: "daemon.get_status.response",
payload: {
requestId: "s-2",
serverId: "srv-1",
version: "1.2.3",
pid: process.pid,
nodePath: process.execPath,
startedAt: null,
listen: null,
relay: null,
providers: [],
},
},
]);
});
test("pairing offer is empty when relay is disabled", async () => {
const { subsystem, emitted } = makeSubsystem({
daemonRuntimeConfig: {
listen: "127.0.0.1:6767",
relay: {
enabled: false,
endpoint: "relay.paseo.sh:443",
publicEndpoint: "relay.paseo.sh:443",
useTls: true,
publicUseTls: true,
},
},
});
await subsystem.handleGetPairingOfferRequest({
type: "daemon.get_pairing_offer.request",
requestId: "p-1",
});
expect(emitted).toEqual([
{
type: "daemon.get_pairing_offer.response",
payload: { requestId: "p-1", url: "", qr: null, relayEnabled: false },
},
]);
});
test("pairing offer mints a real connection URL when relay is enabled", async () => {
const { subsystem, emitted } = makeSubsystem({
daemonRuntimeConfig: {
listen: "127.0.0.1:6767",
appBaseUrl: "https://app.example.test",
relay: {
enabled: true,
endpoint: "relay.example.test:443",
publicEndpoint: "relay.example.test:443",
useTls: true,
publicUseTls: true,
},
},
});
await subsystem.handleGetPairingOfferRequest({
type: "daemon.get_pairing_offer.request",
requestId: "p-2",
});
expect(emitted).toHaveLength(1);
const message = emitted[0];
expect(message.type).toBe("daemon.get_pairing_offer.response");
if (message.type !== "daemon.get_pairing_offer.response") {
throw new Error("expected a pairing offer response");
}
expect(message.payload.requestId).toBe("p-2");
expect(message.payload.relayEnabled).toBe(true);
expect(message.payload.url.startsWith("https://app.example.test")).toBe(true);
expect(typeof message.payload.qr).toBe("string");
});
});
@@ -0,0 +1,139 @@
import type pino from "pino";
import type { ProviderAvailability } from "../../agent/agent-manager.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import { getPidLockInfo } from "../../pid-lock.js";
import { generateLocalPairingOffer } from "../../pairing-offer.js";
export interface DaemonRuntimeConfig {
listen: string | null;
appBaseUrl?: string;
relay: {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
} | null;
}
export interface DaemonSessionHost {
emit(msg: SessionOutboundMessage): void;
}
export interface DaemonSessionOptions {
host: DaemonSessionHost;
paseoHome: string;
serverId: string | undefined;
daemonVersion: string | undefined;
daemonRuntimeConfig: DaemonRuntimeConfig | undefined;
listProviderAvailability: () => Promise<ProviderAvailability[]>;
logger: pino.Logger;
}
/**
* A client's read surface for the daemon process itself: its runtime status
* (pid-lock start time, listen address, relay config, provider availability) and
* a fresh local pairing offer for connecting a new client. Owns the `daemon.*`
* RPCs. Reaches no state beyond the never-mutated runtime values injected at
* construction and the outbound channel.
*/
export class DaemonSession {
private readonly host: DaemonSessionHost;
private readonly paseoHome: string;
private readonly serverId: string | undefined;
private readonly daemonVersion: string | undefined;
private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined;
private readonly listProviderAvailability: () => Promise<ProviderAvailability[]>;
private readonly logger: pino.Logger;
constructor(options: DaemonSessionOptions) {
this.host = options.host;
this.paseoHome = options.paseoHome;
this.serverId = options.serverId;
this.daemonVersion = options.daemonVersion;
this.daemonRuntimeConfig = options.daemonRuntimeConfig;
this.listProviderAvailability = options.listProviderAvailability;
this.logger = options.logger;
}
async handleGetStatusRequest(
msg: Extract<SessionInboundMessage, { type: "daemon.get_status.request" }>,
): Promise<void> {
try {
const pidInfo = await getPidLockInfo(this.paseoHome);
const providers = (await this.listProviderAvailability()).map((p) => ({
provider: p.provider,
available: p.available,
error: p.error ?? null,
}));
this.host.emit({
type: "daemon.get_status.response",
payload: {
requestId: msg.requestId,
serverId: this.serverId ?? "",
version: this.daemonVersion ?? null,
pid: process.pid,
nodePath: process.execPath,
startedAt: pidInfo?.startedAt ?? null,
listen: this.daemonRuntimeConfig?.listen ?? null,
relay: this.daemonRuntimeConfig?.relay ?? null,
providers,
},
});
} catch (error) {
this.logger.error({ err: error }, "Failed to handle daemon status request");
this.host.emit({
type: "daemon.get_status.response",
payload: {
requestId: msg.requestId,
serverId: this.serverId ?? "",
version: this.daemonVersion ?? null,
pid: process.pid,
nodePath: process.execPath,
startedAt: null,
listen: null,
relay: null,
providers: [],
},
});
}
}
async handleGetPairingOfferRequest(
msg: Extract<SessionInboundMessage, { type: "daemon.get_pairing_offer.request" }>,
): Promise<void> {
try {
const relay = this.daemonRuntimeConfig?.relay;
const pairing = await generateLocalPairingOffer({
paseoHome: this.paseoHome,
relayEnabled: relay?.enabled ?? true,
relayEndpoint: relay?.endpoint,
relayPublicEndpoint: relay?.publicEndpoint,
relayUseTls: relay?.useTls,
relayPublicUseTls: relay?.publicUseTls,
appBaseUrl: this.daemonRuntimeConfig?.appBaseUrl,
includeQr: true,
logger: this.logger,
});
this.host.emit({
type: "daemon.get_pairing_offer.response",
payload: {
requestId: msg.requestId,
url: pairing.url ?? "",
qr: pairing.qr ?? null,
relayEnabled: pairing.relayEnabled,
},
});
} catch (error) {
this.logger.error({ err: error }, "Failed to handle daemon pairing offer request");
this.host.emit({
type: "rpc_error",
payload: {
requestId: msg.requestId,
requestType: "daemon.get_pairing_offer.request",
error: error instanceof Error ? error.message : String(error),
},
});
}
}
}
@@ -0,0 +1,272 @@
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import pino from "pino";
import {
decodeFileTransferFrame,
encodeFileTransferFrame,
FileTransferOpcode,
type FileTransferFrame,
} from "@getpaseo/protocol/binary-frames/index";
import {
WorkspaceFilesSession,
type WorkspaceFilesSessionHost,
} from "./workspace-files-session.js";
import { DownloadTokenStore } from "../../file-download/token-store.js";
import type { SessionOutboundMessage } from "../../messages.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeDir(prefix: string): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
tempDirs.push(dir);
return dir;
}
function makeSubsystem(options: { hasBinaryChannel?: boolean } = {}) {
const emitted: SessionOutboundMessage[] = [];
const binary: Uint8Array[] = [];
let hasBinary = options.hasBinaryChannel ?? false;
const host: WorkspaceFilesSessionHost = {
emit: (msg) => emitted.push(msg),
emitBinary: (frame) => binary.push(frame),
hasBinaryChannel: () => hasBinary,
};
const paseoHome = makeDir("workspace-files-home-");
const subsystem = new WorkspaceFilesSession({
host,
downloadTokenStore: new DownloadTokenStore({ ttlMs: 60_000 }),
paseoHome,
logger: pino({ level: "silent" }),
});
return {
subsystem,
emitted,
binary,
paseoHome,
setHasBinary: (value: boolean) => {
hasBinary = value;
},
};
}
function uploadFrame(args: Parameters<typeof encodeFileTransferFrame>[0]): FileTransferFrame {
const frame = decodeFileTransferFrame(encodeFileTransferFrame(args));
if (!frame) {
throw new Error("Expected a file transfer frame");
}
return frame;
}
describe("WorkspaceFilesSession", () => {
test("lists directory entries", async () => {
const cwd = makeDir("workspace-files-list-");
writeFileSync(join(cwd, "a.txt"), "alpha");
const { subsystem, emitted } = makeSubsystem();
await subsystem.handleFileExplorerRequest({
type: "file_explorer_request",
cwd,
path: ".",
mode: "list",
requestId: "req-list",
});
expect(emitted).toHaveLength(1);
const message = emitted[0];
if (message.type !== "file_explorer_response") {
throw new Error(`expected file_explorer_response, got ${message.type}`);
}
expect(message.payload.error).toBeNull();
expect(message.payload.directory).not.toBeNull();
});
test("reads file content inline when the client has no binary channel", async () => {
const cwd = makeDir("workspace-files-read-");
writeFileSync(join(cwd, "notes.txt"), "hello world");
const { subsystem, emitted, binary } = makeSubsystem({ hasBinaryChannel: false });
await subsystem.handleFileExplorerRequest({
type: "file_explorer_request",
cwd,
path: "notes.txt",
mode: "file",
requestId: "req-read",
acceptBinary: true,
});
expect(binary).toEqual([]);
expect(emitted).toHaveLength(1);
const message = emitted[0];
if (message.type !== "file_explorer_response") {
throw new Error(`expected file_explorer_response, got ${message.type}`);
}
expect(message.payload.error).toBeNull();
expect(message.payload.file).not.toBeNull();
});
test("streams binary frames when the client accepts binary and has a channel", async () => {
const cwd = makeDir("workspace-files-binary-");
writeFileSync(join(cwd, "notes.txt"), "hello world");
const { subsystem, emitted, binary } = makeSubsystem({ hasBinaryChannel: true });
await subsystem.handleFileExplorerRequest({
type: "file_explorer_request",
cwd,
path: "notes.txt",
mode: "file",
requestId: "req-binary",
acceptBinary: true,
});
expect(emitted).toEqual([]);
expect(binary).toHaveLength(3);
const opcodes = binary.map((frame) => decodeFileTransferFrame(frame)?.opcode);
expect(opcodes).toEqual([
FileTransferOpcode.FileBegin,
FileTransferOpcode.FileChunk,
FileTransferOpcode.FileEnd,
]);
});
test("rejects an empty file-explorer cwd with an error envelope", async () => {
const { subsystem, emitted } = makeSubsystem();
await subsystem.handleFileExplorerRequest({
type: "file_explorer_request",
cwd: " ",
path: ".",
mode: "list",
requestId: "req-empty",
});
expect(emitted).toEqual([
{
type: "file_explorer_response",
payload: expect.objectContaining({
error: "cwd is required",
directory: null,
file: null,
requestId: "req-empty",
}),
},
]);
});
test("issues a download token for a real file", async () => {
const cwd = makeDir("workspace-files-token-");
writeFileSync(join(cwd, "report.txt"), "hello world");
const { subsystem, emitted } = makeSubsystem();
await subsystem.handleFileDownloadTokenRequest({
type: "file_download_token_request",
cwd,
path: "report.txt",
requestId: "req-token",
});
expect(emitted).toHaveLength(1);
const message = emitted[0];
if (message.type !== "file_download_token_response") {
throw new Error(`expected file_download_token_response, got ${message.type}`);
}
expect(message.payload.error).toBeNull();
expect(typeof message.payload.token).toBe("string");
expect(message.payload.fileName).toBe("report.txt");
expect(message.payload.size).toBe(11);
});
test("rejects an empty download-token cwd with an error envelope", async () => {
const { subsystem, emitted } = makeSubsystem();
await subsystem.handleFileDownloadTokenRequest({
type: "file_download_token_request",
cwd: "",
path: "report.txt",
requestId: "req-token-empty",
});
expect(emitted).toEqual([
{
type: "file_download_token_response",
payload: expect.objectContaining({
token: null,
error: "cwd is required",
requestId: "req-token-empty",
}),
},
]);
});
test("responds to a project icon request", async () => {
const cwd = makeDir("workspace-files-icon-");
const { subsystem, emitted } = makeSubsystem();
await subsystem.handleProjectIconRequest({
type: "project_icon_request",
cwd,
requestId: "req-icon",
});
expect(emitted).toHaveLength(1);
const message = emitted[0];
if (message.type !== "project_icon_response") {
throw new Error(`expected project_icon_response, got ${message.type}`);
}
expect(message.payload.cwd).toBe(cwd);
expect(message.payload.error).toBeNull();
});
test("round-trips an upload through transfer frames", async () => {
const { subsystem, emitted, paseoHome } = makeSubsystem();
subsystem.handleFileUploadRequest({
type: "file.upload.request",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-upload",
});
await subsystem.handleFileTransferFrame(
uploadFrame({
opcode: FileTransferOpcode.FileBegin,
requestId: "req-upload",
metadata: {
mime: "text/plain",
size: 11,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
fileName: "notes.txt",
},
}),
);
await subsystem.handleFileTransferFrame(
uploadFrame({
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode("hello world"),
}),
);
await subsystem.handleFileTransferFrame(
uploadFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-upload" }),
);
const message = emitted.find((entry) => entry.type === "file.upload.response");
if (message?.type !== "file.upload.response") {
throw new Error("expected a file.upload.response message");
}
expect(message.payload.error).toBeNull();
expect(message.payload.file?.fileName).toBe("notes.txt");
expect(readFileSync(join(paseoHome, "uploads", "upload_req-upload", "notes.txt"), "utf8")).toBe(
"hello world",
);
});
});
@@ -0,0 +1,286 @@
import type pino from "pino";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import {
encodeFileTransferFrame,
FileTransferOpcode,
type FileTransferFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type {
FileDownloadTokenRequest,
FileExplorerRequest,
FileUploadRequest,
SessionInboundMessage,
SessionOutboundMessage,
} from "../../messages.js";
import { FileUploadStore } from "../../file-upload/index.js";
import type { DownloadTokenStore } from "../../file-download/token-store.js";
import {
getDownloadableFileInfo,
listDirectoryEntries,
readExplorerFile,
readExplorerFileBytes,
} from "../../file-explorer/service.js";
import { getProjectIcon } from "../../../utils/project-icon.js";
/**
* What a workspace file-access request reaches outside its own domain: the
* outbound message channel (text + binary). `hasBinaryChannel` gates the
* binary file-explorer transfer path the same way the terminal subsystem does
* — old clients without a binary channel fall back to inline JSON file content.
*/
export interface WorkspaceFilesSessionHost {
emit(msg: SessionOutboundMessage): void;
emitBinary(frame: Uint8Array): void;
hasBinaryChannel(): boolean;
}
export interface WorkspaceFilesSessionOptions {
host: WorkspaceFilesSessionHost;
downloadTokenStore: DownloadTokenStore;
paseoHome: string;
logger: pino.Logger;
}
/**
* A client's workspace file-access surface: browsing directories, reading file
* contents (inline JSON or binary frames), receiving uploads, issuing download
* tokens, and reading project icons. It owns the upload store and reaches no
* workspace-git, registry, or subscription state — file I/O scoped to a cwd is
* the whole concern.
*/
export class WorkspaceFilesSession {
private readonly host: WorkspaceFilesSessionHost;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly logger: pino.Logger;
private readonly fileUploads: FileUploadStore;
constructor(options: WorkspaceFilesSessionOptions) {
this.host = options.host;
this.downloadTokenStore = options.downloadTokenStore;
this.logger = options.logger;
this.fileUploads = new FileUploadStore({ paseoHome: options.paseoHome });
}
async handleFileExplorerRequest(request: FileExplorerRequest): Promise<void> {
const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request;
const cwd = workspaceCwd.trim();
if (!cwd) {
this.host.emit({
type: "file_explorer_response",
payload: {
cwd: workspaceCwd,
path: requestedPath,
mode,
directory: null,
file: null,
error: "cwd is required",
requestId,
},
});
return;
}
try {
if (mode === "list") {
const directory = await listDirectoryEntries({
root: cwd,
relativePath: requestedPath,
});
this.host.emit({
type: "file_explorer_response",
payload: {
cwd,
path: directory.path,
mode,
directory,
file: null,
error: null,
requestId,
},
});
} else {
if (request.acceptBinary && this.host.hasBinaryChannel()) {
const file = await readExplorerFileBytes({
root: cwd,
relativePath: requestedPath,
});
this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId,
metadata: {
mime: file.mimeType,
size: file.size,
encoding: file.encoding,
modifiedAt: file.modifiedAt,
},
}),
);
this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId,
payload: file.bytes,
}),
);
this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileEnd,
requestId,
}),
);
} else {
const file = await readExplorerFile({
root: cwd,
relativePath: requestedPath,
});
this.host.emit({
type: "file_explorer_response",
payload: {
cwd,
path: file.path,
mode,
directory: null,
file,
error: null,
requestId,
},
});
}
}
} catch (error) {
this.logger.error(
{ err: error, cwd, path: requestedPath },
`Failed to fulfill file explorer request for workspace ${cwd}`,
);
this.host.emit({
type: "file_explorer_response",
payload: {
cwd,
path: requestedPath,
mode,
directory: null,
file: null,
error: getErrorMessage(error),
requestId,
},
});
}
}
handleFileUploadRequest(request: FileUploadRequest): void {
this.fileUploads.beginUpload(request);
}
async handleFileTransferFrame(frame: FileTransferFrame): Promise<void> {
const response = await this.fileUploads.receiveFrame(frame);
if (response) {
this.host.emit(response);
}
}
async handleProjectIconRequest(
request: Extract<SessionInboundMessage, { type: "project_icon_request" }>,
): Promise<void> {
const { cwd, requestId } = request;
try {
const icon = await getProjectIcon(cwd);
this.host.emit({
type: "project_icon_response",
payload: {
cwd,
icon,
error: null,
requestId,
},
});
} catch (error) {
this.host.emit({
type: "project_icon_response",
payload: {
cwd,
icon: null,
error: getErrorMessage(error),
requestId,
},
});
}
}
async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise<void> {
const { cwd: workspaceCwd, path: requestedPath, requestId } = request;
const cwd = workspaceCwd.trim();
if (!cwd) {
this.host.emit({
type: "file_download_token_response",
payload: {
cwd: workspaceCwd,
path: requestedPath,
token: null,
fileName: null,
mimeType: null,
size: null,
error: "cwd is required",
requestId,
},
});
return;
}
this.logger.debug(
{ cwd, path: requestedPath },
`Handling file download token request for workspace ${cwd} (${requestedPath})`,
);
try {
const info = await getDownloadableFileInfo({
root: cwd,
relativePath: requestedPath,
});
const entry = this.downloadTokenStore.issueToken({
path: info.path,
absolutePath: info.absolutePath,
fileName: info.fileName,
mimeType: info.mimeType,
size: info.size,
});
this.host.emit({
type: "file_download_token_response",
payload: {
cwd,
path: info.path,
token: entry.token,
fileName: entry.fileName,
mimeType: entry.mimeType,
size: entry.size,
error: null,
requestId,
},
});
} catch (error) {
this.logger.error(
{ err: error, cwd, path: requestedPath },
`Failed to issue download token for workspace ${cwd}`,
);
this.host.emit({
type: "file_download_token_response",
payload: {
cwd,
path: requestedPath,
token: null,
fileName: null,
mimeType: null,
size: null,
error: getErrorMessage(error),
requestId,
},
});
}
}
}
@@ -0,0 +1,230 @@
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import pino from "pino";
import { ProjectConfigSession, type ProjectConfigSessionHost } from "./project-config-session.js";
import type { PersistedProjectRecord } from "../../workspace-registry.js";
import type { SessionOutboundMessage } from "../../messages.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeRoot(): string {
const root = realpathSync(mkdtempSync(join(tmpdir(), "project-config-session-test-")));
tempDirs.push(root);
return root;
}
function projectRecord(rootPath: string, archivedAt: string | null = null): PersistedProjectRecord {
return {
projectId: `project:${rootPath}`,
rootPath,
kind: "git",
displayName: "Project",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt,
};
}
function makeSubsystem(records: PersistedProjectRecord[]) {
const emitted: SessionOutboundMessage[] = [];
const host: ProjectConfigSessionHost = { emit: (msg) => emitted.push(msg) };
const subsystem = new ProjectConfigSession({
host,
projectRegistry: { list: async () => records },
logger: pino({ level: "silent" }),
});
return { subsystem, emitted };
}
describe("ProjectConfigSession", () => {
test("read resolves a known root despite a trailing slash and returns the raw config + revision", async () => {
const repoRoot = makeRoot();
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]);
await subsystem.handleReadProjectConfigRequest({
type: "read_project_config_request",
requestId: "read-1",
repoRoot: `${repoRoot}/`,
});
expect(emitted).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "read-1",
repoRoot,
ok: true,
config: { worktree: { setup: "npm ci" } },
revision: expect.objectContaining({
mtimeMs: expect.any(Number),
size: expect.any(Number),
}),
},
},
]);
});
// POSIX-only: creates a directory symlink without Windows privileges.
test.skipIf(process.platform === "win32")(
"read resolves a symlink to an active root via realpath",
async () => {
const repoRoot = makeRoot();
writeFileSync(
join(repoRoot, "paseo.json"),
JSON.stringify({ worktree: { setup: "npm ci" } }),
);
const linkRoot = join(makeRoot(), "link");
symlinkSync(repoRoot, linkRoot, "dir");
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]);
await subsystem.handleReadProjectConfigRequest({
type: "read_project_config_request",
requestId: "read-symlink-1",
repoRoot: linkRoot,
});
expect(emitted).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "read-symlink-1",
repoRoot,
ok: true,
config: { worktree: { setup: "npm ci" } },
revision: expect.objectContaining({
mtimeMs: expect.any(Number),
size: expect.any(Number),
}),
},
},
]);
},
);
test("read rejects archived and unknown roots with project_not_found", async () => {
const archivedRoot = makeRoot();
const unknownRoot = makeRoot();
const { subsystem, emitted } = makeSubsystem([
projectRecord(archivedRoot, "2026-01-02T00:00:00.000Z"),
]);
await subsystem.handleReadProjectConfigRequest({
type: "read_project_config_request",
requestId: "archived-1",
repoRoot: archivedRoot,
});
await subsystem.handleReadProjectConfigRequest({
type: "read_project_config_request",
requestId: "unknown-1",
repoRoot: unknownRoot,
});
expect(emitted).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "archived-1",
repoRoot: archivedRoot,
ok: false,
error: { code: "project_not_found" },
},
},
{
type: "read_project_config_response",
payload: {
requestId: "unknown-1",
repoRoot: unknownRoot,
ok: false,
error: { code: "project_not_found" },
},
},
]);
});
test("write round-trips a config to a known root and echoes the new revision", async () => {
const repoRoot = makeRoot();
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]);
await subsystem.handleWriteProjectConfigRequest({
type: "write_project_config_request",
requestId: "write-1",
repoRoot,
config: { worktree: { setup: "npm ci" } },
expectedRevision: null,
});
expect(emitted).toEqual([
{
type: "write_project_config_response",
payload: {
requestId: "write-1",
repoRoot,
ok: true,
config: { worktree: { setup: "npm ci" } },
revision: expect.objectContaining({
mtimeMs: expect.any(Number),
size: expect.any(Number),
}),
},
},
]);
});
test("write rejects a stale revision and an unknown root with their inline domain failures", async () => {
const staleRoot = makeRoot();
writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
const unknownRoot = makeRoot();
const { subsystem, emitted } = makeSubsystem([projectRecord(staleRoot)]);
await subsystem.handleWriteProjectConfigRequest({
type: "write_project_config_request",
requestId: "stale-1",
repoRoot: staleRoot,
config: { worktree: { setup: "new" } },
expectedRevision: { mtimeMs: 1, size: 1 },
});
await subsystem.handleWriteProjectConfigRequest({
type: "write_project_config_request",
requestId: "unknown-write-1",
repoRoot: unknownRoot,
config: { worktree: { setup: "new" } },
expectedRevision: null,
});
expect(emitted).toEqual([
{
type: "write_project_config_response",
payload: {
requestId: "stale-1",
repoRoot: staleRoot,
ok: false,
error: {
code: "stale_project_config",
currentRevision: expect.objectContaining({
mtimeMs: expect.any(Number),
size: expect.any(Number),
}),
},
},
},
{
type: "write_project_config_response",
payload: {
requestId: "unknown-write-1",
repoRoot: unknownRoot,
ok: false,
error: { code: "project_not_found" },
},
},
]);
});
});
@@ -0,0 +1,184 @@
import { realpathSync } from "node:fs";
import { resolve, sep } from "path";
import type pino from "pino";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import type { ProjectRegistry } from "../../workspace-registry.js";
import {
readPaseoConfigForEdit,
writePaseoConfigForEdit,
type ProjectConfigRpcError,
} from "../../../utils/paseo-config-file.js";
export interface ProjectConfigSessionHost {
emit(msg: SessionOutboundMessage): void;
}
export interface ProjectConfigSessionOptions {
host: ProjectConfigSessionHost;
projectRegistry: Pick<ProjectRegistry, "list">;
logger: pino.Logger;
}
/**
* A client's read/write surface for a project's on-disk paseo.json. Resolves the
* request's repoRoot against the known (non-archived) project roots — accepting a
* trailing slash or a symlink via realpath — then reads or writes the config
* substrate and emits the matching response. Reaches no state beyond the injected
* project registry and the outbound channel.
*/
export class ProjectConfigSession {
private readonly host: ProjectConfigSessionHost;
private readonly projectRegistry: Pick<ProjectRegistry, "list">;
private readonly logger: pino.Logger;
constructor(options: ProjectConfigSessionOptions) {
this.host = options.host;
this.projectRegistry = options.projectRegistry;
this.logger = options.logger;
}
async handleReadProjectConfigRequest(
msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>,
): Promise<void> {
const repoRoot = await this.resolveKnownProjectRoot(msg.repoRoot);
if (!repoRoot) {
this.emitProjectConfigReadFailure(msg, { code: "project_not_found" });
return;
}
const result = readPaseoConfigForEdit(repoRoot);
if (!result.ok) {
this.logger.warn(
{ repoRoot, requestId: msg.requestId, outcome: result.error.code },
"Failed to read project config",
);
this.emitProjectConfigReadFailure(msg, result.error, repoRoot);
return;
}
if (result.config === null) {
this.logger.debug(
{ repoRoot, requestId: msg.requestId, outcome: "missing_project_config" },
"Project config missing",
);
}
this.host.emit({
type: "read_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: true,
config: result.config,
revision: result.revision,
},
});
}
async handleWriteProjectConfigRequest(
msg: Extract<SessionInboundMessage, { type: "write_project_config_request" }>,
): Promise<void> {
const repoRoot = await this.resolveKnownProjectRoot(msg.repoRoot);
if (!repoRoot) {
this.emitProjectConfigWriteFailure(msg, { code: "project_not_found" });
return;
}
this.logger.debug(
{ repoRoot, requestId: msg.requestId, outcome: "write_attempt" },
"Writing project config",
);
const result = writePaseoConfigForEdit({
repoRoot,
config: msg.config,
expectedRevision: msg.expectedRevision,
});
if (!result.ok) {
this.logger.debug(
{ repoRoot, requestId: msg.requestId, outcome: result.error.code },
"Project config write did not complete",
);
this.emitProjectConfigWriteFailure(msg, result.error, repoRoot);
return;
}
this.logger.debug(
{ repoRoot, requestId: msg.requestId, outcome: "written" },
"Project config written",
);
this.host.emit({
type: "write_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: true,
config: result.config,
revision: result.revision,
},
});
}
private emitProjectConfigReadFailure(
msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>,
error: ProjectConfigRpcError,
repoRoot = msg.repoRoot,
): void {
this.host.emit({
type: "read_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: false,
error,
},
});
}
private emitProjectConfigWriteFailure(
msg: Extract<SessionInboundMessage, { type: "write_project_config_request" }>,
error: ProjectConfigRpcError,
repoRoot = msg.repoRoot,
): void {
this.host.emit({
type: "write_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: false,
error,
},
});
}
private async resolveKnownProjectRoot(repoRoot: string): Promise<string | null> {
const requestedRoot = canonicalizeConfigRoot(repoRoot);
const projects = await this.projectRegistry.list();
for (const project of projects) {
if (project.archivedAt !== null) {
continue;
}
const projectRoot = canonicalizeConfigRoot(project.rootPath);
if (requestedRoot === projectRoot) {
return projectRoot;
}
}
return null;
}
}
function canonicalizeConfigRoot(repoRoot: string): string {
const resolved = resolve(repoRoot);
try {
return stripTrailingPathSeparators(realpathSync(resolved));
} catch {
return stripTrailingPathSeparators(resolved);
}
}
function stripTrailingPathSeparators(path: string): string {
let normalized = path;
while (normalized.length > 1 && normalized.endsWith(sep)) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
@@ -0,0 +1,184 @@
import { describe, expect, it } from "vitest";
import pino from "pino";
import {
ProviderCatalogSession,
type ProviderCatalogSessionHost,
} from "./provider-catalog-session.js";
import { createStub } from "../../test-utils/class-mocks.js";
import { findByType } from "../../test-utils/session-stubs.js";
import type { SessionOutboundMessage } from "../../messages.js";
import {
resolveSnapshotCwd,
type ProviderSnapshotManager,
} from "../../agent/provider-snapshot-manager.js";
import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js";
import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js";
type SnapshotChangeHandler = (entries: ProviderSnapshotEntry[], cwd: string) => void;
interface MakeOptions {
visibleProviders?: Set<string>;
supportsCustomModeIcons?: boolean;
snapshot?: { [K in keyof ProviderSnapshotManager]?: unknown };
usage?: { [K in keyof ProviderUsageService]?: unknown };
host?: Partial<ProviderCatalogSessionHost>;
}
// A codex entry whose two modes exercise both downgrade branches (unknown icon →
// "ShieldCheck", known icon → preserved) plus a claude entry the visibility gate drops.
function makeEntries(): ProviderSnapshotEntry[] {
return [
{
provider: "codex",
status: "ready",
enabled: true,
modes: [
{ id: "default", label: "Default", icon: "Sparkles" },
{ id: "safe", label: "Safe", icon: "ShieldCheck" },
],
},
{ provider: "claude", status: "ready", enabled: true, modes: [] },
];
}
function makeSubsystem(options: MakeOptions = {}) {
const emitted: SessionOutboundMessage[] = [];
const visible = options.visibleProviders ?? new Set(["codex"]);
let changeHandler: SnapshotChangeHandler | null = null;
const host: ProviderCatalogSessionHost = {
emit: (msg) => emitted.push(msg),
isProviderVisibleToClient: (provider) => visible.has(provider),
supportsCustomModeIcons: () => options.supportsCustomModeIcons ?? false,
listProviderAvailability: async () => [],
listDraftFeatures: async () => [],
...options.host,
};
const providerSnapshotManager = createStub<ProviderSnapshotManager>({
on: (_event: string, handler: SnapshotChangeHandler) => {
changeHandler = handler;
},
off: () => {},
...options.snapshot,
});
const subsystem = new ProviderCatalogSession({
host,
providerSnapshotManager,
providerUsageService: createStub<ProviderUsageService>(options.usage ?? {}),
logger: pino({ level: "silent" }),
});
function pushSnapshotChange(entries: ProviderSnapshotEntry[], cwd = resolveSnapshotCwd()): void {
if (!changeHandler) throw new Error("start() must run before a snapshot change");
changeHandler(entries, cwd);
}
return { subsystem, emitted, pushSnapshotChange };
}
describe("ProviderCatalogSession", () => {
it("PUSH gates invisible providers and downgrades unknown mode icons for legacy clients", () => {
const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({
visibleProviders: new Set(["codex"]),
supportsCustomModeIcons: false,
});
subsystem.start();
pushSnapshotChange(makeEntries());
const push = findByType(emitted, "providers_snapshot_update");
expect(push?.payload.entries.map((entry) => entry.provider)).toEqual(["codex"]);
expect(push?.payload.entries[0]?.modes).toEqual([
{ id: "default", label: "Default", icon: "ShieldCheck" },
{ id: "safe", label: "Safe", icon: "ShieldCheck" },
]);
});
it("PUSH and PULL produce identical visible, downgraded entries for one client", async () => {
const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({
visibleProviders: new Set(["codex"]),
supportsCustomModeIcons: false,
snapshot: { getSnapshot: () => makeEntries() },
});
subsystem.start();
pushSnapshotChange(makeEntries());
await subsystem.handleGetProvidersSnapshotRequest({
type: "get_providers_snapshot_request",
requestId: "g1",
});
const push = findByType(emitted, "providers_snapshot_update");
const pull = findByType(emitted, "get_providers_snapshot_response");
expect(pull?.payload.entries).toEqual(push?.payload.entries);
});
it("preserves custom mode icons when the client supports them", async () => {
const { subsystem, emitted } = makeSubsystem({
supportsCustomModeIcons: true,
snapshot: { getSnapshot: () => makeEntries() },
});
await subsystem.handleGetProvidersSnapshotRequest({
type: "get_providers_snapshot_request",
requestId: "g2",
});
const pull = findByType(emitted, "get_providers_snapshot_response");
expect(pull?.payload.entries[0]?.modes?.[0]?.icon).toBe("Sparkles");
});
it("reports a disabled provider on list_provider_models without warming the snapshot", async () => {
// warmUpSnapshotForCwd is intentionally unstubbed: createStub throws if it is called,
// so the disabled short-circuit is proven by the absence of a throw.
const { subsystem, emitted } = makeSubsystem({
snapshot: { getSnapshot: () => [{ provider: "codex", status: "loading", enabled: false }] },
});
await subsystem.handleListProviderModelsRequest({
type: "list_provider_models_request",
provider: "codex",
requestId: "m1",
});
const res = findByType(emitted, "list_provider_models_response");
expect(res?.payload.error).toBe("Provider codex is disabled");
});
it("surfaces a usage-list failure as an rpc_error envelope", async () => {
const { subsystem, emitted } = makeSubsystem({
usage: {
listUsage: async () => {
throw new Error("quota service down");
},
},
});
await subsystem.handleProviderUsageListRequest({
type: "provider.usage.list.request",
requestId: "u1",
});
const err = findByType(emitted, "rpc_error");
expect(err?.payload.code).toBe("provider_usage_list_failed");
expect(err?.payload.requestId).toBe("u1");
});
it("surfaces a feature-list failure inline, not as an rpc_error", async () => {
const { subsystem, emitted } = makeSubsystem({
host: {
listDraftFeatures: async () => {
throw new Error("feature probe failed");
},
},
});
await subsystem.handleListProviderFeaturesRequest({
type: "list_provider_features_request",
requestId: "f1",
draftConfig: { provider: "codex", cwd: "/tmp/project" },
});
expect(findByType(emitted, "rpc_error")).toBeUndefined();
const res = findByType(emitted, "list_provider_features_response");
expect(res?.payload.error).toBe("feature probe failed");
expect(res?.payload.requestId).toBe("f1");
});
});
@@ -0,0 +1,454 @@
import type pino from "pino";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import {
resolveSnapshotCwd,
type ProviderSnapshotManager,
} from "../../agent/provider-snapshot-manager.js";
import type {
AgentFeature,
AgentProvider,
AgentSessionConfig,
ProviderSnapshotEntry,
} from "../../agent/agent-sdk-types.js";
import type { ProviderAvailability } from "../../agent/agent-manager.js";
import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js";
import { expandTilde } from "../../../utils/path.js";
// COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any
// other icon name is downgraded to "ShieldCheck" for those clients.
const LEGACY_MODE_ICONS = new Set<string>([
"ShieldCheck",
"ShieldAlert",
"ShieldOff",
"ShieldQuestionMark",
]);
/**
* The collaborators a provider-catalog request reaches that are NOT part of the
* provider domain. Two are CLIENT-COMPAT predicates the Session shell owns because
* agent-lifecycle shares the visibility gate: both read client state (appVersion /
* capabilities) LIVE, mutated post-construction via updateAppVersion /
* updateClientCapabilities. The two agent-control reads expose provider availability
* and draft features the AgentManager owns.
*/
export interface ProviderCatalogSessionHost {
emit(msg: SessionOutboundMessage): void;
// COMPAT(providersSnapshot): visibility gating for older clients lives on the shell
// (agent-lifecycle shares it). Reads appVersion live.
isProviderVisibleToClient(provider: string): boolean;
// COMPAT(customModeIcons): reads clientCapabilities live.
supportsCustomModeIcons(): boolean;
listProviderAvailability(): Promise<ProviderAvailability[]>;
listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]>;
}
export interface ProviderCatalogSessionOptions {
host: ProviderCatalogSessionHost;
providerSnapshotManager: ProviderSnapshotManager;
providerUsageService: ProviderUsageService;
logger: pino.Logger;
}
/**
* A client's provider catalog surface: model / mode / feature listing, the providers
* snapshot push + pull, provider diagnostics, and usage. The snapshot PUSH (start) and
* every PULL handler gate visibility and downgrade mode icons through the SAME predicates,
* so an older client sees one consistent provider set across both paths — the COMPAT
* invariant the shell could only enforce by code proximity before this carve.
*/
export class ProviderCatalogSession {
private readonly host: ProviderCatalogSessionHost;
private readonly providerSnapshotManager: ProviderSnapshotManager;
private readonly providerUsageService: ProviderUsageService;
private readonly logger: pino.Logger;
private unsubscribeSnapshotEvents: (() => void) | null = null;
constructor(options: ProviderCatalogSessionOptions) {
this.host = options.host;
this.providerSnapshotManager = options.providerSnapshotManager;
this.providerUsageService = options.providerUsageService;
this.logger = options.logger;
}
start(): void {
const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => {
// COMPAT(providersSnapshot): keep provider visibility gating for older clients.
const visibleEntries = entries.filter((entry) =>
this.host.isProviderVisibleToClient(entry.provider),
);
const snapshotCwd = cwd === resolveSnapshotCwd() ? undefined : cwd;
this.host.emit({
type: "providers_snapshot_update",
payload: {
...(snapshotCwd ? { cwd: snapshotCwd } : {}),
entries: this.downgradeEntryModesForClient(visibleEntries),
generatedAt: new Date().toISOString(),
},
});
};
this.providerSnapshotManager.on("change", handleProviderSnapshotChange);
this.unsubscribeSnapshotEvents = () => {
this.providerSnapshotManager.off("change", handleProviderSnapshotChange);
};
}
dispose(): void {
if (this.unsubscribeSnapshotEvents) {
this.unsubscribeSnapshotEvents();
this.unsubscribeSnapshotEvents = null;
}
}
// COMPAT(customModeIcons): rewrite icons unknown to v0.1.83 clients (whose MODE_ICONS
// map is a closed enum and would render `undefined`, crashing in render). Drop
// this and the cap gate when floor >= v0.1.84.
private downgradeModeIconsForClient<T extends { icon?: string }>(modes: T[]): T[] {
if (this.host.supportsCustomModeIcons()) return modes;
return modes.map((mode) =>
mode.icon && !LEGACY_MODE_ICONS.has(mode.icon) ? { ...mode, icon: "ShieldCheck" } : mode,
);
}
private downgradeEntryModesForClient<T extends { modes?: { icon?: string }[] }>(
entries: T[],
): T[] {
if (this.host.supportsCustomModeIcons()) return entries;
return entries.map((entry) =>
entry.modes ? { ...entry, modes: this.downgradeModeIconsForClient(entry.modes) } : entry,
);
}
private emitProviderDisabledResponse(
kind: "models" | "modes",
provider: AgentProvider,
requestId: string,
fetchedAt: string,
): void {
const payload = {
provider,
error: `Provider ${provider} is disabled`,
fetchedAt,
requestId,
};
if (kind === "models") {
this.host.emit({ type: "list_provider_models_response", payload });
} else {
this.host.emit({ type: "list_provider_modes_response", payload });
}
}
async handleListProviderModelsRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>,
): Promise<void> {
const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined);
const fetchedAt = new Date().toISOString();
const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
if (!entry) {
this.host.emit({
type: "list_provider_models_response",
payload: {
provider: msg.provider,
error: `Unknown provider: ${msg.provider}`,
fetchedAt,
requestId: msg.requestId,
},
});
return;
}
if (!entry.enabled) {
this.emitProviderDisabledResponse("models", msg.provider, msg.requestId, fetchedAt);
return;
}
if (entry.status === "ready") {
this.host.emit({
type: "list_provider_models_response",
payload: {
provider: msg.provider,
models: entry.models ?? [],
error: null,
fetchedAt: entry.fetchedAt ?? fetchedAt,
requestId: msg.requestId,
},
});
return;
}
const errorMessage =
entry.status === "error"
? (entry.error ?? `Failed to list models for ${msg.provider}`)
: `Provider ${msg.provider} is not available`;
this.host.emit({
type: "list_provider_models_response",
payload: {
provider: msg.provider,
error: errorMessage,
fetchedAt,
requestId: msg.requestId,
},
});
}
async handleListProviderModesRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_modes_request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined);
const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
if (!entry) {
this.host.emit({
type: "list_provider_modes_response",
payload: {
provider: msg.provider,
error: `Unknown provider: ${msg.provider}`,
fetchedAt,
requestId: msg.requestId,
},
});
return;
}
if (!entry.enabled) {
this.emitProviderDisabledResponse("modes", msg.provider, msg.requestId, fetchedAt);
return;
}
if (entry.status === "ready") {
this.host.emit({
type: "list_provider_modes_response",
payload: {
provider: msg.provider,
modes: this.downgradeModeIconsForClient(entry.modes ?? []),
error: null,
fetchedAt: entry.fetchedAt ?? fetchedAt,
requestId: msg.requestId,
},
});
return;
}
const errorMessage =
entry.status === "error"
? (entry.error ?? `Failed to list modes for ${msg.provider}`)
: `Provider ${msg.provider} is not available`;
this.host.emit({
type: "list_provider_modes_response",
payload: {
provider: msg.provider,
error: errorMessage,
fetchedAt,
requestId: msg.requestId,
},
});
}
private async getProviderSnapshotEntryForRead(
cwd: string,
provider: AgentProvider,
): Promise<ProviderSnapshotEntry | undefined> {
const manager = this.providerSnapshotManager;
const findEntry = () =>
manager.getSnapshot(cwd).find((candidate) => candidate.provider === provider);
let entry = findEntry();
if (entry && !entry.enabled) {
return entry;
}
if (!entry || entry.status === "loading") {
// Awaits the in-flight warmup (deduped per-cwd) so old clients still get
// a resolved answer rather than a loading placeholder.
await manager.warmUpSnapshotForCwd({ cwd, providers: [provider] });
entry = findEntry();
}
return entry;
}
private buildDraftAgentSessionConfig(draftConfig: {
provider: AgentProvider;
cwd: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
}): AgentSessionConfig {
return {
provider: draftConfig.provider,
cwd: expandTilde(draftConfig.cwd),
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
...(draftConfig.model ? { model: draftConfig.model } : {}),
...(draftConfig.thinkingOptionId ? { thinkingOptionId: draftConfig.thinkingOptionId } : {}),
...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}),
};
}
async handleListProviderFeaturesRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_features_request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
try {
const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig);
const features = await this.host.listDraftFeatures(sessionConfig);
this.host.emit({
type: "list_provider_features_response",
payload: {
provider: msg.draftConfig.provider,
features,
error: null,
fetchedAt,
requestId: msg.requestId,
},
});
} catch (error) {
this.logger.error(
{ err: error, provider: msg.draftConfig.provider, draftConfig: msg.draftConfig },
`Failed to list features for ${msg.draftConfig.provider}`,
);
this.host.emit({
type: "list_provider_features_response",
payload: {
provider: msg.draftConfig.provider,
error: getErrorMessage(error),
fetchedAt,
requestId: msg.requestId,
},
});
}
}
async handleListAvailableProvidersRequest(
msg: Extract<SessionInboundMessage, { type: "list_available_providers_request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
try {
const providers = (await this.host.listProviderAvailability()).filter((provider) =>
this.host.isProviderVisibleToClient(provider.provider),
);
this.host.emit({
type: "list_available_providers_response",
payload: {
providers,
error: null,
fetchedAt,
requestId: msg.requestId,
},
});
} catch (error) {
this.logger.error({ err: error }, "Failed to list provider availability");
this.host.emit({
type: "list_available_providers_response",
payload: {
providers: [],
error: getErrorMessage(error),
fetchedAt,
requestId: msg.requestId,
},
});
}
}
async handleGetProvidersSnapshotRequest(
msg: Extract<SessionInboundMessage, { type: "get_providers_snapshot_request" }>,
): Promise<void> {
// COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow.
const entries = this.providerSnapshotManager
.getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined)
.filter((entry) => this.host.isProviderVisibleToClient(entry.provider));
this.host.emit({
type: "get_providers_snapshot_response",
payload: {
entries: this.downgradeEntryModesForClient(entries),
generatedAt: new Date().toISOString(),
requestId: msg.requestId,
},
});
}
async handleRefreshProvidersSnapshotRequest(
msg: Extract<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
): Promise<void> {
if (msg.cwd) {
await this.providerSnapshotManager.refreshSnapshotForCwd({
cwd: expandTilde(msg.cwd),
providers: msg.providers,
});
} else {
await this.providerSnapshotManager.refreshSettingsSnapshot({
providers: msg.providers,
});
}
this.host.emit({
type: "refresh_providers_snapshot_response",
payload: {
acknowledged: true,
requestId: msg.requestId,
},
});
}
async handleProviderDiagnosticRequest(
msg: Extract<SessionInboundMessage, { type: "provider_diagnostic_request" }>,
): Promise<void> {
try {
const { diagnostic } = await this.providerSnapshotManager.getProviderDiagnostic(msg.provider);
this.host.emit({
type: "provider_diagnostic_response",
payload: {
provider: msg.provider,
diagnostic,
requestId: msg.requestId,
},
});
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error(
{ err, provider: msg.provider },
`Failed to get provider diagnostic for ${msg.provider}`,
);
this.host.emit({
type: "rpc_error",
payload: {
requestId: msg.requestId,
requestType: msg.type,
error: `Failed to get provider diagnostic: ${err.message}`,
code: "provider_diagnostic_failed",
},
});
}
}
async handleProviderUsageListRequest(
msg: Extract<SessionInboundMessage, { type: "provider.usage.list.request" }>,
): Promise<void> {
try {
const usage = await this.providerUsageService.listUsage();
this.host.emit({
type: "provider.usage.list.response",
payload: {
requestId: msg.requestId,
fetchedAt: usage.fetchedAt,
providers: usage.providers,
},
});
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error({ err }, "Failed to list provider usage");
this.host.emit({
type: "rpc_error",
payload: {
requestId: msg.requestId,
requestType: msg.type,
error: `Failed to list provider usage: ${err.message}`,
code: "provider_usage_list_failed",
},
});
}
}
}
+22
View File
@@ -25,6 +25,28 @@ const READ_ONLY_GIT_ENV = {
GIT_OPTIONAL_LOCKS: "0",
} as const;
/**
* Why a git mutation is forcing a workspace snapshot refresh. Shared between the
* Session shell (which owns the refresh primitive) and the checkout subsystem
* (which triggers most of these reasons after a write).
*/
export type GitMutationRefreshReason =
| "commit-changes"
| "pull"
| "push"
| "merge-to-base"
| "merge-from-base"
| "merge-pr"
| "enable-pr-auto-merge"
| "disable-pr-auto-merge"
| "create-pr"
| "switch-branch"
| "rename-branch"
| "create-branch"
| "stash-push"
| "stash-pop"
| "create-worktree";
const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000;
const PULL_REQUEST_STATUS_CACHE_MAX = 1_000;
const DEFAULT_SHORTSTAT_CACHE_TTL_MS = 15_000;