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
6 changes: 2 additions & 4 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -941,7 +941,7 @@ function registerHostClientIpc(
const emitTargetSessionsChanged = (
reason: SessionChangedReason,
sessionId?: string,
extra?: Pick<SessionChangedEvent, "connectionSlug" | "modelId" | "turnId">,
extra?: Pick<SessionChangedEvent, "modelId" | "turnId">,
): void => {
if (isTargetActive()) emitSessionsChanged(scope, reason, sessionId, extra);
};
Expand Down Expand Up @@ -1461,14 +1461,12 @@ function emitSessionsChanged(
scope: DesktopTargetScope,
reason: SessionChangedReason,
sessionId?: string,
extra?: Pick<SessionChangedEvent, "connectionSlug" | "modelId" | "turnId">,
extra?: Pick<SessionChangedEvent, "modelId" | "turnId">,
): void {
const event: SessionChangedEvent = {
type: "sessions_changed",
reason,
ts: Date.now(),
...(sessionId ? { sessionId } : {}),
...(extra?.connectionSlug ? { connectionSlug: extra.connectionSlug } : {}),
...(extra?.modelId ? { modelId: extra.modelId } : {}),
...(extra?.turnId ? { turnId: extra.turnId } : {}),
};
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/runtime-host-desktop-candidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export interface DesktopRuntimeHostCandidateDeps {
scope: DesktopTargetScope,
reason: SessionChangedReason,
sessionId?: string,
extra?: Pick<SessionChangedEvent, "connectionSlug" | "modelId" | "turnId">,
extra?: Pick<SessionChangedEvent, "modelId" | "turnId">,
) => void;
readonly completeComputerUseTurn: (
sessionId: string,
Expand Down Expand Up @@ -366,7 +366,7 @@ export async function createDesktopRuntimeHostCandidate(
const emitSessionsChanged = (
reason: SessionChangedReason,
sessionId?: string,
extra?: Pick<SessionChangedEvent, "connectionSlug" | "modelId" | "turnId">,
extra?: Pick<SessionChangedEvent, "modelId" | "turnId">,
): void => {
if (isTargetActive()) deps.emitSessionsChanged(scope, reason, sessionId, extra);
};
Expand Down
12 changes: 3 additions & 9 deletions apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export interface RuntimeHostSessionCatalogIpcDeps {
emitSessionsChanged: (
reason: SessionChangedReason,
sessionId?: string,
extra?: Pick<SessionChangedEvent, 'connectionSlug' | 'modelId' | 'turnId'>,
extra?: Pick<SessionChangedEvent, 'modelId' | 'turnId'>,
) => void;
releaseSessionResources: (sessionId: string) => void | Promise<void>;
sessionCopyCleanup: SessionCopyCleanupAuthority;
Expand Down Expand Up @@ -199,13 +199,7 @@ export function registerRuntimeHostSessionCatalogIpc(
);
ipcMain.handle('sessions:setModel', async (_event, sessionId: string, input: unknown) => {
const modelTarget = normalizeExplicitModel(input);
return updateConfiguration(
deps,
sessionId,
{ modelTarget, thinkingLevel: null },
'updated',
{ connectionSlug: modelTarget.connectionSlug, modelId: modelTarget.model },
);
return updateConfiguration(deps, sessionId, { modelTarget, thinkingLevel: null }, 'updated');
});
ipcMain.handle('sessions:setThinkingLevel', async (_event, sessionId: string, level: unknown) => {
if (level !== undefined && level !== null && !isThinkingLevel(level)) {
Expand Down Expand Up @@ -263,7 +257,7 @@ async function updateConfiguration(
sessionId: string,
patch: DesktopSessionConfigurationPatch,
reason: SessionChangedReason,
extra?: Pick<SessionChangedEvent, 'connectionSlug' | 'modelId' | 'turnId'>,
extra?: Pick<SessionChangedEvent, 'modelId' | 'turnId'>,
): Promise<DesktopHostSessionSummary> {
const session = await deps.client.updateSessionConfiguration(sessionId, patch);
deps.emitSessionsChanged(reason, sessionId, extra);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type { ChatDefaultPermissionMode } from '@maka/core/settings';
import type { LlmConnection } from '@maka/core/llm-connections';
import type { PermissionMode } from '@maka/core/permission';
import {
deriveModelSwitchTranscript,
latestAssistantModelId,
type StoredMessage,
} from '@maka/core/session';
import type { ThinkingLevel } from '@maka/core/model-thinking';
Expand Down Expand Up @@ -177,7 +177,7 @@ export function createAppShellSessionSettingsActions(deps: {
const sessionId = activeIdRef.current;
if (!sessionId) return;
const previous = sessionsRef.current.find((session) => session.id === sessionId);
const transcript = deriveModelSwitchTranscript(messages);
const lastUsedModel = latestAssistantModelId(messages);
if (pendingSessionModelChangesRef.current.has(sessionId)) return;
pendingSessionModelChangesRef.current.add(sessionId);
setPendingSessionModelBySession((current) => ({
Expand All @@ -190,7 +190,7 @@ export function createAppShellSessionSettingsActions(deps: {
if (activeIdRef.current === sessionId) {
const connectionChanged = previous?.llmConnectionSlug !== next.llmConnectionSlug;
const to = modelEndpointLabel(next.llmConnectionSlug, next.model, connectionChanged);
const previousModel = transcript.lastUsedModel ?? previous?.model;
const previousModel = lastUsedModel ?? previous?.model;
toastApi.success(
copy.modelSwitchedTitle,
previous && previousModel
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/pi-tui-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import {
} from '@maka/core/slash-command-catalog';
import { type QueueEnqueueOutcome, type ShellRunUpdate } from '@maka/core/events';
import {
deriveModelSwitchTranscript,
latestAssistantModelId,
type SessionSummary,
type StoredMessage,
} from '@maka/core/session';
Expand Down Expand Up @@ -1462,7 +1462,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {

const setModel = async (nextModel: string) => {
if (nextModel === model) return;
const previousModel = deriveModelSwitchTranscript(transcriptMessages).lastUsedModel ?? model;
const previousModel = latestAssistantModelId(transcriptMessages) ?? model;
await input.driver.setModel(nextModel);
model = nextModel;
// Same-connection switch: scope the choice lookup to the live connection
Expand All @@ -1488,7 +1488,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
// Updates the provider (and thus the thinking variants) and the status line.
const setModelChoice = async (choice: ModelChoice) => {
if (choice.model === model && choice.connectionSlug === connectionSlug) return;
const previousModel = deriveModelSwitchTranscript(transcriptMessages).lastUsedModel ?? model;
const previousModel = latestAssistantModelId(transcriptMessages) ?? model;
const previousConnectionSlug = connectionSlug;
const previousChoice = modelChoices?.find(
(candidate) =>
Expand Down
15 changes: 5 additions & 10 deletions packages/core/src/__tests__/model-switch-transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@

import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { deriveModelSwitchTranscript, type StoredMessage } from '../session.js';
import { latestAssistantModelId, type StoredMessage } from '../session.js';

describe('deriveModelSwitchTranscript', () => {
it('treats any durable transcript as an existing conversation', () => {
describe('latestAssistantModelId', () => {
it('returns undefined until an assistant response exists', () => {
const messages: StoredMessage[] = [
{
type: 'user',
Expand All @@ -33,9 +33,7 @@ describe('deriveModelSwitchTranscript', () => {
},
];

assert.deepEqual(deriveModelSwitchTranscript(messages), {
hasConversation: true,
});
assert.equal(latestAssistantModelId(messages), undefined);
});

it('uses the latest assistant model as the actual baseline', () => {
Expand All @@ -58,9 +56,6 @@ describe('deriveModelSwitchTranscript', () => {
},
];

assert.deepEqual(deriveModelSwitchTranscript(messages), {
hasConversation: true,
lastUsedModel: 'model-b',
});
assert.equal(latestAssistantModelId(messages), 'model-b');
});
});
24 changes: 4 additions & 20 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,10 +685,8 @@ export type SessionChangedReason =
| 'rebound';

export interface SessionChangedEvent {
type: 'sessions_changed';
reason: SessionChangedReason;
sessionId?: string;
connectionSlug?: string;
modelId?: string;
/**
* The turn this change is ABOUT, when the change has a turn to name.
Expand Down Expand Up @@ -1205,27 +1203,13 @@ function isToolActivityIdentity(value: Record<string, unknown>): boolean {
export const STEP_LIMIT_NOTICE_TEXT =
'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.';

/**
* View-boundary facts for explaining a model selection without adding a
* second persisted model authority. Assistant rows already record the actual
* model used by each completed step, so the latest such row is the only
* durable model fact the switcher needs.
*/
export function deriveModelSwitchTranscript(messages: readonly StoredMessage[]): {
hasConversation: boolean;
lastUsedModel?: string;
} {
let lastUsedModel: string | undefined;
/** Latest actual model recorded by a completed assistant step. */
export function latestAssistantModelId(messages: readonly StoredMessage[]): string | undefined {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.type !== 'assistant') continue;
lastUsedModel = message.modelId;
break;
if (message?.type === 'assistant') return message.modelId;
}
return {
hasConversation: messages.length > 0,
...(lastUsedModel ? { lastUsedModel } : {}),
};
return undefined;
}

export function deriveTurnRecords(messages: readonly StoredMessage[]): TurnRecord[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ const MIN_IMPLEMENTATION_CHILD_REQUESTS = 6;
const MAX_IMPLEMENTATION_CHILD_REQUESTS =
MIN_IMPLEMENTATION_CHILD_REQUESTS + MAX_IMPLEMENTATION_CHILD_PTY_READS - 1;
const HEADLESS_CODING_V1_PROMPT_HASH =
'sha256:0e3389e330b8b8f0db1c7a8b8e2126325fe4c672d6eff279afcd3f9412e52271';
'sha256:b2773282ac4755dc8d8a663eafdec68c3fa6f5680ec8557d261b5f723672b467';
const HEADLESS_CODING_V1_TOOLS_HASH =
'sha256:c062194603f93b568da5ca59b865b316156b5f218ba854c291aa9582859b3de4';
const execFileAsync = promisify(execFile);
Expand Down Expand Up @@ -920,6 +920,7 @@ test('hosted execution freezes the headless coding provider wire contract', asyn
const instructions = responsesDeveloperPrompt(request?.body);
const tools = request?.body.tools;
assert.equal(typeof instructions, 'string', JSON.stringify(request?.body));
assert.match(instructions ?? '', /^Active model: deepseek-v4-flash$/mu);
assert.ok(Array.isArray(tools));
assert.equal(stableHash(instructions), HEADLESS_CODING_V1_PROMPT_HASH);
assert.equal(stableHash(tools), HEADLESS_CODING_V1_TOOLS_HASH);
Expand Down Expand Up @@ -2831,15 +2832,14 @@ test('backend composition survives a moved saved Git Bash executable while Bash
const capturedBash = childComposer.tools.find((tool) => tool.name === 'Bash');
assert.match(capturedBash?.description ?? '', /captured child shell/);
assert.doesNotMatch(capturedBash?.description ?? '', /unavailable this turn/);
assert.match(
await childComposer.turnTailPrompt({
sessionId: 'session',
turnId: 'turn-child',
cwd: '/workspace',
workspaceRoot: '/workspace',
}),
/captured child shell/,
);
const childContext = {
sessionId: 'session',
turnId: 'turn-child',
cwd: '/workspace',
workspaceRoot: '/workspace',
} as const;
const childTail = await childComposer.turnTailPrompt(childContext);
assert.match(childTail, /captured child shell/);
});

test('child execution Bash carries the configured shell guidance and spawn plan', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom
readonly runId?: string;
readonly emitSkillCatalogTrace?: (message: string, data?: Record<string, unknown>) => void;
}) => {
return await modelComposition.resolveSystemPrompt({
const resolved = await modelComposition.resolveSystemPrompt({
sessionId: input.context.sessionId,
turnId: context.turnId,
...(context.runId ? { runId: context.runId } : {}),
Expand All @@ -298,6 +298,11 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom
? { emitSkillCatalogTrace: context.emitSkillCatalogTrace }
: {}),
});
const model = target.model.replace(/[\r\n\t]+/g, ' ').trim();
return Object.freeze({
...resolved,
text: [`Active model: ${model}`, resolved.text].filter(Boolean).join('\n\n'),
});
};
const recordRunComposition = input.context.recordRunComposition;
const commitRunComposition = recordRunComposition
Expand Down