Skip to content

Commit a140597

Browse files
Merge pull request #4 from hrishikeshmane/fix/kiro-in-session-model-switch
fix(kiro): use session/set_model for in-session model switching
2 parents 6e1d670 + 8b97c92 commit a140597

4 files changed

Lines changed: 203 additions & 9 deletions

File tree

apps/server/scripts/acp-mock-agent.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,15 @@ const program = Effect.gen(function* () {
248248
),
249249
);
250250

251+
yield* agent.handleSetSessionModel((request) =>
252+
Effect.gen(function* () {
253+
if (typeof request.modelId === "string") {
254+
currentModelId = request.modelId;
255+
}
256+
return {};
257+
}),
258+
);
259+
251260
yield* agent.handleSetSessionConfigOption((request) =>
252261
Effect.gen(function* () {
253262
if (exitOnSetConfigOption) {

apps/server/scripts/kiro-mock-agent.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
/**
33
* Mock ACP agent that imitates kiro-cli ACP protocol for integration tests.
44
*
5-
* Responds to: initialize, session/new, session/prompt, session/cancel.
5+
* Responds to: initialize, session/new, session/prompt, session/cancel,
6+
* session/set_model, session/set_config_option.
67
* Emits: session/update (agent_message_chunk), _kiro.dev/metadata extension.
78
*/
89
import * as Effect from "effect/Effect";
@@ -14,6 +15,22 @@ import * as EffectAcpAgent from "effect-acp/agent";
1415

1516
const sessionId = "kiro-mock-session-1";
1617

18+
/** Available models — returned in session responses. */
19+
const availableModels = ["auto", "claude-sonnet-4-20250514", "claude-opus-4-20250918"];
20+
let currentModel = "auto";
21+
22+
/** Build the model config option structure used in ACP responses. */
23+
function makeModelConfigOption(model: string) {
24+
return {
25+
id: "model",
26+
name: "Model",
27+
type: "select" as const,
28+
category: "model" as const,
29+
currentValue: model,
30+
options: availableModels.map((m) => ({ value: m, name: m })),
31+
};
32+
}
33+
1734
const program = Effect.gen(function* () {
1835
const agent = yield* EffectAcpAgent.AcpAgent;
1936

@@ -33,13 +50,32 @@ const program = Effect.gen(function* () {
3350
yield* agent.handleCreateSession(() =>
3451
Effect.succeed({
3552
sessionId,
53+
configOptions: [makeModelConfigOption(currentModel)],
3654
}),
3755
);
3856

3957
yield* agent.handleLoadSession(() => Effect.succeed({}));
4058

4159
yield* agent.handleCancel(() => Effect.void);
4260

61+
yield* agent.handleSetSessionModel((request) =>
62+
Effect.gen(function* () {
63+
if (typeof request.modelId === "string") {
64+
currentModel = request.modelId;
65+
}
66+
return {};
67+
}),
68+
);
69+
70+
yield* agent.handleSetSessionConfigOption((request) =>
71+
Effect.gen(function* () {
72+
if (request.configId === "model" && "value" in request && typeof request.value === "string") {
73+
currentModel = request.value;
74+
}
75+
return { configOptions: [makeModelConfigOption(currentModel)] };
76+
}),
77+
);
78+
4379
yield* agent.handlePrompt((request) =>
4480
Effect.gen(function* () {
4581
const requestedSessionId = String(request.sessionId ?? sessionId);

apps/server/src/provider/Layers/KiroAdapter.integration.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,4 +341,141 @@ describe("KiroAdapterLive integration", () => {
341341
yield* adapter.stopSession(threadId);
342342
}).pipe(Effect.scoped, Effect.provide(adapterLayer)),
343343
);
344+
345+
it.effect(
346+
"switches model in-session without restarting the process",
347+
() =>
348+
Effect.gen(function* () {
349+
const adapter = yield* KiroAdapter;
350+
const threadId = ThreadId.make("kiro-int-model-switch-1");
351+
352+
yield* adapter.startSession({
353+
threadId,
354+
provider: "kiro",
355+
cwd: process.cwd(),
356+
runtimeMode: "full-access",
357+
modelSelection: { provider: "kiro", model: "auto" },
358+
});
359+
360+
const spawnCountAfterStart = capturedArgs.length;
361+
362+
// First turn with default model
363+
yield* adapter.sendTurn({
364+
threadId,
365+
input: "first turn",
366+
attachments: [],
367+
});
368+
369+
// Second turn with different model — should NOT respawn
370+
yield* adapter.sendTurn({
371+
threadId,
372+
input: "second turn after model switch",
373+
attachments: [],
374+
modelSelection: {
375+
provider: "kiro",
376+
model: "claude-sonnet-4-20250514",
377+
},
378+
});
379+
380+
// Session should NOT have been restarted — only one spawn
381+
expect(capturedArgs.length).toBe(spawnCountAfterStart);
382+
383+
yield* adapter.stopSession(threadId);
384+
}).pipe(Effect.scoped, Effect.provide(adapterLayer)),
385+
);
386+
387+
it.effect(
388+
"updates session.model immediately after in-session model switch",
389+
() =>
390+
Effect.gen(function* () {
391+
const adapter = yield* KiroAdapter;
392+
const threadId = ThreadId.make("kiro-int-model-state-1");
393+
394+
const session = yield* adapter.startSession({
395+
threadId,
396+
provider: "kiro",
397+
cwd: process.cwd(),
398+
runtimeMode: "full-access",
399+
modelSelection: { provider: "kiro", model: "auto" },
400+
});
401+
402+
expect(session.model).toBe("auto");
403+
404+
// Collect turn.started events to verify model is correct
405+
const eventsFiber = yield* adapter.streamEvents.pipe(
406+
Stream.filter(
407+
(event) => event.type === "turn.started" || event.type === "turn.completed",
408+
),
409+
Stream.take(2), // turn.started + turn.completed for the model-switch turn
410+
Stream.runCollect,
411+
Effect.forkChild,
412+
);
413+
414+
// Send turn with new model
415+
const turn = yield* adapter.sendTurn({
416+
threadId,
417+
input: "switch model turn",
418+
attachments: [],
419+
modelSelection: {
420+
provider: "kiro",
421+
model: "claude-sonnet-4-20250514",
422+
},
423+
});
424+
425+
expect(turn.threadId).toBe(threadId);
426+
427+
const events = yield* Fiber.join(eventsFiber);
428+
const turnStarted = events.find((e) => e.type === "turn.started");
429+
expect(turnStarted).toBeDefined();
430+
if (turnStarted?.type === "turn.started") {
431+
expect(turnStarted.payload.model).toBe("claude-sonnet-4-20250514");
432+
}
433+
434+
// Verify session state reflects the new model
435+
const sessions = yield* adapter.listSessions();
436+
const currentSession = sessions.find((s) => s.threadId === threadId);
437+
expect(currentSession?.model).toBe("claude-sonnet-4-20250514");
438+
439+
yield* adapter.stopSession(threadId);
440+
}).pipe(Effect.scoped, Effect.provide(adapterLayer)),
441+
);
442+
443+
it.effect(
444+
"does not call setModel when model is unchanged between turns",
445+
() =>
446+
Effect.gen(function* () {
447+
const adapter = yield* KiroAdapter;
448+
const threadId = ThreadId.make("kiro-int-model-unchanged-1");
449+
450+
yield* adapter.startSession({
451+
threadId,
452+
provider: "kiro",
453+
cwd: process.cwd(),
454+
runtimeMode: "full-access",
455+
modelSelection: { provider: "kiro", model: "auto" },
456+
});
457+
458+
// Two turns with the same model — should not trigger setModel
459+
yield* adapter.sendTurn({
460+
threadId,
461+
input: "first turn",
462+
attachments: [],
463+
modelSelection: { provider: "kiro", model: "auto" },
464+
});
465+
466+
yield* adapter.sendTurn({
467+
threadId,
468+
input: "second turn same model",
469+
attachments: [],
470+
modelSelection: { provider: "kiro", model: "auto" },
471+
});
472+
473+
// Both turns should succeed without issues (no setModel called)
474+
const sessions = yield* adapter.listSessions();
475+
const currentSession = sessions.find((s) => s.threadId === threadId);
476+
expect(currentSession?.model).toBe("auto");
477+
478+
yield* adapter.stopSession(threadId);
479+
}).pipe(Effect.scoped, Effect.provide(adapterLayer)),
480+
);
344481
});

apps/server/src/provider/Layers/KiroAdapter.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -984,13 +984,26 @@ function makeKiroAdapter(options?: KiroAdapterLiveOptions) {
984984

985985
// Only switch model if different from current
986986
if (model !== ctx.session.model && model !== "auto") {
987-
yield* ctx.acp.setModel(model).pipe(
988-
Effect.mapError((error) =>
989-
mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", error),
990-
),
991-
// Kiro may not support session/set_config_option — ignore errors
992-
Effect.catch(() => Effect.void),
993-
);
987+
// Kiro supports session/set_model but NOT session/set_config_option.
988+
// AcpSessionRuntime.setModel routes through setConfigOption, which
989+
// Kiro rejects with -32601 "Method not found". Call the correct
990+
// RPC method directly via the raw request interface.
991+
yield* ctx.acp
992+
.request("session/set_model", {
993+
sessionId: ctx.mainSessionId,
994+
modelId: model,
995+
})
996+
.pipe(
997+
Effect.mapError((error) =>
998+
mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", error),
999+
),
1000+
);
1001+
// Update session model immediately after successful setModel,
1002+
// matching the pattern used by ClaudeAdapter and OpenCodeAdapter.
1003+
ctx.session = {
1004+
...ctx.session,
1005+
model,
1006+
};
9941007
}
9951008
ctx.activeTurnId = turnId;
9961009
ctx.session = {
@@ -1036,7 +1049,6 @@ function makeKiroAdapter(options?: KiroAdapterLiveOptions) {
10361049
...ctx.session,
10371050
activeTurnId: turnId,
10381051
updatedAt: yield* nowIso,
1039-
model,
10401052
};
10411053

10421054
const stopReason = ctx.interrupted ? "interrupted" : (result.stopReason ?? null);

0 commit comments

Comments
 (0)