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:
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+170
-2545
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user