Compare commits

...

3 Commits

Author SHA1 Message Date
abeatrix 8ec403e16a patch 2026-05-26 11:05:28 -07:00
abeatrix 6155fc1d82 patches 2026-05-26 10:48:55 -07:00
abeatrix bd4821a2ea feat: ClineCore supports updateConnection
The existing SessionRuntime.updateConnection already supports the lower-level fields, but the host API and persistence layer do not expose or persist per-turn updates yet. This PR fixes it.
2026-05-26 10:26:22 -07:00
25 changed files with 563 additions and 41 deletions
+4 -4
View File
@@ -287,9 +287,9 @@ export class AcpAgent implements Agent {
}
session.currentModelId = params.modelId;
if (session.sessionManager && session.activeSessionId) {
await session.sessionManager.updateSessionModel?.(
await session.sessionManager.updateSessionConnection?.(
session.activeSessionId,
params.modelId,
{ modelId: params.modelId },
);
}
return {};
@@ -342,9 +342,9 @@ export class AcpAgent implements Agent {
case "model": {
session.currentModelId = value;
if (session.sessionManager && session.activeSessionId) {
await session.sessionManager.updateSessionModel?.(
await session.sessionManager.updateSessionConnection?.(
session.activeSessionId,
value,
{ modelId: value },
);
}
break;
@@ -116,7 +116,7 @@ function makeManager() {
readTranscript: vi.fn(),
ingestHookEvent: vi.fn(),
subscribe: vi.fn(),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(),
pendingPrompts: {
update: vi.fn(),
},
+1 -1
View File
@@ -63,7 +63,7 @@ describe("createCliCore", () => {
readTranscript: vi.fn(),
ingestHookEvent: vi.fn(),
subscribe: vi.fn(),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(),
});
delete process.env.CLINE_RPC_ADDRESS;
delete process.env.CLINE_SESSION_BACKEND_MODE;
+5
View File
@@ -0,0 +1,5 @@
# Changelog
## Next Release
- Adds per-turn session connection updates to the host API and persistence layer, including provider/model changes and reasoning/thinking overrides.
+9 -9
View File
@@ -100,7 +100,7 @@ describe("ClineCore", () => {
listeners.push(listener);
return () => {};
}),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(),
};
createRuntimeHostMock.mockResolvedValue(host);
@@ -157,7 +157,7 @@ describe("ClineCore", () => {
listener = nextListener;
return () => {};
}),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(),
};
createRuntimeHostMock.mockResolvedValue(host);
@@ -193,7 +193,7 @@ describe("ClineCore", () => {
deleteSession: vi.fn(),
readSessionMessages: vi.fn(),
subscribe: vi.fn(() => () => {}),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(),
};
createRuntimeHostMock.mockResolvedValue(host);
@@ -251,7 +251,7 @@ describe("ClineCore", () => {
deleteSession: vi.fn(),
readSessionMessages: vi.fn(),
subscribe: vi.fn(() => () => {}),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(),
};
createRuntimeHostMock.mockResolvedValue(host);
const askQuestion = vi.fn(async () => "yes");
@@ -300,7 +300,7 @@ describe("ClineCore", () => {
deleteSession: vi.fn(),
readSessionMessages: vi.fn(),
subscribe: vi.fn(() => () => {}),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(),
};
createRuntimeHostMock.mockResolvedValue(host);
@@ -382,7 +382,7 @@ describe("ClineCore", () => {
]),
dispatchHookEvent: vi.fn(),
subscribe: vi.fn(() => () => {}),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(),
};
createRuntimeHostMock.mockResolvedValue(host);
@@ -437,7 +437,7 @@ describe("ClineCore", () => {
readSessionMessages: vi.fn(),
dispatchHookEvent: vi.fn(),
subscribe: vi.fn(() => () => {}),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(),
};
createRuntimeHostMock.mockResolvedValue(host);
@@ -493,7 +493,7 @@ Summarize the local event.
readSessionMessages: vi.fn(),
dispatchHookEvent: vi.fn(),
subscribe: vi.fn(() => () => {}),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(),
};
createRuntimeHostMock.mockResolvedValue(host);
@@ -568,7 +568,7 @@ Summarize the local event.
readSessionMessages: vi.fn(),
dispatchHookEvent: vi.fn(),
subscribe: vi.fn(() => () => {}),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(),
};
createRuntimeHostMock.mockResolvedValue(host);
+14 -12
View File
@@ -37,7 +37,7 @@ import type {
PendingPromptsServiceApi,
RuntimeHost,
RuntimeHostSubscribeOptions,
SessionModelRuntimeService,
SessionConnectionRuntimeService,
SessionUsageRuntimeService,
StartSessionInput,
StartSessionResult,
@@ -546,21 +546,23 @@ export class ClineCore {
return this.host.subscribe(listener, options);
}
/**
* Updates the AI model used by an active session.
* Updates the AI connection used by an active session.
*
* Switches the session to use a different AI model while maintaining the session state
* and message history. This allows you to continue a conversation with a different model.
* Switches provider/model credentials and reasoning settings while maintaining
* the session state and message history. This allows you to continue a
* conversation with a different connection.
*
* @example
* ```ts
* // Switch to a different model mid-session
* await cline.updateSessionModel(sessionId, "claude-opus-4-1");
* await cline.updateSessionConnection(sessionId, {
* providerId: "anthropic",
* modelId: "claude-opus-4-1",
* });
* ```
*/
updateSessionModel: SessionModelRuntimeService["updateSessionModel"] = (
...args
) => {
const service = this.host as RuntimeHostServiceExtensions;
return service.updateSessionModel?.(...args) ?? Promise.resolve();
};
updateSessionConnection: SessionConnectionRuntimeService["updateSessionConnection"] =
(...args) => {
const service = this.host as RuntimeHostServiceExtensions;
return service.updateSessionConnection?.(...args) ?? Promise.resolve();
};
}
@@ -145,6 +145,11 @@ export function createClineCoreAutomationRuntimeHandlers(
prompt: request.prompt,
userImages: request.attachments?.userImages,
userFiles: request.attachments?.userFiles?.map((file) => file.content),
connection: {
providerId: request.config.provider,
modelId: request.config.model,
apiKey: request.config.apiKey?.trim() || undefined,
},
delivery: request.delivery,
});
if (!result) {
@@ -2,7 +2,7 @@ import type {
PendingPromptsRuntimeService,
PendingPromptsServiceApi,
RuntimeHost,
SessionModelRuntimeService,
SessionConnectionRuntimeService,
SessionUsageRuntimeService,
} from "../runtime/host/runtime-host";
import {
@@ -27,7 +27,7 @@ export type RuntimeHostServiceExtensions = RuntimeHost &
Partial<
PendingPromptsRuntimeService &
SessionUsageRuntimeService &
SessionModelRuntimeService
SessionConnectionRuntimeService
>;
export function createClineCoreSettingsApi(
@@ -27,6 +27,8 @@ export type DelegatedAgentConnectionConfig = Pick<
| "providerConfig"
| "knownModels"
| "thinking"
| "reasoningEffort"
| "thinkingBudgetTokens"
>;
export interface DelegatedAgentRuntimeConfig
@@ -87,6 +89,8 @@ export function createDelegatedAgentConfigProvider(
providerConfig: runtimeConfig.providerConfig,
knownModels: runtimeConfig.knownModels,
thinking: runtimeConfig.thinking,
reasoningEffort: runtimeConfig.reasoningEffort,
thinkingBudgetTokens: runtimeConfig.thinkingBudgetTokens,
}),
updateConnectionDefaults: (overrides) => {
runtimeConfig = {
@@ -921,7 +921,20 @@ export class AgentTeamsRuntime {
}
updateTeammateConnections(
overrides: Partial<Pick<AgentConfig, "apiKey" | "baseUrl" | "headers">>,
overrides: Partial<
Pick<
AgentConfig,
| "providerId"
| "modelId"
| "apiKey"
| "baseUrl"
| "headers"
| "providerConfig"
| "reasoningEffort"
| "thinking"
| "thinkingBudgetTokens"
>
>,
): void {
for (const member of this.members.values()) {
if (member.role !== "teammate" || !member.agent) {
@@ -1067,6 +1067,7 @@ export class HubRuntimeHost implements RuntimeHost {
}
: undefined,
delivery: input.delivery,
connection: input.connection,
timeoutMs: input.timeoutMs,
},
input.sessionId,
@@ -161,6 +161,43 @@ describe("run handlers", () => {
await expect(promise).resolves.toMatchObject({ ok: true });
});
it("passes through only string-valued connection headers", async () => {
const runTurn = vi.fn().mockResolvedValue(undefined);
const ctx = createContext({ runTurn });
await expect(
handleSessionInput(ctx, {
version: "v1",
command: "run.start",
requestId: "req-connection",
sessionId: "session-1",
payload: {
sessionId: "session-1",
prompt: "go",
connection: {
providerId: "anthropic",
modelId: "claude-opus-4-1",
headers: {
"x-valid": "yes",
"x-number": 1,
"x-object": { nested: true },
},
},
},
}),
).resolves.toMatchObject({ ok: true });
expect(runTurn).toHaveBeenCalledWith(
expect.objectContaining({
connection: expect.objectContaining({
providerId: "anthropic",
modelId: "claude-opus-4-1",
headers: { "x-valid": "yes" },
}),
}),
);
});
it("treats abort as applied when the runtime abort hook rejects", async () => {
const abort = vi.fn().mockRejectedValue(new Error("Run aborted"));
const ctx = createContext({ abort });
@@ -64,6 +64,52 @@ function parseRunTimeoutMs(
return undefined;
}
function parseConnectionUpdate(
payload: Record<string, unknown>,
): SendSessionInput["connection"] | undefined {
const raw = payload.connection;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
const input = raw as Record<string, unknown>;
const connection: NonNullable<SendSessionInput["connection"]> = {};
for (const key of ["providerId", "modelId", "apiKey", "baseUrl"] as const) {
if (typeof input[key] === "string") connection[key] = input[key];
}
if (
input.reasoningEffort === "low" ||
input.reasoningEffort === "medium" ||
input.reasoningEffort === "high" ||
input.reasoningEffort === "xhigh"
) {
connection.reasoningEffort = input.reasoningEffort;
}
if (
input.headers &&
typeof input.headers === "object" &&
!Array.isArray(input.headers)
) {
const headers = Object.fromEntries(
Object.entries(input.headers).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
if (Object.keys(headers).length > 0) {
connection.headers = headers;
}
}
if (input.providerConfig !== undefined) {
connection.providerConfig = input.providerConfig;
}
if (typeof input.thinking === "boolean") {
connection.thinking = input.thinking;
}
if (typeof input.thinkingBudgetTokens === "number") {
connection.thinkingBudgetTokens = input.thinkingBudgetTokens;
}
return Object.keys(connection).length > 0 ? connection : undefined;
}
async function runTurnWithRuntimeHealth(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
@@ -211,6 +257,7 @@ export async function handleSessionInput(
? (attachments.userImages as string[])
: undefined,
userFiles,
connection: parseConnectionUpdate(payload),
timeoutMs,
},
timeoutMs,
+2
View File
@@ -380,6 +380,8 @@ export type {
RuntimeHostSubscribeOptions,
SendSessionInput,
SessionAccumulatedUsage,
SessionConnectionRuntimeService,
SessionConnectionUpdate,
SessionUsageSummary,
StartSessionInput,
StartSessionResult,
@@ -4055,6 +4055,211 @@ describe("LocalRuntimeHost", () => {
expect(run).toHaveBeenCalledTimes(1);
});
it("applies and persists per-turn connection updates before running", async () => {
const sessionId = "sess-turn-connection";
const manifest = createManifest(sessionId);
const updateSession = vi.fn().mockResolvedValue({ updated: true });
const writeSessionManifest = vi.fn();
const sessionService = {
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
manifestPath: "/tmp/manifest-turn-connection.json",
messagesPath: "/tmp/messages-turn-connection.json",
manifest,
}),
persistSessionMessages: vi.fn(),
updateSession,
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
writeSessionManifest,
listSessions: vi.fn().mockResolvedValue([]),
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
};
const updateConnectionDefaults = vi.fn();
const updateTeammateConnections = vi.fn();
const runtimeBuilder = {
build: vi.fn().mockReturnValue({
tools: [],
delegatedAgentConfigProvider: {
getRuntimeConfig: vi.fn(),
getConnectionConfig: vi.fn(),
updateConnectionDefaults,
},
teamRuntime: {
getTeamId: vi.fn().mockReturnValue("team-turn-connection"),
getTeamName: vi.fn().mockReturnValue("turn-connection"),
updateTeammateConnections,
},
shutdown: vi.fn(),
}),
};
const run = vi.fn().mockResolvedValue(
createResult({
model: { id: "claude-opus-4-1", provider: "anthropic" },
}),
);
const updateConnection = vi.fn();
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService: sessionService as never,
runtimeBuilder,
createAgent: () =>
({
run,
continue: vi.fn(),
abort: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
canStartRun: vi.fn().mockReturnValue(true),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
restore: vi.fn(),
updateConnection,
shutdown: vi.fn().mockResolvedValue(undefined),
getMessages: vi.fn().mockReturnValue([]),
messages: [],
}) as never,
});
await manager.startSession(
normalizeStartInput({
config: createConfig({ sessionId }),
interactive: true,
}),
);
await manager.runTurn({
sessionId,
prompt: "hello",
connection: {
providerId: "anthropic",
modelId: "claude-opus-4-1",
apiKey: "turn-key",
baseUrl: "https://api.example.test",
headers: { "x-turn": "1" },
reasoningEffort: "high",
thinking: true,
thinkingBudgetTokens: 2048,
},
});
const expectedConnection = expect.objectContaining({
providerId: "anthropic",
modelId: "claude-opus-4-1",
apiKey: "turn-key",
baseUrl: "https://api.example.test",
headers: { "x-turn": "1" },
reasoningEffort: "high",
thinking: true,
thinkingBudgetTokens: 2048,
providerConfig: expect.objectContaining({
providerId: "anthropic",
modelId: "claude-opus-4-1",
apiKey: "turn-key",
}),
});
expect(updateConnection).toHaveBeenCalledWith(expectedConnection);
expect(updateConnectionDefaults).toHaveBeenCalledWith(expectedConnection);
expect(updateTeammateConnections).toHaveBeenCalledWith(expectedConnection);
expect(updateSession).toHaveBeenCalledWith({
sessionId,
provider: "anthropic",
model: "claude-opus-4-1",
});
expect(writeSessionManifest).toHaveBeenCalledWith(
"/tmp/manifest-turn-connection.json",
expect.objectContaining({
provider: "anthropic",
model: "claude-opus-4-1",
}),
);
expect(run).toHaveBeenCalledTimes(1);
});
it("does not persist per-turn connection when provider and model are unchanged", async () => {
const sessionId = "sess-turn-connection-noop";
const manifest = createManifest(sessionId);
const updateSession = vi.fn().mockResolvedValue({ updated: true });
const writeSessionManifest = vi.fn();
const sessionService = {
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
manifestPath: "/tmp/manifest-turn-connection-noop.json",
messagesPath: "/tmp/messages-turn-connection-noop.json",
manifest,
}),
persistSessionMessages: vi.fn(),
updateSession,
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
writeSessionManifest,
listSessions: vi.fn().mockResolvedValue([]),
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
};
const run = vi.fn().mockResolvedValue(createResult());
const updateConnection = vi.fn();
const updateConnectionDefaults = vi.fn();
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService: sessionService as never,
runtimeBuilder: {
build: vi.fn().mockReturnValue({
tools: [],
delegatedAgentConfigProvider: {
getRuntimeConfig: vi.fn(),
getConnectionConfig: vi.fn(),
updateConnectionDefaults,
},
shutdown: vi.fn(),
}),
},
createAgent: () =>
({
run,
continue: vi.fn(),
abort: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
canStartRun: vi.fn().mockReturnValue(true),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
restore: vi.fn(),
updateConnection,
shutdown: vi.fn().mockResolvedValue(undefined),
getMessages: vi.fn().mockReturnValue([]),
messages: [],
}) as never,
});
await manager.startSession(
normalizeStartInput({
config: createConfig({ sessionId }),
interactive: true,
}),
);
await manager.runTurn({
sessionId,
prompt: "hello",
connection: {
providerId: "mock-provider",
modelId: "mock-model",
},
});
expect(updateSession).not.toHaveBeenCalledWith(
expect.objectContaining({
provider: expect.any(String),
model: expect.any(String),
}),
);
expect(writeSessionManifest).toHaveBeenCalledTimes(1);
const update = updateConnection.mock.calls[0]?.[0];
expect(update).toMatchObject({
providerId: "mock-provider",
modelId: "mock-model",
});
expect(update).not.toHaveProperty("apiKey");
expect(update).not.toHaveProperty("baseUrl");
expect(update).not.toHaveProperty("headers");
expect(updateConnectionDefaults).toHaveBeenCalledWith(update);
expect(run).toHaveBeenCalledTimes(1);
});
it("hydrates provider-specific config from provider settings", async () => {
const sessionId = "sess-provider-config";
const manifest = createManifest(sessionId);
@@ -20,7 +20,10 @@ import type { TeamEvent } from "../../extensions/tools/team";
import type { HookEventPayload } from "../../hooks";
import { buildTelemetryAgentIdentity } from "../../services/agent-events";
import { resolveWorkspacePath } from "../../services/config";
import { prepareLocalRuntimeBootstrap } from "../../services/local-runtime-bootstrap";
import {
buildProviderConfig,
prepareLocalRuntimeBootstrap,
} from "../../services/local-runtime-bootstrap";
import { nowIso } from "../../services/session-artifacts";
import {
toSessionRecord,
@@ -106,6 +109,7 @@ import type {
RuntimeHostSubscribeOptions,
SendSessionInput,
SessionAccumulatedUsage,
SessionConnectionUpdate,
SessionUsageSummary,
StartSessionInput,
StartSessionResult,
@@ -695,6 +699,7 @@ export class LocalRuntimeHost implements RuntimeHost {
delivery,
userImages: input.userImages,
userFiles: input.userFiles,
connection: input.connection,
});
return undefined;
}
@@ -704,6 +709,7 @@ export class LocalRuntimeHost implements RuntimeHost {
mode: input.mode,
userImages: input.userImages,
userFiles: input.userFiles,
connection: input.connection,
});
if (!session.interactive) {
await this.finalizeSingleRun(session, result.finishReason);
@@ -917,13 +923,12 @@ export class LocalRuntimeHost implements RuntimeHost {
return this.events.subscribe(listener, options);
}
async updateSessionModel(sessionId: string, modelId: string): Promise<void> {
async updateSessionConnection(
sessionId: string,
connection: SessionConnectionUpdate,
): Promise<void> {
const session = this.getSessionOrThrow(sessionId);
session.config.modelId = modelId;
session.runtime.delegatedAgentConfigProvider?.updateConnectionDefaults({
modelId,
});
session.agent.updateConnection({ modelId });
await this.applySessionConnectionUpdate(session, connection);
}
// Retained for unit tests that reach in via Reflect.
@@ -950,6 +955,7 @@ export class LocalRuntimeHost implements RuntimeHost {
mode?: SendSessionInput["mode"];
userImages?: string[];
userFiles?: string[];
connection?: SessionConnectionUpdate;
},
): Promise<AgentResult> {
const preparedInput = await this.prepareTurnInput(session, input);
@@ -960,6 +966,9 @@ export class LocalRuntimeHost implements RuntimeHost {
session.pendingPrompt = prompt;
}
await this.ensureSessionPersisted(session);
if (input.connection) {
await this.applySessionConnectionUpdate(session, input.connection);
}
await this.syncOAuthCredentials(session);
await this.markTurnRunning(session);
@@ -1248,6 +1257,145 @@ export class LocalRuntimeHost implements RuntimeHost {
};
}
private async applySessionConnectionUpdate(
session: ActiveSession,
connection: SessionConnectionUpdate,
): Promise<void> {
const previousProviderId = session.config.providerId;
const previousModelId = session.config.modelId;
const providerChanged =
connection.providerId !== undefined &&
connection.providerId !== previousProviderId;
const nextConfig: CoreSessionConfig = { ...session.config };
if (connection.providerId !== undefined) {
nextConfig.providerId = connection.providerId;
}
if (connection.modelId !== undefined) {
nextConfig.modelId = connection.modelId;
}
if (providerChanged) {
if (connection.apiKey === undefined) delete nextConfig.apiKey;
if (connection.baseUrl === undefined) delete nextConfig.baseUrl;
if (connection.headers === undefined) delete nextConfig.headers;
if (connection.providerConfig === undefined) {
delete nextConfig.providerConfig;
}
}
if (connection.apiKey !== undefined) nextConfig.apiKey = connection.apiKey;
if (connection.baseUrl !== undefined)
nextConfig.baseUrl = connection.baseUrl;
if (connection.headers !== undefined)
nextConfig.headers = connection.headers;
if (connection.providerConfig !== undefined) {
nextConfig.providerConfig =
connection.providerConfig as CoreSessionConfig["providerConfig"];
}
if (connection.reasoningEffort !== undefined) {
nextConfig.reasoningEffort = connection.reasoningEffort;
}
if (connection.thinking !== undefined)
nextConfig.thinking = connection.thinking;
if (connection.thinkingBudgetTokens !== undefined) {
nextConfig.thinkingBudgetTokens = connection.thinkingBudgetTokens;
}
if (connection.providerConfig === undefined) {
const providerConfig = buildProviderConfig(
nextConfig,
session.sessionId,
this.providerSettingsManager,
undefined,
this.defaultFetch,
);
nextConfig.providerConfig = providerConfig;
nextConfig.apiKey = providerConfig.apiKey;
nextConfig.baseUrl = providerConfig.baseUrl;
nextConfig.headers = providerConfig.headers;
nextConfig.reasoningEffort =
nextConfig.reasoningEffort ?? providerConfig.reasoningEffort;
nextConfig.thinkingBudgetTokens =
nextConfig.thinkingBudgetTokens ?? providerConfig.thinkingBudgetTokens;
}
const runtimeConnection: SessionConnectionUpdate = {
providerId: nextConfig.providerId,
modelId: nextConfig.modelId,
};
if (nextConfig.apiKey !== undefined) {
runtimeConnection.apiKey = nextConfig.apiKey;
} else if (providerChanged) {
runtimeConnection.apiKey = "";
}
if (nextConfig.baseUrl !== undefined) {
runtimeConnection.baseUrl = nextConfig.baseUrl;
} else if (providerChanged) {
runtimeConnection.baseUrl = "";
}
if (nextConfig.headers !== undefined) {
runtimeConnection.headers = nextConfig.headers;
} else if (providerChanged) {
runtimeConnection.headers = {};
}
if (nextConfig.providerConfig !== undefined) {
runtimeConnection.providerConfig = nextConfig.providerConfig;
}
if (nextConfig.reasoningEffort !== undefined) {
runtimeConnection.reasoningEffort = nextConfig.reasoningEffort;
}
if (nextConfig.thinking !== undefined) {
runtimeConnection.thinking = nextConfig.thinking;
}
if (nextConfig.thinkingBudgetTokens !== undefined) {
runtimeConnection.thinkingBudgetTokens = nextConfig.thinkingBudgetTokens;
}
session.config = nextConfig;
session.agent.updateConnection(runtimeConnection);
session.runtime.delegatedAgentConfigProvider?.updateConnectionDefaults(
runtimeConnection,
);
session.runtime.teamRuntime?.updateTeammateConnections(runtimeConnection);
await this.persistSessionConnection(session, connection, {
providerId: previousProviderId,
modelId: previousModelId,
});
}
private async persistSessionConnection(
session: ActiveSession,
connection: SessionConnectionUpdate,
previous: { providerId: string; modelId: string },
): Promise<void> {
if (!session.artifacts) return;
const providerChanged =
connection.providerId !== undefined &&
connection.providerId !== previous.providerId;
const modelChanged =
connection.modelId !== undefined &&
connection.modelId !== previous.modelId;
if (!providerChanged && !modelChanged) {
return;
}
const result = await this.invokeOptionalValue<{ updated?: boolean }>(
"updateSession",
{
sessionId: session.sessionId,
provider: connection.providerId ?? session.config.providerId,
model: connection.modelId ?? session.config.modelId,
},
);
if (result?.updated === false) {
return;
}
session.artifacts.manifest.provider = session.config.providerId;
session.artifacts.manifest.model = session.config.modelId;
await this.invoke<void>(
"writeSessionManifest",
session.artifacts.manifestPath,
session.artifacts.manifest,
);
}
// ── Session lifecycle ───────────────────────────────────────────────
private async ensureSessionPersisted(session: ActiveSession): Promise<void> {
@@ -16,6 +16,7 @@ import type {
} from "../../types/events";
import type { SessionRecord } from "../../types/sessions";
import type { RuntimeCapabilities } from "../capabilities";
import type { ConnectionOverrides } from "../orchestration/session-runtime-orchestrator";
type LocalOnlyCoreSessionConfigKeys =
| "hooks"
@@ -166,10 +167,13 @@ export interface SendSessionInput {
mode?: AgentMode;
userImages?: string[];
userFiles?: string[];
connection?: SessionConnectionUpdate;
delivery?: "queue" | "steer";
timeoutMs?: number;
}
export type SessionConnectionUpdate = ConnectionOverrides;
export interface SessionAccumulatedUsage {
inputTokens: number;
outputTokens: number;
@@ -228,8 +232,11 @@ export interface SessionUsageRuntimeService {
): Promise<SessionUsageSummary | undefined>;
}
export interface SessionModelRuntimeService {
updateSessionModel(sessionId: string, modelId: string): Promise<void>;
export interface SessionConnectionRuntimeService {
updateSessionConnection(
sessionId: string,
connection: SessionConnectionUpdate,
): Promise<void>;
}
export interface RuntimeHostSubscribeOptions {
@@ -9,6 +9,7 @@ import type {
PendingPromptMutationResult,
PendingPromptsDeleteInput,
PendingPromptsUpdateInput,
SessionConnectionUpdate,
} from "../host/runtime-host";
export type PendingPromptDelivery = "queue" | "steer";
@@ -20,6 +21,7 @@ export interface PendingPromptEntry {
delivery: PendingPromptDelivery;
userImages?: string[];
userFiles?: string[];
connection?: SessionConnectionUpdate;
}
export interface PendingPromptQueueState {
@@ -35,6 +37,7 @@ export interface PendingPromptsControllerDeps {
mode?: AgentMode;
userImages?: string[];
userFiles?: string[];
connection?: SessionConnectionUpdate;
}): Promise<unknown>;
}
@@ -44,6 +47,7 @@ export interface PendingPromptEnqueueInput {
delivery: PendingPromptDelivery;
userImages?: string[];
userFiles?: string[];
connection?: SessionConnectionUpdate;
}
export interface PendingPromptConsumeResult {
@@ -150,6 +154,7 @@ export class PendingPromptService {
mode: mode ?? existing.mode,
userImages: userImages ?? existing.userImages,
userFiles: userFiles ?? existing.userFiles,
connection: input.connection ?? existing.connection,
};
if (delivery === "steer" || existing.delivery === "steer") {
state.pendingPrompts.unshift({ ...next, delivery: "steer" });
@@ -164,6 +169,7 @@ export class PendingPromptService {
delivery,
userImages,
userFiles,
connection: input.connection,
};
if (delivery === "steer") {
state.pendingPrompts.unshift(newEntry);
@@ -243,6 +249,7 @@ export class PendingPromptsController {
delivery: "queue" | "steer";
userImages?: string[];
userFiles?: string[];
connection?: SessionConnectionUpdate;
},
): void {
const session = this.deps.getSession(sessionId);
@@ -314,6 +321,7 @@ export class PendingPromptsController {
...(next.mode ? { mode: next.mode } : {}),
userImages: next.userImages,
userFiles: next.userFiles,
connection: next.connection,
});
} catch {
continueDrain = false;
@@ -171,7 +171,7 @@ function deriveOpenAICodexAccountId(
return undefined;
}
function buildProviderConfig(
export function buildProviderConfig(
config: CoreSessionConfig,
sessionId: string,
providerSettingsManager: ProviderSettingsManager,
@@ -171,6 +171,14 @@ class FileSessionPersistenceAdapter implements SessionPersistenceAdapter {
: (existing.exitCode ?? null),
prompt:
input.prompt !== undefined ? input.prompt : (existing.prompt ?? null),
provider:
input.provider !== undefined
? (input.provider ?? existing.provider)
: existing.provider,
model:
input.model !== undefined
? (input.model ?? existing.model)
: existing.model,
metadata:
input.metadata !== undefined
? (input.metadata ?? null)
@@ -214,6 +214,8 @@ export class UnifiedSessionPersistenceService {
async updateSession(input: {
sessionId: string;
prompt?: string | null;
provider?: string | null;
model?: string | null;
metadata?: Record<string, unknown> | null;
title?: string | null;
}): Promise<{ updated: boolean }> {
@@ -251,6 +253,8 @@ export class UnifiedSessionPersistenceService {
const changed = await this.adapter.updateSession({
sessionId: input.sessionId,
prompt: input.prompt,
provider: input.provider || undefined,
model: input.model || undefined,
metadata: hasMetadataChange
? Object.keys(baseMeta).length > 0
? baseMeta
@@ -267,6 +271,12 @@ export class UnifiedSessionPersistenceService {
if (input.prompt !== undefined) {
manifest.prompt = input.prompt ?? undefined;
}
if (input.provider !== undefined && input.provider) {
manifest.provider = input.provider;
}
if (input.model !== undefined && input.model) {
manifest.model = input.model;
}
const manifestMeta =
input.metadata !== undefined
? (sanitizeMetadata(input.metadata) ?? {})
@@ -157,6 +157,14 @@ class LocalSessionPersistenceAdapter implements SessionPersistenceAdapter {
fields.push("prompt = ?");
params.push(input.prompt ?? null);
}
if (input.provider !== undefined) {
fields.push("provider = ?");
params.push(input.provider ?? null);
}
if (input.model !== undefined) {
fields.push("model = ?");
params.push(input.model ?? null);
}
if (input.metadata !== undefined) {
fields.push("metadata_json = ?");
params.push(stringifyMetadata(input.metadata));
+2
View File
@@ -87,6 +87,8 @@ export type {
RuntimeHostMode,
SendSessionInput,
SessionAccumulatedUsage,
SessionConnectionRuntimeService,
SessionConnectionUpdate,
SessionUsageSummary,
StartSessionInput,
StartSessionResult,
+4
View File
@@ -37,6 +37,10 @@ export interface CoreModelConfig {
* Explicit reasoning effort override for capable models.
*/
reasoningEffort?: ProviderConfig["reasoningEffort"];
/**
* Explicit thinking/reasoning token budget override for capable models.
*/
thinkingBudgetTokens?: ProviderConfig["thinkingBudgetTokens"];
}
export interface CoreRuntimeFeatures {
+7 -1
View File
@@ -1,6 +1,9 @@
import type * as LlmsProviders from "@cline/llms";
import type { AgentFinishReason } from "@cline/shared";
import type { SessionAccumulatedUsage } from "../runtime/host/runtime-host";
import type {
SessionAccumulatedUsage,
SessionConnectionUpdate,
} from "../runtime/host/runtime-host";
import type { BuiltRuntime } from "../runtime/orchestration/session-runtime";
import type { SessionRuntime } from "../runtime/orchestration/session-runtime-orchestrator";
import type { SessionRow } from "../session/models/session-row";
@@ -62,6 +65,7 @@ export type PendingPrompt = {
delivery: "queue" | "steer";
userImages?: string[];
userFiles?: string[];
connection?: SessionConnectionUpdate;
};
export type TeamRunUpdate = {
@@ -90,6 +94,8 @@ export interface PersistedSessionUpdateInput {
endedAt?: string | null;
exitCode?: number | null;
prompt?: string | null;
provider?: string | null;
model?: string | null;
metadata?: Record<string, unknown> | null;
title?: string | null;
parentSessionId?: string | null;