Skip to content

Commit 3db4f34

Browse files
feat(claude): Reflect Claude’s autonomous plan mode transitions to the UI
Signed-off-by: Abdulelah Hajjar <aab.hajjar@gmail.com>
1 parent 9403429 commit 3db4f34

5 files changed

Lines changed: 264 additions & 1 deletion

File tree

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2320,4 +2320,65 @@ describe("ProviderRuntimeIngestion", () => {
23202320
expect(thread.session?.status).toBe("error");
23212321
expect(thread.session?.lastError).toBe("runtime still processed");
23222322
});
2323+
2324+
it("dispatches thread.interaction-mode.set when interaction.mode.changed arrives with a different mode", async () => {
2325+
const harness = await createHarness();
2326+
const now = new Date().toISOString();
2327+
2328+
// Thread starts with DEFAULT_PROVIDER_INTERACTION_MODE ("default").
2329+
// Emit interaction.mode.changed with "plan" — should update the thread.
2330+
harness.emit({
2331+
type: "interaction.mode.changed",
2332+
eventId: asEventId("evt-mode-changed-plan"),
2333+
provider: "claudeAgent",
2334+
threadId: asThreadId("thread-1"),
2335+
createdAt: now,
2336+
payload: {
2337+
interactionMode: "plan",
2338+
},
2339+
});
2340+
2341+
const thread = await waitForThread(harness.engine, (entry) => entry.interactionMode === "plan");
2342+
expect(thread.interactionMode).toBe("plan");
2343+
});
2344+
2345+
it("does not dispatch when interaction.mode.changed arrives with the same mode", async () => {
2346+
const harness = await createHarness();
2347+
const now = new Date().toISOString();
2348+
2349+
// First, change to plan mode.
2350+
harness.emit({
2351+
type: "interaction.mode.changed",
2352+
eventId: asEventId("evt-mode-changed-plan-1"),
2353+
provider: "claudeAgent",
2354+
threadId: asThreadId("thread-1"),
2355+
createdAt: now,
2356+
payload: {
2357+
interactionMode: "plan",
2358+
},
2359+
});
2360+
2361+
await waitForThread(harness.engine, (entry) => entry.interactionMode === "plan");
2362+
2363+
// Now emit the same mode again. The thread should still be in plan mode
2364+
// but no new command should be dispatched. We verify by emitting a
2365+
// subsequent event and confirming the thread state is unchanged.
2366+
harness.emit({
2367+
type: "interaction.mode.changed",
2368+
eventId: asEventId("evt-mode-changed-plan-2"),
2369+
provider: "claudeAgent",
2370+
threadId: asThreadId("thread-1"),
2371+
createdAt: new Date().toISOString(),
2372+
payload: {
2373+
interactionMode: "plan",
2374+
},
2375+
});
2376+
2377+
// Drain to ensure the duplicate event is processed.
2378+
await harness.drain();
2379+
2380+
const readModel = await Effect.runPromise(harness.engine.getReadModel());
2381+
const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1"));
2382+
expect(thread?.interactionMode).toBe("plan");
2383+
});
23232384
});

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,6 +1210,19 @@ const make = Effect.gen(function* () {
12101210
});
12111211
}
12121212

