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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,13 @@
"ownerSymbol": "OverlaysRoot",
"count": 1
},
{
"implementation": "src/renderer/features/session-settings/controller/use-session-settings-controller.ts",
"symbol": "useSessionSettingsController",
"owner": "src/renderer/features/session-settings/ui/session-settings-provider.tsx",
"ownerSymbol": "SessionSettingsProvider",
"count": 1
},
{
"implementation": "src/renderer/features/task-entry/controller/use-task-entry-controller.ts",
"symbol": "useTaskEntryController",
Expand Down Expand Up @@ -714,7 +721,6 @@
"window.maka.notifications.runEnded": 1,
"window.maka.onboarding.setMilestone": 1,
"window.maka.sessions.compact": 1,
"window.maka.sessions.getPlanState": 1,
"window.maka.sessions.listActiveInteractions": 1,
"window.maka.sessions.listTurnLandmarks": 1,
"window.maka.sessions.promoteQueueEntry": 1,
Expand Down Expand Up @@ -854,7 +860,7 @@
"react": 1
},
"importSpecifiers": 99,
"nonTriviaTokens": 12972
"nonTriviaTokens": 12863
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@ import { parseHTML } from 'linkedom';
import {
SessionSettingsServicesProvider,
type SessionSettingsServices,
useSessionSettingIntent,
} from '../../renderer/features/session-settings/index.js';
import { useSessionSettingsController } from '../../renderer/features/session-settings/testing.js';
import { reconcileRuntimeHostSessionCatalog } from '../../preload/runtime-host-session-catalog.js';
import {
createSessionCatalogController,
type SessionCatalogController,
} from '../../renderer/application/contracts/session-catalog/session-catalog-state.js';
import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js';

type Controller = ReturnType<typeof useSessionSettingIntent<{ sessionId?: string }>>;
type Controller = ReturnType<typeof useSessionSettingsController<{ sessionId?: string }>>;

