mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ec403e16a | |||
| 6155fc1d82 | |||
| bd4821a2ea |
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -87,6 +87,8 @@ export type {
|
||||
RuntimeHostMode,
|
||||
SendSessionInput,
|
||||
SessionAccumulatedUsage,
|
||||
SessionConnectionRuntimeService,
|
||||
SessionConnectionUpdate,
|
||||
SessionUsageSummary,
|
||||
StartSessionInput,
|
||||
StartSessionResult,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user