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 type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||||
import {
|
import {
|
||||||
decodeFileTransferFrame,
|
decodeFileTransferFrame,
|
||||||
|
encodeFileTransferFrame,
|
||||||
FileTransferOpcode,
|
FileTransferOpcode,
|
||||||
|
type FileTransferFrame,
|
||||||
} from "@getpaseo/protocol/binary-frames/index";
|
} from "@getpaseo/protocol/binary-frames/index";
|
||||||
import { Session } from "./session.js";
|
import { Session } from "./session.js";
|
||||||
|
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
|
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
|
||||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||||
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||||
import type { SessionOptions } from "./session.js";
|
import type { SessionOptions } from "./session.js";
|
||||||
|
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
|
||||||
import {
|
import {
|
||||||
asSessionInternals as asSessionInternalsHelper,
|
asSessionInternals as asSessionInternalsHelper,
|
||||||
asAgentManager,
|
asAgentManager,
|
||||||
@@ -212,6 +216,10 @@ interface SessionForTestOptions {
|
|||||||
stt?: SessionOptions["stt"];
|
stt?: SessionOptions["stt"];
|
||||||
voice?: SessionOptions["voice"];
|
voice?: SessionOptions["voice"];
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
|
serverId?: SessionOptions["serverId"];
|
||||||
|
daemonVersion?: SessionOptions["daemonVersion"];
|
||||||
|
daemonRuntimeConfig?: SessionOptions["daemonRuntimeConfig"];
|
||||||
|
downloadTokenStore?: SessionOptions["downloadTokenStore"];
|
||||||
messages?: unknown[];
|
messages?: unknown[];
|
||||||
binaryMessages?: Uint8Array[];
|
binaryMessages?: Uint8Array[];
|
||||||
}
|
}
|
||||||
@@ -246,7 +254,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
|||||||
onMessage: (message) => messages.push(message),
|
onMessage: (message) => messages.push(message),
|
||||||
onBinaryMessage: createBinaryMessageHandler(options.binaryMessages),
|
onBinaryMessage: createBinaryMessageHandler(options.binaryMessages),
|
||||||
logger,
|
logger,
|
||||||
downloadTokenStore: asDownloadTokenStore(),
|
downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(),
|
||||||
pushTokenStore: asPushTokenStore(),
|
pushTokenStore: asPushTokenStore(),
|
||||||
paseoHome: options.paseoHome ?? "/tmp/paseo-home",
|
paseoHome: options.paseoHome ?? "/tmp/paseo-home",
|
||||||
agentManager: asAgentManager({
|
agentManager: asAgentManager({
|
||||||
@@ -294,6 +302,9 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
|||||||
getDaemonTcpPort: options.getDaemonTcpPort,
|
getDaemonTcpPort: options.getDaemonTcpPort,
|
||||||
getDaemonTcpHost: options.getDaemonTcpHost,
|
getDaemonTcpHost: options.getDaemonTcpHost,
|
||||||
voice: options.voice,
|
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(
|
function createStoredAgentRecord(
|
||||||
overrides: Pick<StoredAgentRecord, "id" | "cwd"> & Partial<StoredAgentRecord>,
|
overrides: Pick<StoredAgentRecord, "id" | "cwd"> & Partial<StoredAgentRecord>,
|
||||||
): 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(
|
function createWorkspaceGitSnapshot(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
overrides?: {
|
overrides?: {
|
||||||
@@ -1063,7 +1394,7 @@ describe("session checkout merge handling", () => {
|
|||||||
|
|
||||||
checkoutGitMocks.mergeToBase.mockResolvedValue("/tmp/base-worktree");
|
checkoutGitMocks.mergeToBase.mockResolvedValue("/tmp/base-worktree");
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutMergeRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_merge_request",
|
type: "checkout_merge_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
baseRef: "main",
|
baseRef: "main",
|
||||||
@@ -1111,7 +1442,7 @@ describe("session checkout merge handling", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ workspaceGitService, messages });
|
const session = createSessionForTest({ workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutMergeFromBaseRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_merge_from_base_request",
|
type: "checkout_merge_from_base_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
baseRef: "main",
|
baseRef: "main",
|
||||||
@@ -1149,7 +1480,7 @@ describe("session checkout merge handling", () => {
|
|||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
checkoutGitMocks.mergeFromBase.mockResolvedValue(undefined);
|
checkoutGitMocks.mergeFromBase.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutMergeFromBaseRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_merge_from_base_request",
|
type: "checkout_merge_from_base_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
baseRef: "main",
|
baseRef: "main",
|
||||||
@@ -1241,7 +1572,7 @@ diff --git a/file.txt b/file.txt
|
|||||||
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
|
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
|
||||||
const session = createSessionForTest({ workspaceGitService });
|
const session = createSessionForTest({ workspaceGitService });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutCommitRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_commit_request",
|
type: "checkout_commit_request",
|
||||||
cwd: join(repoRoot, "nested"),
|
cwd: join(repoRoot, "nested"),
|
||||||
message: "",
|
message: "",
|
||||||
@@ -1262,7 +1593,7 @@ diff --git a/file.txt b/file.txt
|
|||||||
|
|
||||||
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
|
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutCommitRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_commit_request",
|
type: "checkout_commit_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
message: "Ship it",
|
message: "Ship it",
|
||||||
@@ -1316,7 +1647,7 @@ diff --git a/file.txt b/file.txt
|
|||||||
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
|
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
|
||||||
const session = createSessionForTest({ workspaceGitService, messages });
|
const session = createSessionForTest({ workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutCommitRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_commit_request",
|
type: "checkout_commit_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
message: "",
|
message: "",
|
||||||
@@ -1421,7 +1752,7 @@ diff --git a/file.txt b/file.txt
|
|||||||
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
|
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
|
||||||
const session = createSessionForTest({ workspaceGitService, messages });
|
const session = createSessionForTest({ workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutCommitRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_commit_request",
|
type: "checkout_commit_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
message: "",
|
message: "",
|
||||||
@@ -1450,7 +1781,7 @@ diff --git a/file.txt b/file.txt
|
|||||||
const session = createSessionForTest({ workspaceGitService, messages });
|
const session = createSessionForTest({ workspaceGitService, messages });
|
||||||
checkoutGitMocks.commitChanges.mockRejectedValue(new Error("nothing to commit"));
|
checkoutGitMocks.commitChanges.mockRejectedValue(new Error("nothing to commit"));
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutCommitRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_commit_request",
|
type: "checkout_commit_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
message: "Ship it",
|
message: "Ship it",
|
||||||
@@ -1540,7 +1871,7 @@ diff --git a/file.txt b/file.txt
|
|||||||
});
|
});
|
||||||
const session = createSessionForTest({ workspaceGitService });
|
const session = createSessionForTest({ workspaceGitService });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutPrCreateRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_pr_create_request",
|
type: "checkout_pr_create_request",
|
||||||
cwd: join(repoRoot, "nested"),
|
cwd: join(repoRoot, "nested"),
|
||||||
baseRef: "main",
|
baseRef: "main",
|
||||||
@@ -1585,7 +1916,7 @@ diff --git a/file.txt b/file.txt
|
|||||||
});
|
});
|
||||||
const session = createSessionForTest({ workspaceGitService, messages });
|
const session = createSessionForTest({ workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutPrCreateRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_pr_create_request",
|
type: "checkout_pr_create_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
baseRef: "main",
|
baseRef: "main",
|
||||||
@@ -1724,7 +2055,7 @@ diff --git a/file.txt b/file.txt
|
|||||||
});
|
});
|
||||||
const session = createSessionForTest({ workspaceGitService, messages });
|
const session = createSessionForTest({ workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutPrCreateRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_pr_create_request",
|
type: "checkout_pr_create_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
baseRef: "main",
|
baseRef: "main",
|
||||||
@@ -1766,7 +2097,7 @@ diff --git a/file.txt b/file.txt
|
|||||||
});
|
});
|
||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutPrCreateRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_pr_create_request",
|
type: "checkout_pr_create_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
baseRef: "main",
|
baseRef: "main",
|
||||||
@@ -1828,7 +2159,7 @@ describe("session checkout pull request merge", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutPrMergeRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_pr_merge_request",
|
type: "checkout_pr_merge_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
mergeMethod: "squash",
|
mergeMethod: "squash",
|
||||||
@@ -1925,7 +2256,7 @@ describe("session checkout pull request merge", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutPrMergeRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_pr_merge_request",
|
type: "checkout_pr_merge_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
mergeMethod: "squash",
|
mergeMethod: "squash",
|
||||||
@@ -1978,7 +2309,7 @@ describe("session checkout pull request merge", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutPrMergeRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_pr_merge_request",
|
type: "checkout_pr_merge_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
mergeMethod: "squash",
|
mergeMethod: "squash",
|
||||||
@@ -2040,7 +2371,7 @@ describe("session checkout pull request merge", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutPrMergeRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_pr_merge_request",
|
type: "checkout_pr_merge_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
mergeMethod: "merge",
|
mergeMethod: "merge",
|
||||||
@@ -2103,7 +2434,7 @@ describe("session checkout pull request auto-merge", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
await session.handleMessage({
|
||||||
type: "checkout.github.set_auto_merge.request",
|
type: "checkout.github.set_auto_merge.request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -2169,7 +2500,7 @@ describe("session checkout pull request auto-merge", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
await session.handleMessage({
|
||||||
type: "checkout.github.set_auto_merge.request",
|
type: "checkout.github.set_auto_merge.request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -2232,7 +2563,7 @@ describe("session checkout pull request auto-merge", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
await session.handleMessage({
|
||||||
type: "checkout.github.set_auto_merge.request",
|
type: "checkout.github.set_auto_merge.request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -2281,7 +2612,7 @@ describe("session checkout pull request auto-merge", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
await session.handleMessage({
|
||||||
type: "checkout.github.set_auto_merge.request",
|
type: "checkout.github.set_auto_merge.request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -2337,7 +2668,7 @@ describe("session checkout pull request auto-merge", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
await session.handleMessage({
|
||||||
type: "checkout.github.set_auto_merge.request",
|
type: "checkout.github.set_auto_merge.request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -2392,7 +2723,7 @@ describe("session checkout pull request auto-merge", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
await session.handleMessage({
|
||||||
type: "checkout.github.set_auto_merge.request",
|
type: "checkout.github.set_auto_merge.request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -2426,7 +2757,7 @@ describe("session checkout pull and push handling", () => {
|
|||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
checkoutGitMocks.pullCurrentBranch.mockResolvedValue(undefined);
|
checkoutGitMocks.pullCurrentBranch.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutPullRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_pull_request",
|
type: "checkout_pull_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
requestId: "request-pull",
|
requestId: "request-pull",
|
||||||
@@ -2456,7 +2787,7 @@ describe("session checkout pull and push handling", () => {
|
|||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
checkoutGitMocks.pushCurrentBranch.mockResolvedValue(undefined);
|
checkoutGitMocks.pushCurrentBranch.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutPushRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_push_request",
|
type: "checkout_push_request",
|
||||||
cwd: "/tmp/request-worktree",
|
cwd: "/tmp/request-worktree",
|
||||||
requestId: "request-push",
|
requestId: "request-push",
|
||||||
@@ -3034,7 +3365,7 @@ describe("session checkout switch branch handling", () => {
|
|||||||
const session = createSessionForTest({ github, workspaceGitService, messages });
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
checkoutGitMocks.checkoutResolvedBranch.mockResolvedValue({ source: "local" });
|
checkoutGitMocks.checkoutResolvedBranch.mockResolvedValue({ source: "local" });
|
||||||
|
|
||||||
await asSessionInternals(session).handleCheckoutSwitchBranchRequest({
|
await session.handleMessage({
|
||||||
type: "checkout_switch_branch_request",
|
type: "checkout_switch_branch_request",
|
||||||
cwd: "/tmp/repo",
|
cwd: "/tmp/repo",
|
||||||
branch: "release",
|
branch: "release",
|
||||||
@@ -3323,7 +3654,7 @@ describe("session stash list handling", () => {
|
|||||||
};
|
};
|
||||||
const session = createSessionForTest({ workspaceGitService, messages });
|
const session = createSessionForTest({ workspaceGitService, messages });
|
||||||
|
|
||||||
await asSessionInternals(session).handleStashListRequest({
|
await session.handleMessage({
|
||||||
type: "stash_list_request",
|
type: "stash_list_request",
|
||||||
cwd: "/tmp/repo",
|
cwd: "/tmp/repo",
|
||||||
paseoOnly: true,
|
paseoOnly: true,
|
||||||
@@ -3354,7 +3685,7 @@ describe("session stash mutation handling", () => {
|
|||||||
truncated: false,
|
truncated: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await asSessionInternals(session).handleStashSaveRequest({
|
await session.handleMessage({
|
||||||
type: "stash_save_request",
|
type: "stash_save_request",
|
||||||
cwd: "/tmp/repo",
|
cwd: "/tmp/repo",
|
||||||
branch: "feature",
|
branch: "feature",
|
||||||
@@ -3388,7 +3719,7 @@ describe("session stash mutation handling", () => {
|
|||||||
truncated: false,
|
truncated: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await asSessionInternals(session).handleStashPopRequest({
|
await session.handleMessage({
|
||||||
type: "stash_pop_request",
|
type: "stash_pop_request",
|
||||||
cwd: "/tmp/repo",
|
cwd: "/tmp/repo",
|
||||||
stashIndex: 0,
|
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,
|
CheckoutSession,
|
||||||
type CheckoutSessionHost,
|
type CheckoutSessionHost,
|
||||||
} from "./checkout-session.js";
|
} 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 { SessionOutboundMessage } from "../../messages.js";
|
||||||
import type {
|
import type {
|
||||||
CheckoutDiffCompareInput,
|
CheckoutDiffCompareInput,
|
||||||
@@ -54,24 +54,84 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) {
|
|||||||
return { subscriber, subscriptions, refreshedCwds };
|
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?: {
|
function makeCheckoutSession(options?: {
|
||||||
git?: Partial<WorkspaceGitService>;
|
git?: Partial<WorkspaceGitService>;
|
||||||
diff?: CheckoutDiffSubscriber;
|
diff?: CheckoutDiffSubscriber;
|
||||||
|
github?: Partial<GitHubService>;
|
||||||
|
host?: Partial<CheckoutSessionHost>;
|
||||||
}) {
|
}) {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
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({
|
const checkout = new CheckoutSession({
|
||||||
host,
|
host,
|
||||||
workspaceGitService: createNoopWorkspaceGitService(options?.git),
|
workspaceGitService: createNoopWorkspaceGitService(options?.git),
|
||||||
github: createGitHubService(),
|
github,
|
||||||
checkoutDiffManager:
|
checkoutDiffManager:
|
||||||
options?.diff ?? createFakeDiffSubscriber({ cwd: "", files: [], error: null }).subscriber,
|
options?.diff ?? createFakeDiffSubscriber({ cwd: "", files: [], error: null }).subscriber,
|
||||||
|
paseoHome: "/tmp/paseo-home",
|
||||||
|
worktreesRoot: undefined,
|
||||||
logger: pino({ level: "silent" }),
|
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 {
|
return {
|
||||||
cwd,
|
cwd,
|
||||||
git: {
|
git: {
|
||||||
@@ -81,7 +141,7 @@ function createGitSnapshot(cwd: string, currentBranch: string): WorkspaceGitRunt
|
|||||||
currentBranch,
|
currentBranch,
|
||||||
remoteUrl: null,
|
remoteUrl: null,
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
isDirty: false,
|
isDirty: overrides?.isDirty ?? false,
|
||||||
baseRef: null,
|
baseRef: null,
|
||||||
aheadBehind: null,
|
aheadBehind: null,
|
||||||
aheadOfOrigin: 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 type pino from "pino";
|
||||||
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
||||||
|
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||||
import type {
|
import type {
|
||||||
BranchSuggestionsRequest,
|
BranchSuggestionsRequest,
|
||||||
CheckoutRefreshRequest,
|
CheckoutRefreshRequest,
|
||||||
|
CheckoutRenameBranchRequest,
|
||||||
CheckoutStatusRequest,
|
CheckoutStatusRequest,
|
||||||
|
SessionInboundMessage,
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
SubscribeCheckoutDiffRequest,
|
SubscribeCheckoutDiffRequest,
|
||||||
UnsubscribeCheckoutDiffRequest,
|
UnsubscribeCheckoutDiffRequest,
|
||||||
@@ -21,15 +24,59 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
WorkspaceGitRuntimeSnapshot,
|
WorkspaceGitRuntimeSnapshot,
|
||||||
WorkspaceGitService,
|
WorkspaceGitService,
|
||||||
|
WorkspaceGitSnapshotOptions,
|
||||||
} from "../../workspace-git-service.js";
|
} from "../../workspace-git-service.js";
|
||||||
import { assertSafeGitRef } from "../../worktree-session.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";
|
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 {
|
export interface CheckoutSessionHost {
|
||||||
emit(msg: SessionOutboundMessage): void;
|
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
|
* The slice of CheckoutDiffManager that CheckoutSession needs: open a live diff
|
||||||
* subscription, and nudge open subscriptions to recompute after a mutation. The
|
* subscription, and nudge open subscriptions to recompute after a mutation. The
|
||||||
@@ -48,24 +95,30 @@ export interface CheckoutSessionOptions {
|
|||||||
workspaceGitService: WorkspaceGitService;
|
workspaceGitService: WorkspaceGitService;
|
||||||
github: GitHubService;
|
github: GitHubService;
|
||||||
checkoutDiffManager: CheckoutDiffSubscriber;
|
checkoutDiffManager: CheckoutDiffSubscriber;
|
||||||
|
paseoHome: string;
|
||||||
|
worktreesRoot: string | undefined;
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The read & live-stream side of a client's checkout view: status queries,
|
* A client's checkout view, both sides: the read & live-stream side (status
|
||||||
* branch validation/suggestions, manual refresh, and the live git-diff and
|
* queries, branch validation/suggestions, manual refresh, live git-diff and
|
||||||
* checkout-status subscriptions.
|
* 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
|
* Command operations keep the live diff in sync by calling scheduleDiffRefresh()
|
||||||
* GitHub-PR operations still live on Session; they keep the diff in sync by
|
* and refresh the workspace git snapshot through host.notifyGitMutation(); the
|
||||||
* calling scheduleDiffRefresh(), and the workspace git observer streams branch
|
* workspace git observer streams branch changes through emitStatusUpdate().
|
||||||
* changes through emitStatusUpdate().
|
|
||||||
*/
|
*/
|
||||||
export class CheckoutSession {
|
export class CheckoutSession {
|
||||||
|
private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:";
|
||||||
|
|
||||||
private readonly host: CheckoutSessionHost;
|
private readonly host: CheckoutSessionHost;
|
||||||
private readonly workspaceGitService: WorkspaceGitService;
|
private readonly workspaceGitService: WorkspaceGitService;
|
||||||
private readonly github: GitHubService;
|
private readonly github: GitHubService;
|
||||||
private readonly checkoutDiffManager: CheckoutDiffSubscriber;
|
private readonly checkoutDiffManager: CheckoutDiffSubscriber;
|
||||||
|
private readonly paseoHome: string;
|
||||||
|
private readonly worktreesRoot: string | undefined;
|
||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
private readonly diffSubscriptions = new Map<string, () => void>();
|
private readonly diffSubscriptions = new Map<string, () => void>();
|
||||||
|
|
||||||
@@ -74,6 +127,8 @@ export class CheckoutSession {
|
|||||||
this.workspaceGitService = options.workspaceGitService;
|
this.workspaceGitService = options.workspaceGitService;
|
||||||
this.github = options.github;
|
this.github = options.github;
|
||||||
this.checkoutDiffManager = options.checkoutDiffManager;
|
this.checkoutDiffManager = options.checkoutDiffManager;
|
||||||
|
this.paseoHome = options.paseoHome;
|
||||||
|
this.worktreesRoot = options.worktreesRoot;
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,13 +356,755 @@ export class CheckoutSession {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify the live diff subscriptions that the working tree at `cwd` changed.
|
* 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
|
* Called by the command handlers below after they mutate the repository.
|
||||||
* the repository.
|
|
||||||
*/
|
*/
|
||||||
scheduleDiffRefresh(cwd: string): void {
|
private scheduleDiffRefresh(cwd: string): void {
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
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 {
|
cleanup(): void {
|
||||||
for (const unsubscribe of this.diffSubscriptions.values()) {
|
for (const unsubscribe of this.diffSubscriptions.values()) {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
@@ -315,3 +1112,30 @@ export class CheckoutSession {
|
|||||||
this.diffSubscriptions.clear();
|
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",
|
GIT_OPTIONAL_LOCKS: "0",
|
||||||
} as const;
|
} 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 DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000;
|
||||||
const PULL_REQUEST_STATUS_CACHE_MAX = 1_000;
|
const PULL_REQUEST_STATUS_CACHE_MAX = 1_000;
|
||||||
const DEFAULT_SHORTSTAT_CACHE_TTL_MS = 15_000;
|
const DEFAULT_SHORTSTAT_CACHE_TTL_MS = 15_000;
|
||||||
|
|||||||
Reference in New Issue
Block a user