1213+
if (
1214+
event.type === "interaction.mode.changed" &&
1215+
event.payload.interactionMode !== thread.interactionMode
1216+
) {
1217+
yield* orchestrationEngine.dispatch({
1218+
type: "thread.interaction-mode.set",
1219+
commandId: providerCommandId(event, "interaction-mode-changed"),
1220+
threadId: thread.id,
1221+
interactionMode: event.payload.interactionMode,
1222+
createdAt: now,
1223+
});
1224+
}
1225+
12131226
if (event.type === "turn.diff.updated") {
12141227
const turnId = toTurnId(event.turnId);
12151228
if (turnId && (yield* isGitRepoForThread(thread.id))) {

apps/server/src/provider/Layers/ClaudeAdapter.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2983,4 +2983,139 @@ describe("ClaudeAdapterLive", () => {
29832983
Effect.provide(harness.layer),
29842984
);
29852985
});
2986+
2987+
it.effect(
2988+
"emits interaction.mode.changed with plan when EnterPlanMode tool starts in stream",
2989+
() => {
2990+
const harness = makeHarness();
2991+
return Effect.gen(function* () {
2992+
const adapter = yield* ClaudeAdapter;
2993+
2994+
// Collect enough events: session.started, session.configured, session.state.changed,
2995+
// turn.started, interaction.mode.changed, item.started, turn.completed = 7
2996+
const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe(
2997+
Stream.runCollect,
2998+
Effect.forkChild,
2999+
);
3000+
3001+
yield* adapter.startSession({
3002+
threadId: THREAD_ID,
3003+
provider: "claudeAgent",
3004+
runtimeMode: "full-access",
3005+
});
3006+
3007+
yield* adapter.sendTurn({
3008+
threadId: THREAD_ID,
3009+
input: "please plan this",
3010+
attachments: [],
3011+
});
3012+
3013+
harness.query.emit({
3014+
type: "stream_event",
3015+
session_id: "sdk-session-plan-enter",
3016+
uuid: "stream-enter-plan-0",
3017+
parent_tool_use_id: null,
3018+
event: {
3019+
type: "content_block_start",
3020+
index: 0,
3021+
content_block: {
3022+
type: "tool_use",
3023+
id: "tool-enter-plan-1",
3024+
name: "EnterPlanMode",
3025+
input: {},
3026+
},
3027+
},
3028+
} as unknown as SDKMessage);
3029+
3030+
harness.query.emit({
3031+
type: "result",
3032+
subtype: "success",
3033+
is_error: false,
3034+
errors: [],
3035+
session_id: "sdk-session-plan-enter",
3036+
uuid: "result-enter-plan",
3037+
} as unknown as SDKMessage);
3038+
3039+
const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
3040+
const modeChanged = runtimeEvents.find(
3041+
(event) => event.type === "interaction.mode.changed",
3042+
);
3043+
assert.equal(modeChanged?.type, "interaction.mode.changed");
3044+
if (modeChanged?.type === "interaction.mode.changed") {
3045+
assert.equal(modeChanged.payload.interactionMode, "plan");
3046+
}
3047+
}).pipe(
3048+
Effect.provideService(Random.Random, makeDeterministicRandomService()),
3049+
Effect.provide(harness.layer),
3050+
);
3051+
},
3052+
);
3053+
3054+
it.effect(
3055+
"emits interaction.mode.changed with default when ExitPlanMode tool starts in stream",
3056+
() => {
3057+
const harness = makeHarness();
3058+
return Effect.gen(function* () {
3059+
const adapter = yield* ClaudeAdapter;
3060+
3061+
const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe(
3062+
Stream.runCollect,
3063+
Effect.forkChild,
3064+
);
3065+
3066+
yield* adapter.startSession({
3067+
threadId: THREAD_ID,
3068+
provider: "claudeAgent",
3069+
runtimeMode: "full-access",
3070+
});
3071+
3072+
yield* adapter.sendTurn({
3073+
threadId: THREAD_ID,
3074+
input: "exit plan mode",
3075+
interactionMode: "plan",
3076+
attachments: [],
3077+
});
3078+
3079+
harness.query.emit({
3080+
type: "stream_event",
3081+
session_id: "sdk-session-plan-exit",
3082+
uuid: "stream-exit-plan-0",
3083+
parent_tool_use_id: null,
3084+
event: {
3085+
type: "content_block_start",
3086+
index: 0,
3087+
content_block: {
3088+
type: "tool_use",
3089+
id: "tool-exit-plan-1",
3090+
name: "ExitPlanMode",
3091+
input: {
3092+
plan: "# My plan",
3093+
},
3094+
},
3095+
},
3096+
} as unknown as SDKMessage);
3097+
3098+
harness.query.emit({
3099+
type: "result",
3100+
subtype: "success",
3101+
is_error: false,
3102+
errors: [],
3103+
session_id: "sdk-session-plan-exit",
3104+
uuid: "result-exit-plan",
3105+
} as unknown as SDKMessage);
3106+
3107+
const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
3108+
const modeChanged = runtimeEvents.find(
3109+
(event) => event.type === "interaction.mode.changed",
3110+
);
3111+
assert.equal(modeChanged?.type, "interaction.mode.changed");
3112+
if (modeChanged?.type === "interaction.mode.changed") {
3113+
assert.equal(modeChanged.payload.interactionMode, "default");
3114+
}
3115+
}).pipe(
3116+
Effect.provideService(Random.Random, makeDeterministicRandomService()),
3117+
Effect.provide(harness.layer),
3118+
);
3119+
},
3120+
);
29863121
});

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
type CanonicalRequestType,
2525
EventId,
2626
type ProviderApprovalDecision,
27+
type ProviderInteractionMode,
2728
ProviderItemId,
2829
type ProviderRuntimeEvent,
2930
type ProviderRuntimeTurnStatus,
@@ -444,6 +445,21 @@ function isReadOnlyToolName(toolName: string): boolean {
444445
);
445446
}
446447