const originalGlobals = {
document: globalThis.document,
Expand Down Expand Up @@ -479,15 +479,15 @@ function Harness(props: {
model: string;
}): void;
}) {
const controller = useSessionSettingIntent({
const controller = useSessionSettingsController({
catalog: props.catalog,
isActiveSession: () => true,
newSessionPermissionMode: 'ask',
refreshCatalog: async () => {},
saveComposerDefaults: props.saveComposerDefaults,
writeFailureCopy: () => ({ title: 'failed', description: 'failed' }),
showSessionError: () => {},
planMode: { write: async () => true },
planMode: { reportExecutionActive: () => {}, confirmDiscard: async () => true },
captureOwner: () => props.owner,
isOwnerActive: () => true,
setNewTaskPermissionMode: props.setNewTaskPermissionMode,
Expand All @@ -501,15 +501,15 @@ function CausalRetirementHarness(props: {
capture(controller: Controller): void;
catalog: SessionCatalogController;
}) {
const controller = useSessionSettingIntent({
const controller = useSessionSettingsController({
catalog: props.catalog,
isActiveSession: () => true,
newSessionPermissionMode: 'ask',
refreshCatalog: async () => {},
saveComposerDefaults: () => {},
writeFailureCopy: () => ({ title: 'failed', description: 'failed' }),
showSessionError: () => {},
planMode: { write: async () => true },
planMode: { reportExecutionActive: () => {}, confirmDiscard: async () => true },
captureOwner: () => ({ sessionId: 'session-a' }),
isOwnerActive: () => true,
setNewTaskPermissionMode: () => {},
Expand Down Expand Up @@ -545,6 +545,7 @@ function createServices(
overrides: Partial<SessionSettingsServices> = {},
): SessionSettingsServices {
return {
getPlanState: async (sessionId) => ({ schemaVersion: 1, sessionId, storeVersion: 0, proposals: [], executions: [] }),
setModelConfiguration: async () => ({} as DesktopSessionSummary),
setPermissionMode: async () => ({} as DesktopSessionSummary),
setOrchestrationMode: async () => ({} as DesktopSessionSummary),
Expand Down
69 changes: 69 additions & 0 deletions apps/desktop/src/main/__tests__/session-settings-plan-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { PlanSessionState } from '@maka/core/plan';
import type { SessionSettingsServices } from '../../renderer/features/session-settings/index.js';
import { writeSessionPlanMode } from '../../renderer/features/session-settings/testing.js';

function fixture(state: Partial<PlanSessionState> = {}, confirmed = true) {
const writes: unknown[] = [];
const errors: string[] = [];
const confirmations: string[] = [];
const services = {
getPlanState: async (sessionId) => ({ schemaVersion: 1, sessionId, storeVersion: 0, proposals: [], executions: [], ...state }),
abandonPlanProposal: async (...args) => { writes.push(['abandon', ...args]); },
setCollaborationMode: async (...args) => { writes.push(['mode', ...args]); return {} as never; },
} satisfies Pick<SessionSettingsServices, 'getPlanState' | 'abandonPlanProposal' | 'setCollaborationMode'>;
const presentation = {
reportExecutionActive: (id: string) => { errors.push(id); },
confirmDiscard: async (title: string) => { confirmations.push(title); return confirmed; },
};
return { services, presentation, writes, errors, confirmations };
}

test('entering Plan is refused when the Host reports an active execution', async () => {
const f = fixture({ activeExecutionId: 'execution' });
assert.equal(await writeSessionPlanMode(f.services, f.presentation, 'a', true), false);
assert.deepEqual(f.writes, []);
assert.deepEqual(f.errors, ['a']);
});

test('canceling the latest pending proposal confirmation makes no write', async () => {
const f = fixture({ latestProposalId: 'p', proposals: [{ proposalId: 'p', title: 'Keep this', status: 'pending_approval' } as never] }, false);
assert.equal(await writeSessionPlanMode(f.services, f.presentation, 'a', false), false);
assert.deepEqual(f.confirmations, ['Keep this']);
assert.deepEqual(f.writes, []);
});

test('ordinary Plan transitions write only collaboration mode, preserving orchestration', async () => {
const f = fixture();
assert.equal(await writeSessionPlanMode(f.services, f.presentation, 'a', true), true);
assert.equal(await writeSessionPlanMode(f.services, f.presentation, 'a', false), true);
assert.deepEqual(f.writes, [['mode', 'a', 'plan'], ['mode', 'a', 'agent']]);
assert.deepEqual(f.confirmations, []);
});

test('Host read errors propagate to the intent error path without writing', async () => {
const f = fixture();
f.services.getPlanState = async () => { throw new Error('offline'); };
await assert.rejects(writeSessionPlanMode(f.services, f.presentation, 'a', true), /offline/);
assert.deepEqual(f.writes, []);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { afterEach, test } from 'node:test';
import { act, createElement, StrictMode } from 'react';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
import {
SessionSettingsProvider,
SessionSettingsServicesProvider,
useSessionSettingIntent,
type SessionSettingsServices,
} from '../../renderer/features/session-settings/index.js';
import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js';
import { createSessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js';

const model = { llmConnectionId: 'connection', llmConnectionSlug: 'openai', model: 'next' };
const session = (id: string) => ({ id, revision: 1, permissionMode: 'ask', ...model } as DesktopSessionSummary);

function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}

function services(overrides: Partial<SessionSettingsServices> = {}): SessionSettingsServices {
return {
getPlanState: async (sessionId) => ({ schemaVersion: 1, sessionId, storeVersion: 0, proposals: [], executions: [] }),
setModelConfiguration: async (id, input) => ({ ...session(id), ...input, thinkingLevel: input.thinkingLevel ?? undefined, revision: 2 }),
setPermissionMode: async (id, mode) => ({ ...session(id), permissionMode: mode, revision: 2 }),
setOrchestrationMode: async (id, mode) => ({ ...session(id), orchestrationMode: mode, revision: 2 }),
setCollaborationMode: async (id, mode) => ({ ...session(id), collaborationMode: mode, revision: 2 }),
abandonPlanProposal: async () => {},
...overrides,
};
}

afterEach(cleanupFakeDom);

async function mount(options: {
services?: SessionSettingsServices;
confirmBypass?: () => Promise<boolean>;
confirmDiscard?: (title: string) => Promise<boolean>;
strict?: boolean;
} = {}) {
const { root } = installReactRenderer();
const catalog = createSessionCatalogController();
catalog.commitSessions([session('a'), session('b')]);
let selected: string | undefined = 'a';
let owner: { sessionId?: string } = { sessionId: selected };
let snapshot!: ReturnType<typeof useSessionSettingIntent>;
let renders = 0;
let frameRenders = 0;
const errors: unknown[] = [];
const service = options.services ?? services();
function Frame() { frameRenders += 1; return null; }
function Shell() {
renders += 1;
snapshot = useSessionSettingIntent(selected);
return createElement(SessionSettingsProvider, {
bridge: snapshot.bridge,
input: {
catalog,
isActiveSession: (id) => id === selected,
newSessionPermissionMode: 'ask',
refreshCatalog: async () => {},
saveComposerDefaults: () => {},
writeFailureCopy: () => ({ title: 'failed', description: 'failed' }),
showSessionError: (...args) => { errors.push(args); },
planMode: {
reportExecutionActive: (id) => { errors.push(['execution-active', id]); },
confirmDiscard: options.confirmDiscard ?? (async () => true),
},
captureOwner: () => owner,
isOwnerActive: (claim) => claim === owner,
setNewTaskPermissionMode: () => {},
confirmBypass: options.confirmBypass ?? (async () => true),
},
}, createElement(Frame));
}
const render = () => root.render(createElement(
SessionSettingsServicesProvider, { services: service },
options.strict ? createElement(StrictMode, null, createElement(Shell)) : createElement(Shell),
));
await act(render);
return {
root, errors, catalog,
current: () => snapshot,
renders: () => renders,
frameRenders: () => frameRenders,
select: async (id: string | undefined) => {
selected = id;
owner = { sessionId: selected };
await act(render);
},
};
}

test('inactive Session writes keep the shell and frame asleep; selection reads the right overlay immediately', async () => {
const write = deferred<DesktopSessionSummary>();
const h = await mount({ services: services({ setModelConfiguration: () => write.promise }) });
const commands = h.current().commands;
const initialRenders = h.renders();
const initialFrames = h.frameRenders();
let completion!: Promise<boolean>;
await act(() => { completion = commands.setSessionModel('b', model); });
assert.equal(h.renders(), initialRenders);
assert.equal(h.frameRenders(), initialFrames);
assert.equal(h.current().overlay.modelConfiguration, undefined);
await act(async () => { write.resolve({ ...session('b'), thinkingLevel: undefined, revision: 2 }); await completion; });
assert.equal(h.renders(), initialRenders);
assert.equal(h.frameRenders(), initialFrames);
await h.select('b');
assert.equal(h.current().overlay.modelConfiguration?.modelTarget.model, 'next');
assert.equal(h.current().commands, commands);
await h.select('a');
assert.equal(h.current().overlay.modelConfiguration, undefined);
await act(() => h.root.unmount());
});

test('catalog observations retire only the acknowledged Session overlay without waking the shell', async () => {
const h = await mount();
const commands = h.current().commands;
const initialRenders = h.renders();
const initialFrames = h.frameRenders();
await act(async () => { assert.equal(await commands.setSessionModel('b', model), true); });
const overlay = () => h.current().bridge.getState().modelConfiguration.b;
assert.equal(overlay()?.modelTarget.model, 'next');

await act(() => h.catalog.commitSessions([{ ...session('a'), revision: 2 }, session('b')]));
assert.equal(overlay()?.modelTarget.model, 'next');
await act(() => h.catalog.commitSessions([{ ...session('a'), revision: 2 }, { ...session('b'), revision: 2 }]));
assert.equal(overlay(), undefined);
assert.equal(h.renders(), initialRenders);
assert.equal(h.frameRenders(), initialFrames);
assert.equal(h.current().commands, commands);
await h.select('b');
assert.equal(h.current().overlay.modelConfiguration, undefined);
await act(() => h.root.unmount());
});

test('active optimistic state rolls back on failure through the same stable command port', async () => {
const write = deferred<DesktopSessionSummary>();
const h = await mount({ services: services({ setModelConfiguration: () => write.promise }) });
const commands = h.current().commands;
let completion!: Promise<boolean>;
await act(() => { completion = commands.setSessionModel('a', model); });
assert.equal(h.current().overlay.modelConfiguration?.modelTarget.model, 'next');
let result = true;
await act(async () => { write.reject(new Error('Host unavailable')); result = await completion; });
assert.equal(result, false);
assert.equal(h.current().overlay.modelConfiguration, undefined);
assert.equal(h.current().commands, commands);
assert.deepEqual(h.errors, [['a', 'failed', 'failed']]);
await act(() => h.root.unmount());
});

test('a bypass confirmation cannot write after its captured selection owner changes', async () => {
const confirmation = deferred<boolean>();
const writes: unknown[] = [];
const h = await mount({
confirmBypass: () => confirmation.promise,
services: services({ setPermissionMode: async (...args) => { writes.push(args); return session(args[0]); } }),
});
let completion!: Promise<boolean>;
await act(() => { completion = h.current().commands.setPermissionMode('bypass'); });
await h.select('b');
let result = true;
await act(async () => { confirmation.resolve(true); result = await completion; });
assert.equal(result, false);
assert.deepEqual(writes, []);
await act(() => h.root.unmount());
});

test('clear retires an in-flight intent, and StrictMode cleanup disconnects retained commands', async () => {
const write = deferred<DesktopSessionSummary>();
let writes = 0;
const h = await mount({ strict: true, services: services({ setModelConfiguration: () => { writes += 1; return write.promise; } }) });
const commands = h.current().commands;
let completion!: Promise<boolean>;
await act(() => { completion = commands.setSessionModel('a', model); });
assert.equal(writes, 1);
await act(() => commands.clear('a'));
assert.equal(await completion, false);
assert.equal(h.current().overlay.modelConfiguration, undefined);
await act(() => h.root.unmount());
assert.equal(await commands.setSessionModel('b', model), false);
await act(async () => write.resolve({ ...session('a'), thinkingLevel: undefined, revision: 2 }));
assert.equal(writes, 1);
});

test('Plan discard remains bound to the requested Session while the user switches away', async () => {
const confirmation = deferred<boolean>();
const writes: unknown[] = [];
const h = await mount({
confirmDiscard: () => confirmation.promise,
services: services({
getPlanState: async (sessionId) => ({
schemaVersion: 1, sessionId, storeVersion: 1, executions: [], latestProposalId: 'proposal-a',
proposals: [{ proposalId: 'proposal-a', title: 'Original plan', status: 'pending_approval' } as never],
}),
abandonPlanProposal: async (...args) => { writes.push(args); },
setCollaborationMode: async () => { assert.fail('abandon already leaves Plan'); },
}),
});
let completion!: Promise<boolean>;
await act(() => { completion = h.current().commands.setPlanMode('a', false); });
await h.select('b');
let result = false;
await act(async () => { confirmation.resolve(true); result = await completion; });
assert.equal(result, true);
assert.deepEqual(writes, [['a', 'proposal-a']]);
assert.equal(h.current().overlay.planMode, undefined);
await act(() => h.root.unmount());
});
Loading