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
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,68 @@ test('merges a configuration patch into each fresh CAS projection', async () =>
);
});

test('merges active-turn configuration edits from the Host pending projection', async () => {
const { client, requests } = clientWithResponses([
{
kind: 'session',
session: session('session-1', 10, {
model: 'model-a',
thinkingLevel: 'high',
pendingConfiguration: {
llmConnectionSlug: 'test-connection',
model: 'model-b',
permissionMode: 'ask',
collaborationMode: 'agent',
orchestrationMode: 'default',
},
}),
},
{
kind: 'committed',
session: session('session-1', 11, {
model: 'model-a',
thinkingLevel: 'high',
}),
},
]);

await client.updateSessionConfiguration('session-1', {
modelTarget: { kind: 'explicit', connectionSlug: 'test-connection', model: 'model-a' },
thinkingLevel: null,
});

const request = requests.find(({ operation }) => operation === 'session.configuration.update');
assert.deepEqual(request?.input, {
sessionId: 'session-1',
expectedRevision: 10,
configuration: {
modelTarget: { kind: 'explicit', connectionSlug: 'test-connection', model: 'model-a' },
thinkingLevel: 'high',
permissionMode: 'ask',
collaborationMode: 'agent',
orchestrationMode: 'default',
},
});
});

test('resets thinking when a model selection moves away from the effective model', async () => {
const { client, requests } = clientWithResponses([
{
kind: 'session',
session: session('session-1', 10, { model: 'model-a', thinkingLevel: 'high' }),
},
{ kind: 'committed', session: session('session-1', 11, { model: 'model-b' }) },
]);

await client.updateSessionConfiguration('session-1', {
modelTarget: { kind: 'explicit', connectionSlug: 'test-connection', model: 'model-b' },
thinkingLevel: null,
});

const input = requests.find(({ operation }) => operation === 'session.configuration.update')?.input;
assert.equal((input as { configuration: { thinkingLevel: unknown } }).configuration.thinkingLevel, null);
});

test('retries a Session update through transient revision churn', async () => {
const responses: unknown[] = [];
for (let revision = 10; revision < 14; revision += 1) {
Expand Down
59 changes: 44 additions & 15 deletions apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -867,29 +867,58 @@ export class DesktopRuntimeHostClient {
) as DesktopSessionConfigurationPatch;
if (Object.keys(definedPatch).length === 0)
return this.#requireSession(sessionId);
return this.#updateSession(sessionId, (current) =>
this.request("session.configuration.update", {
return this.#updateSession(sessionId, (current) => {
const effectiveModelTarget = {
kind: "explicit" as const,
connectionSlug: current.llmConnectionSlug,
model: current.model,
};
const base = current.pendingConfiguration ?? {
...effectiveModelTarget,
thinkingLevel: current.thinkingLevel,
permissionMode: current.permissionMode,
collaborationMode: current.collaborationMode,
orchestrationMode: current.orchestrationMode,
};
const defaultModelTarget = current.pendingConfiguration
? {
kind: "explicit" as const,
connectionSlug: current.pendingConfiguration.llmConnectionSlug,
model: current.pendingConfiguration.model,
}
: current.connectionLocked
? effectiveModelTarget
: { kind: "default" as const };
const modelTarget = definedPatch.modelTarget ?? defaultModelTarget;
const selectingModel = definedPatch.modelTarget !== undefined;
const selectedModelIsEffective =
selectingModel &&
definedPatch.modelTarget?.kind === "explicit" &&
definedPatch.modelTarget.connectionSlug === current.llmConnectionSlug &&
definedPatch.modelTarget.model === current.model;
const thinkingLevel =
selectingModel &&
definedPatch.thinkingLevel === null
? selectedModelIsEffective
? (current.pendingConfiguration?.thinkingLevel ?? current.thinkingLevel ?? null)
: null
: (base.thinkingLevel ?? null);
return this.request("session.configuration.update", {
sessionId,
expectedRevision: current.revision,
configuration: {
// An unlocked Session still follows the Host-owned default route.
// Once execution or an explicit model change locks it, the resolved
// catalog route is the explicit target that must survive this patch.
modelTarget: current.connectionLocked
? {
kind: "explicit",
connectionSlug: current.llmConnectionSlug,
model: current.model,
}
: { kind: "default" },
thinkingLevel: current.thinkingLevel ?? null,
permissionMode: current.permissionMode,
collaborationMode: current.collaborationMode,
orchestrationMode: current.orchestrationMode,
permissionMode: base.permissionMode,
collaborationMode: base.collaborationMode,
orchestrationMode: base.orchestrationMode,
...definedPatch,
modelTarget,
thinkingLevel,
},
}),
);
});
});
}