448+
/**
449+
* Maps a tool name to the interaction mode it implies.
450+
* Returns undefined when the tool has no mode semantics.
451+
*/
452+
function interactionModeForTool(toolName: string): ProviderInteractionMode | undefined {
453+
switch (toolName) {
454+
case "EnterPlanMode":
455+
return "plan";
456+
case "ExitPlanMode":
457+
return "default";
458+
default:
459+
return undefined;
460+
}
461+
}
462+
447463
function classifyRequestType(toolName: string): CanonicalRequestType {
448464
if (isReadOnlyToolName(toolName)) {
449465
return "file_read_approval";
@@ -1352,6 +1368,23 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
13521368
});
13531369
});
13541370

1371+
const emitInteractionModeChanged = (
1372+
context: ClaudeSessionContext,
1373+
mode: ProviderInteractionMode,
1374+
): Effect.Effect<void> =>
1375+
Effect.gen(function* () {
1376+
const stamp = yield* makeEventStamp();
1377+
yield* offerRuntimeEvent({
1378+
type: "interaction.mode.changed",
1379+
eventId: stamp.eventId,
1380+
provider: PROVIDER,
1381+
createdAt: stamp.createdAt,
1382+
threadId: context.session.threadId,
1383+
...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1384+
payload: { interactionMode: mode },
1385+
});
1386+
});
1387+
13551388
const completeTurn = (
13561389
context: ClaudeSessionContext,
13571390
status: ProviderRuntimeTurnStatus,
@@ -1673,6 +1706,11 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
16731706

16741707
const toolName = block.name;
16751708
const itemType = classifyToolItemType(toolName);
1709+
1710+
const resolvedInteractionMode = interactionModeForTool(toolName);
1711+
if (resolvedInteractionMode) {
1712+
yield* emitInteractionModeChanged(context, resolvedInteractionMode);
1713+
}
16761714
const toolInput =
16771715
typeof block.input === "object" && block.input !== null
16781716
? (block.input as Record<string, unknown>)

packages/contracts/src/providerRuntime.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
TrimmedNonEmptyString,
1313
TurnId,
1414
} from "./baseSchemas";
15-
import { ProviderKind } from "./orchestration";
15+
import { ProviderInteractionMode, ProviderKind } from "./orchestration";
1616

1717
const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
1818
const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown);
@@ -189,6 +189,7 @@ const ProviderRuntimeEventType = Schema.Literals([
189189
"files.persisted",
190190
"runtime.warning",
191191
"runtime.error",
192+
"interaction.mode.changed",
192193
]);
193194
export type ProviderRuntimeEventType = typeof ProviderRuntimeEventType.Type;
194195

@@ -239,6 +240,7 @@ const DeprecationNoticeType = Schema.Literal("deprecation.notice");
239240
const FilesPersistedType = Schema.Literal("files.persisted");
240241
const RuntimeWarningType = Schema.Literal("runtime.warning");
241242
const RuntimeErrorType = Schema.Literal("runtime.error");
243+
const InteractionModeChangedType = Schema.Literal("interaction.mode.changed");
242244

243245
const ProviderRuntimeEventBase = Schema.Struct({
244246
eventId: EventId,
@@ -940,6 +942,19 @@ const ProviderRuntimeErrorEvent = Schema.Struct({
940942
});
941943
export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type;
942944

945+
const InteractionModeChangedPayload = Schema.Struct({
946+
interactionMode: ProviderInteractionMode,
947+
});
948+
export type InteractionModeChangedPayload = typeof InteractionModeChangedPayload.Type;
949+
950+
const ProviderRuntimeInteractionModeChangedEvent = Schema.Struct({
951+
...ProviderRuntimeEventBase.fields,
952+
type: InteractionModeChangedType,
953+
payload: InteractionModeChangedPayload,
954+
});
955+
export type ProviderRuntimeInteractionModeChangedEvent =
956+
typeof ProviderRuntimeInteractionModeChangedEvent.Type;
957+
943958
export const ProviderRuntimeEventV2 = Schema.Union([
944959
ProviderRuntimeSessionStartedEvent,
945960
ProviderRuntimeSessionConfiguredEvent,
@@ -988,6 +1003,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([
9881003
ProviderRuntimeFilesPersistedEvent,
9891004
ProviderRuntimeWarningEvent,
9901005
ProviderRuntimeErrorEvent,
1006+
ProviderRuntimeInteractionModeChangedEvent,
9911007
]);
9921008
export type ProviderRuntimeEventV2 = typeof ProviderRuntimeEventV2.Type;
9931009

0 commit comments

Comments
 (0)