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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions apps/desktop/src/main/__tests__/session-mode-ipc-main.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Plan and orchestration are two Session fields with two lifetimes, so they
* are two channels here, and each writes only its own field. A Plan excursion
* that cleared the orchestration default would lose it for the execution the
* plan was written for — Runtime leaves Plan by itself on approval, and the
* default has to still be there when it does.
*/
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import type { IpcMain } from 'electron';
import type { DesktopSessionConfigurationPatch } from '../runtime-host-client.js';
import {
registerRuntimeHostSessionCatalogIpc,
type RuntimeHostSessionCatalogIpcDeps,
} from '../runtime-host-session-catalog-ipc-main.js';

type Handler = (event: unknown, ...args: unknown[]) => unknown;

/** Only the fields `toDesktopHostSessionSummary` reads back out. */
function projection(sessionId: string) {
return {
id: sessionId,
revision: 1,
workspace: { hostCwd: '/tmp/session', target: { kind: 'path' as const } },
name: 'Session',
isFlagged: false,
isArchived: false,
labels: [],
status: 'active' as const,
createdAt: 1,
lastUsedAt: 1,
backend: 'fake' as const,
llmConnectionSlug: 'fake',
connectionLocked: false,
model: 'fake-model',
permissionMode: 'ask' as const,
collaborationMode: 'agent' as const,
orchestrationMode: 'default' as const,
};
}

function harness(patches: DesktopSessionConfigurationPatch[]) {
const handlers = new Map<string, Handler>();
const ipcMain = {
handle(channel: string, handler: Handler) {
handlers.set(channel, handler);
},
};
const deps = {
client: {
async updateSessionConfiguration(sessionId: string, patch: DesktopSessionConfigurationPatch) {
patches.push(patch);
return projection(sessionId);
},
},
resolveCreateProject: async () => ({}),
emitSessionsChanged() {},
releaseSessionResources() {},
sessionCopyCleanup: { recover: async () => ({ cleaned: [], failed: [] }) },
} as unknown as RuntimeHostSessionCatalogIpcDeps;
registerRuntimeHostSessionCatalogIpc(deps, ipcMain as unknown as IpcMain);
return {
invoke: (channel: string, ...args: unknown[]) => {
const handler = handlers.get(channel);
assert.ok(handler, `missing handler: ${channel}`);
return handler({}, ...args);
},
channels: handlers,
};
}

test('entering or leaving Plan writes the collaboration field alone', async () => {
const patches: DesktopSessionConfigurationPatch[] = [];
const ipc = harness(patches);

await ipc.invoke('sessions:setCollaborationMode', 'session-1', 'plan');
await ipc.invoke('sessions:setCollaborationMode', 'session-1', 'agent');

assert.deepEqual(patches, [{ collaborationMode: 'plan' }, { collaborationMode: 'agent' }]);
});

test('the orchestration default writes its own field alone', async () => {
const patches: DesktopSessionConfigurationPatch[] = [];
const ipc = harness(patches);

await ipc.invoke('sessions:setOrchestrationMode', 'session-1', 'swarm');
await ipc.invoke('sessions:setOrchestrationMode', 'session-1', 'default');

assert.deepEqual(patches, [{ orchestrationMode: 'swarm' }, { orchestrationMode: 'default' }]);
});

test('a Plan Session keeps the orchestration default it was carrying', async () => {
const patches: DesktopSessionConfigurationPatch[] = [];
const ipc = harness(patches);

await ipc.invoke('sessions:setOrchestrationMode', 'session-1', 'swarm');
await ipc.invoke('sessions:setCollaborationMode', 'session-1', 'plan');

// Nothing in the Plan write names `orchestrationMode`, so the merge at the
// Host leaves Swarm standing. Plan strips the tools it needs for as long as
// the excursion lasts; it does not end it.
assert.deepEqual(patches[1], { collaborationMode: 'plan' });
assert.equal('orchestrationMode' in (patches[1] ?? {}), false);
});

test('an unknown mode is refused rather than persisted', async () => {
const patches: DesktopSessionConfigurationPatch[] = [];
const ipc = harness(patches);

await assert.rejects(
ipc.invoke('sessions:setCollaborationMode', 'session-1', 'swarm') as Promise<unknown>,
/Invalid collaboration mode/,
);
await assert.rejects(
ipc.invoke('sessions:setOrchestrationMode', 'session-1', 'plan') as Promise<unknown>,
/Invalid orchestration mode/,
);
assert.deepEqual(patches, [], 'nothing reached the Host');
});
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ export function registerRuntimeHostSessionCatalogIpc(
if (!isPermissionMode(mode)) throw new Error(`Invalid permission mode: ${String(mode)}`);
return updateConfiguration(deps, sessionId, { permissionMode: mode }, 'mode-change');
});
// Two fields, two channels, one field each. Plan is a temporary
// collaboration excursion that Runtime ends by itself on approval or
// abandonment; orchestration is the Session's standing default for how a
// turn fans out. Runtime resolves the overlap by stripping the subagent and
// agent-graph tools while planning, and validates the two independently, so
// neither channel has any business writing the other's field.
ipcMain.handle(
'sessions:setCollaborationMode',
async (_event, sessionId: string, mode: unknown) => {
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,16 @@ export interface MakaBridge {
setFlagged(sessionId: string, isFlagged: boolean, options?: { revisionFamily?: boolean }): Promise<void>;
rename(sessionId: string, name: string, options?: { revisionFamily?: boolean }): Promise<void>;
setPermissionMode(sessionId: string, mode: PermissionMode): Promise<DesktopSessionSummary>;
/**
* Enter or leave Plan — a temporary collaboration excursion Runtime ends
* by itself once a proposal is approved or abandoned.
*/
setCollaborationMode(sessionId: string, mode: CollaborationMode): Promise<DesktopSessionSummary>;
/**
* The Session's standing default for how a turn fans out. Independent of
* Plan: different field, different lifetime, and Runtime resolves the
* overlap by stripping the tools Swarm and Graph need while planning.
*/
setOrchestrationMode(sessionId: string, mode: OrchestrationMode): Promise<DesktopSessionSummary>;
getPlanState(sessionId: string): Promise<PlanSessionState>;
subscribePlanChanges(sessionId: string, handler: () => void): () => void;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import type { UserQuestionResponse } from '@maka/core/user-question';
import type { PermissionMode } from '@maka/core/permission';
import type { CollaborationMode } from '@maka/core/collaboration';
import type { OrchestrationMode } from '@maka/core/orchestration';

import type { TurnOrchestration, SessionListFilter, RegenerateTurnInput } from '@maka/core/runtime-inputs';
import type { PlanSessionState } from '@maka/core/plan';
import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/search';
Expand Down
Loading