Skip to content
Open
2 changes: 1 addition & 1 deletion apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ async function seedParentRemovalSessions(userDataDir: string): Promise<void> {
cwd: path.join(userDataDir, 'project'),
llmConnectionSlug: 'e2e',
model: 'claude-sonnet-4-5-20250929',
permissionMode: 'execute',
permissionMode: 'ask',
name: PARENT_REMOVAL_CHILD_NAME,
labels: [],
subagentParent: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ function createActionsDeps() {
upsertSessionSummary: () => undefined,
newChatModel: null,
pendingNewChatThinkingLevel: null,
newChatPermissionMode: 'ask' as const,
newChatPermissionChoice: undefined,
newChatCollaborationMode: 'agent' as const,
newChatOrchestrationMode: 'default' as const,
newTaskTarget: { profileId: 'local', hostId: 'host-local', projectId: null },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ function createActionsDeps() {
toastApi: { error: () => undefined, info: () => undefined },
upsertSessionSummary: () => undefined,
newChatModel: null,
newChatPermissionMode: 'ask' as const,
pendingNewChatThinkingLevel: null,
newChatPermissionChoice: undefined,
newChatCollaborationMode: 'agent' as const,
newChatOrchestrationMode: 'default' as const,
newTaskTarget: { profileId: 'local', hostId: 'host-local', projectId: null },
Expand Down Expand Up @@ -155,7 +155,6 @@ describe('composer first-send cleanup', () => {
try {
const deps = {
...createActionsDeps(),
newChatPermissionMode: 'bypass' as const,
newChatModel: {
llmConnectionSlug: 'opencode-free',
model: 'mimo-v2.5-free',
Expand All @@ -171,7 +170,53 @@ describe('composer first-send cleanup', () => {
'opencode-free',
);
assert.equal((createInput as { model?: unknown }).model, 'mimo-v2.5-free');
// Ordinary creation carries no permission mode: the Host applies its own
// `chatDefaults`. Sending the offered default back as an explicit override
// would make a cached snapshot the authority and could create a full-access
// Session from a value another client already lowered.
assert.ok(!('permissionMode' in (createInput as Record<string, unknown>)));
});

it('sends a composer permission choice once without writing it to the Host default', async () => {
let createInput: unknown;
let settingsUpdates = 0;
const restoreWindow = installWindow({
newTasks: {
create: async (_target: unknown, input: unknown) => {
createInput = input;
return { id: 'session-1' };
},
},
settings: {
update: async () => {
settingsUpdates += 1;
return {};
},
},
sessions: {
send: async () => ({
ok: true,
attachments: [],
skillInvocation: { loaded: [], failed: [] },
}),
},
});

try {
const deps = {
...createActionsDeps(),
newChatPermissionChoice: 'bypass' as const,
};
assert.equal(await createAppShellChatActions(deps).send('hello'), true);
} finally {
restoreWindow();
}

// An explicit choice for this draft is a per-Session override: it reaches
// the created Session, and it does not become the Host's default for every
// later task. Only the Settings surface writes `chatDefaults`.
assert.equal((createInput as { permissionMode?: unknown }).permissionMode, 'bypass');
assert.equal(settingsUpdates, 0);
});

it('creates the first session on the selected Runtime Host and project', async () => {
Expand Down
119 changes: 36 additions & 83 deletions apps/desktop/src/main/__tests__/create-session-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,44 @@
* distinct job was turning a product mode into session fields. The IPC is
* gone; this is the part that survived, and the gates it used to carry are
* pinned here as behavior rather than as regexes over the handler's source.
*
* What a product mode expands into — its boundary, name and labels — belongs
* to the Runtime Host, which receives `mode` verbatim and owns the expansion.
* This module only decides what reaches the wire, so that is all it pins.
*/

import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import type { AppSettings, ChatDefaultPermissionMode } from '@maka/core/settings';
import { DEEP_RESEARCH_SESSION_LABEL } from '@maka/core/explore-agent';

import { DEFAULT_SESSION_NAME } from '@maka/core/session-name';

import {
type CreateSessionRequest,
resolveCreateSessionInput,
resolveCreateSessionRequest,
} from '../create-session-input.js';

function settings(permissionMode: ChatDefaultPermissionMode) {
return async () => (({
chatDefaults: { permissionMode },
}) as AppSettings);
}

/** Anything the renderer can put on the wire, including what the type forbids:
* `sessions:create` is an IPC boundary, so the type is a hint, not a gate. */
function resolve(input: unknown, readSettings = settings('ask')) {
return resolveCreateSessionInput(input as CreateSessionRequest | undefined, { readSettings });
function resolve(input: unknown) {
return resolveCreateSessionRequest(input as CreateSessionRequest | undefined);
}

describe('resolveCreateSessionInput', () => {
it('leaves an ordinary default permission choice to the owning runtime', () => {
assert.equal(resolveCreateSessionRequest(undefined).permissionMode, undefined);
assert.equal(resolveCreateSessionRequest({ permissionMode: 'bypass' }).permissionMode, 'bypass');
assert.deepEqual(resolveCreateSessionRequest({ mode: 'deep_research' }), {
describe('resolveCreateSessionRequest', () => {
/**
* An omitted mode must stay omitted all the way to the Host: the Host
* resolves it from its own `chatDefaults`, and substituting a literal here
* would make this module a second authority over the starting boundary.
*/
it('leaves an omitted permission choice to the owning runtime', () => {
assert.equal(resolve(undefined).permissionMode, undefined);
assert.equal(resolve({}).permissionMode, undefined);
assert.equal(resolve({ permissionMode: 'bypass' }).permissionMode, 'bypass');
assert.equal(resolve({ permissionMode: 'ask' }).permissionMode, 'ask');
});

it('passes a product mode through verbatim for the Host to expand', () => {
assert.deepEqual(resolve({ mode: 'deep_research' }), {
mode: 'deep_research',
collaborationMode: 'agent',
orchestrationMode: 'default',
Expand All @@ -43,26 +49,6 @@ describe('resolveCreateSessionInput', () => {
});
});

it('forces the read-only boundary for Deep Research', async () => {
const resolved = await resolve({ mode: 'deep_research' });
assert.equal(resolved.permissionMode, 'explore');
assert.equal(resolved.name, 'Deep Research');
assert.deepEqual(resolved.labels, [DEEP_RESEARCH_SESSION_LABEL]);
});

/**
* Deep Research is a read-only boundary, so it must outrank BOTH the
* renderer's own request and the configured default — otherwise the mode is
* a suggestion, and the session it names is not the session you get.
*/
it("a mode's boundary outranks the renderer's request and the configured default", async () => {
const resolved = await resolve(
{ mode: 'deep_research', permissionMode: 'bypass' },
settings('ask'),
);
assert.equal(resolved.permissionMode, 'explore');
});

/**
* `explore` is a boundary a mode confers, never one a caller may open a
* session at — core names the pickable set `ChatDefaultPermissionMode`.
Expand All @@ -72,66 +58,33 @@ describe('resolveCreateSessionInput', () => {
* separate, deliberate path for moving an EXISTING session (the quote
* companion relies on it), so the guard belongs on creation only.
*/
it('refuses a directly-requested explore boundary', async () => {
await assert.rejects(() => resolve({ permissionMode: 'explore' }), TypeError);
await assert.rejects(() => resolve({ permissionMode: 'nonsense' }), TypeError);
it('refuses a directly-requested explore boundary', () => {
assert.throws(() => resolve({ permissionMode: 'explore' }), TypeError);
assert.throws(() => resolve({ permissionMode: 'nonsense' }), TypeError);
});

it('rejects an invalid collaboration or orchestration mode', async () => {
await assert.rejects(() => resolve({ collaborationMode: 'nonsense' }), TypeError);
await assert.rejects(() => resolve({ orchestrationMode: 'nonsense' }), TypeError);
it('rejects an invalid collaboration or orchestration mode', () => {
assert.throws(() => resolve({ collaborationMode: 'nonsense' }), TypeError);
assert.throws(() => resolve({ orchestrationMode: 'nonsense' }), TypeError);
});

/**
* The mode is a closed mapping, exercised with the raw values a renderer can
* actually put on the wire. An unrecognized mode must confer no boundary, no
* name and no label — it simply is not a mode.
* The mode is a closed set, exercised with the raw values a renderer can
* actually put on the wire. An unrecognized mode must not reach the Host as
* one — it simply is not a mode.
*/
it('cannot be reached by an unrecognized mode from the renderer', async () => {
it('drops an unrecognized mode from the renderer', () => {
for (const mode of ['explore', 'deep-reseach', 'chat', 'admin', '', null, 42, {}]) {
const resolved = await resolve({ mode }, settings('ask'));
assert.equal(resolved.permissionMode, 'ask', `mode ${JSON.stringify(mode)} conferred a boundary`);
const resolved = resolve({ mode });
assert.equal(resolved.mode, undefined, `mode ${JSON.stringify(mode)} reached the wire`);
assert.equal(resolved.name, DEFAULT_SESSION_NAME);
assert.equal(resolved.labels, undefined);
}
});

it('falls back to the configured default when neither a mode nor the caller says otherwise', async () => {
assert.equal((await resolve(undefined, settings('ask'))).permissionMode, 'ask');
assert.equal((await resolve({}, settings('bypass'))).permissionMode, 'bypass');
assert.equal((await resolve({ permissionMode: 'ask' }, settings('bypass'))).permissionMode, 'ask');
});

/**
* The pre-feature fallback was a synchronous `'ask'` literal that could
* never fail. Reading the configured default must not change that: a
* corrupted settings.json must not reject session creation.
*/
it('never rejects when settings cannot be read', async () => {
const resolved = await resolve({}, async () => {
throw new Error('EACCES: settings.json');
});
assert.equal(resolved.permissionMode, 'ask');
});

/**
* The one input no caller sends today: both a mode and a name. The mode wins,
* matching what `quickChat:start` did (it never let the renderer name a Deep
* Research session at all) and matching `permissionMode`, where the mode also
* outranks the request. Pinned because the type accepts the combination, so
* "whichever the expression happened to list first" is not an answer.
*/
it('a mode names the session even when the caller also sent a name', async () => {
const resolved = await resolve({ mode: 'deep_research', name: 'Release notes' });
assert.equal(resolved.name, 'Deep Research');
});

it("adds the mode's label to the caller's rather than replacing them", async () => {
const resolved = await resolve({
mode: 'deep_research',
labels: ['pinned', DEEP_RESEARCH_SESSION_LABEL],
});
it("carries the caller's name and labels when no mode overrides them", () => {
const resolved = resolve({ name: 'Release notes', labels: ['pinned', DEEP_RESEARCH_SESSION_LABEL] });
assert.equal(resolved.name, 'Release notes');
assert.deepEqual(resolved.labels, ['pinned', DEEP_RESEARCH_SESSION_LABEL]);
});

});
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ test('the pending chat view matches no offered model choice', () => {
const view = pendingSessionView({
sessionId: 'session-2',
name: '新任务',
permissionMode: 'execute',
permissionMode: 'ask',
});
const offered = [
{ connectionSlug: 'anthropic', model: 'claude-sonnet-4-5-20250929' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,16 +159,16 @@ test('merges a configuration patch into each fresh CAS projection', async () =>
kind: 'committed',
session: session('session-1', 12, {
collaborationMode: 'plan',
permissionMode: 'execute',
permissionMode: 'ask',
}),
},
]);

const updated = await client.updateSessionConfiguration('session-1', {
permissionMode: 'execute',
permissionMode: 'ask',
});

assert.equal(updated.permissionMode, 'execute');
assert.equal(updated.permissionMode, 'ask');
assert.equal(updated.collaborationMode, 'plan');
assert.deepEqual(
requests
Expand All @@ -185,7 +185,7 @@ test('merges a configuration patch into each fresh CAS projection', async () =>
model: 'test-model',
},
thinkingLevel: null,
permissionMode: 'execute',
permissionMode: 'ask',
collaborationMode: 'agent',
orchestrationMode: 'default',
},
Expand All @@ -200,7 +200,7 @@ test('merges a configuration patch into each fresh CAS projection', async () =>
model: 'test-model',
},
thinkingLevel: null,
permissionMode: 'execute',
permissionMode: 'ask',
collaborationMode: 'plan',
orchestrationMode: 'default',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,10 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn
);
}
assert.equal(
(await ipc.invoke('sessions:setPermissionMode', 'session-ipc', 'execute') as {
(await ipc.invoke('sessions:setPermissionMode', 'session-ipc', 'bypass') as {
permissionMode: string;
}).permissionMode,
'execute',
'bypass',
);
await ipc.invoke('sessions:archive', 'session-ipc');
assert.equal((await ipc.invoke('sessions:list') as Array<{ isArchived: boolean }>)[0]?.isArchived, true);
Expand Down
Loading