async setSessionReadMarker(
Expand Down
10 changes: 8 additions & 2 deletions apps/desktop/src/renderer/app-shell-session-settings-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,10 @@ export function createAppShellSessionSettingsActions(deps: {
if (mode !== 'ask' && mode !== 'bypass') return false;
const sessionId = activeIdRef.current;
const currentMode = sessionId
? sessionsRef.current.find((session) => session.id === sessionId)?.permissionMode
? (() => {
const session = sessionsRef.current.find((entry) => entry.id === sessionId);
return session?.pendingConfiguration?.permissionMode ?? session?.permissionMode;
})()
: undefined;
if (currentMode === mode) return true;
const pendingKey = sessionId ?? '__global_permission_mode__';
Expand Down Expand Up @@ -218,7 +221,10 @@ export function createAppShellSessionSettingsActions(deps: {
const sessionId = activeIdRef.current;
if (!sessionId) return;
const current = sessionsRef.current.find((session) => session.id === sessionId);
if (current && current.thinkingLevel === level) return;
if (
current &&
(current.pendingConfiguration?.thinkingLevel ?? current.thinkingLevel) === level
) return;
if (pendingSessionModelChangesRef.current.has(sessionId)) return;
pendingSessionModelChangesRef.current.add(sessionId);
setPendingSessionModelBySession((currentPending) => ({
Expand Down
45 changes: 27 additions & 18 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,17 @@ function AppShellContent({
activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined;
const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined;
const activeSession = sessions.find((session) => session.id === activeId);
const activeSessionSelection = activeSession?.pendingConfiguration
? {
...activeSession,
llmConnectionSlug: activeSession.pendingConfiguration.llmConnectionSlug,
model: activeSession.pendingConfiguration.model,
...(activeSession.pendingConfiguration.thinkingLevel === undefined
? { thinkingLevel: undefined }
: { thinkingLevel: activeSession.pendingConfiguration.thinkingLevel }),
permissionMode: activeSession.pendingConfiguration.permissionMode,
}
: activeSession;
const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined;
const activeDesktopSession = activeSession;
// The shell's reading of the active live turn: streaming/settled flags, the
Expand Down Expand Up @@ -858,7 +869,7 @@ function AppShellContent({
activationCandidate: modelSettingsOwnsComposerHost
? onboardingActivationCandidate
: undefined,
activeSession,
activeSession: activeSessionSelection,
persistedComposerDefaults,
usePersistedComposerDefaults: modelSettingsOwnsComposerHost,
defaultThinkingLevel: newTask.selectedHost?.chatDefaults.thinkingLevel,
Expand Down Expand Up @@ -1222,6 +1233,11 @@ function AppShellContent({
permissionMode: newTaskPermissionMode,
})
: undefined);
// The Host keeps the effective Session configuration immutable for the
// active AgentRun and projects an optional next-Turn selection alongside it.
// Controls show that latest selection, while execution-boundary reads below
// continue to use the effective configuration.
const activeSessionSelectionView = activeSessionSelection ?? activeSessionForView;
// Each control reads its own field. There is nothing to project and nothing
// to keep in sync: a Session in Plan with Swarm as its orchestration default
// says both, because it is both.
Expand Down Expand Up @@ -1294,7 +1310,8 @@ function AppShellContent({
activeExecutionBoundary,
activeId ? (activeSessionForView?.permissionMode ?? 'ask') : newTaskPermissionMode,
);
const activePermissionMode = activeBoundarySurface.permissionMode;
const activePermissionMode =
activeSessionSelectionView?.permissionMode ?? activeBoundarySurface.permissionMode;
const planMode = usePlanModeState(activeSessionForView);
const planConversationItems = (planMode.state?.proposals ?? []).map((proposal) => ({
id: proposal.proposalId,
Expand Down Expand Up @@ -3053,7 +3070,7 @@ function AppShellContent({
: attachFilePaths
}
modelLabel={activeModelLabel ?? newChatModelLabel ?? undefined}
activeSession={activeSessionForView}
activeSession={activeSessionSelectionView}
activeModel={activeModel}
activeModelLabel={activeModelLabel}
activeProviderType={activeConnection?.providerType}
Expand Down Expand Up @@ -3088,22 +3105,14 @@ function AppShellContent({
}
permissionMode={activePermissionMode}
permissionModePending={activeId ? pendingPermissionModeBySession[activeId] === true : false}
// Every "cannot change this mid-turn" gate reads `turnActive`,
// the same witness Stop reads. Reading the persisted status
// here instead left these toggles live through the whole
// send→run-start window — long enough on a cold backend for a
// mode change to land before the run registers and alter the
// execution config of the turn already sent.
// The Host owns active-turn configuration as pending
// next-Turn state. The short local pending flag only blocks
// duplicate writes; it never changes the active Run's
// permission boundary.
permissionModeDisabledReason={
activeId && pendingPermissionModeBySession[activeId] === true
? shellCopy.permissionModeChanging
: activeStreamingLive
? shellCopy.permissionModeStreaming
: activeId && turnActive
? shellCopy.permissionModeRunning
: activeId && activeSessionForView?.status === 'waiting_for_user'
? shellCopy.permissionModeWaiting
: undefined
? shellCopy.permissionModeChanging
: undefined
}
onPermissionModeChange={
activeBoundarySurface.localInteractionAvailable
Expand Down Expand Up @@ -3157,7 +3166,7 @@ function AppShellContent({
onStreamingSettled={
activeId ? (messageId) => settleAssistantStreaming(activeId, messageId) : undefined
}
activeSession={activeSessionForView}
activeSession={activeSessionSelectionView}
activeConnectionLabel={activeConnectionLabel}
activeModelLabel={activeModelLabel}
activeProviderType={activeConnection?.providerType}
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,23 @@ export interface SessionExternalOrigin {
readonly sourceSessionId: string;
}

/**
* Host-owned configuration selected while the current root Turn is active.
*
* The active AgentRun keeps its own immutable configuration snapshot. This
* projection is the configuration the next root Turn should use; it is
* cleared when that Turn is admitted or when the user returns to the current
* effective configuration.
*/
export interface PendingSessionConfiguration {
readonly llmConnectionSlug: string;
readonly model: string;
readonly thinkingLevel?: import('./model-thinking.js').ThinkingLevel;
readonly permissionMode: PermissionMode;
readonly collaborationMode: CollaborationMode;
readonly orchestrationMode: OrchestrationMode;
}

export interface SessionHeader {
// Identity
id: string;
Expand Down Expand Up @@ -270,6 +287,8 @@ export interface SessionHeader {
/** Per-model reasoning-depth variant; `undefined` = model default. Cleared on model switch. */
thinkingLevel?: import('./model-thinking.js').ThinkingLevel;
permissionMode: PermissionMode;
/** Configuration selected for the next root Turn while one is active. */
pendingConfiguration?: PendingSessionConfiguration;
/** Defaults to `agent` when absent on legacy session records. */
collaborationMode?: CollaborationMode;
/** Defaults to `default` when absent on legacy session records. */
Expand Down Expand Up @@ -369,6 +388,8 @@ export interface SessionSummary {
/** Per-model reasoning-depth variant; `undefined` = model default. Cleared on model switch. */
thinkingLevel?: import('./model-thinking.js').ThinkingLevel;
permissionMode: PermissionMode;
/** Configuration selected for the next root Turn while one is active. */
pendingConfiguration?: PendingSessionConfiguration;
/** Defaults to `agent` when absent on legacy summaries. */
collaborationMode?: CollaborationMode;
/** Defaults to `default` when absent on legacy summaries. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ import { HostProjectMembershipGate } from '../server/project-membership-gate.js'
import { HostWorkspaceResolver } from '../server/workspace-resolver.js';
import {
HostSessionCatalogCoordinator,
SessionOperationFailure,
type HostSessionCatalogCoordinatorOptions,
validatePendingSessionConfiguration,
} from '../server/session-catalog-coordinator.js';
import { SessionAdmissionGate } from '../server/session-admission-gate.js';

Expand Down Expand Up @@ -462,6 +464,60 @@ test('creation on a relay connection honours declared levels via the catalog pro
assert.equal(persistedThinkingLevel, 'low');
});

test('pending configuration validation rechecks model readiness and thinking support', async () => {
const runtimePolicy = runtimePolicyFixture({
providerType: 'openai-compatible',
enabledModelIds: ['relay-model'],
models: [{ id: 'relay-model' }],
relayModelProfiles: { 'relay-model': { thinkingLevels: ['low'] } },
});
const pending = {
llmConnectionSlug: 'test',
model: 'relay-model',
thinkingLevel: 'low' as const,
permissionMode: 'ask' as const,
collaborationMode: 'agent' as const,
orchestrationMode: 'default' as const,
};

await validatePendingSessionConfiguration(runtimePolicy, pending);
await assert.rejects(
validatePendingSessionConfiguration(runtimePolicy, {
...pending,
thinkingLevel: 'high',
}),
(error: unknown) => {
assert.ok(error instanceof SessionOperationFailure);
assert.equal(error.code, 'invalid_request');
assert.match(error.message, /does not support thinking level high/);
return true;
},
);

await assert.rejects(
validatePendingSessionConfiguration(
runtimePolicyFixture({
executionResolution: {
kind: 'credential_not_configured',
status: {
locator: { scope: 'connection', connectionId: 'connection-1', kind: 'api_key' },
configured: false,
credentialId: null,
revision: null,
updatedAt: null,
},
},
}),
{ ...pending, model: 'model-1', thinkingLevel: undefined },
),
(error: unknown) => {
assert.ok(error instanceof SessionOperationFailure);
assert.equal(error.code, 'operation_unavailable');
return true;
},
);
});

test('creation admits the enabled bootstrap DeepSeek model before discovery', async () => {
const modelId = 'deepseek-v4-flash';
let createAttempts = 0;
Expand Down
3 changes: 3 additions & 0 deletions packages/runtime-host/src/client/session-catalog-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ export function projectSessionCatalogSummary(
model: session.model,
...(session.thinkingLevel === undefined ? {} : { thinkingLevel: session.thinkingLevel }),
permissionMode: session.permissionMode,
...(session.pendingConfiguration === undefined
? {}
: { pendingConfiguration: session.pendingConfiguration }),
collaborationMode: session.collaborationMode,
orchestrationMode: session.orchestrationMode,
};
Expand Down
Loading