Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
85 changes: 84 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ interface ClaudeTurnState {
readonly assistantTextBlocks: Map<number, AssistantTextBlockState>;
readonly assistantTextBlockOrder: Array<AssistantTextBlockState>;
readonly capturedProposedPlanKeys: Set<string>;
readonly interactionMode: "plan" | "default";
nextSyntheticAssistantBlockIndex: number;
}

Expand Down Expand Up @@ -173,6 +174,7 @@ interface ClaudeSessionContext {
lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined;
lastAssistantUuid: string | undefined;
lastThreadStartedId: string | undefined;
currentInteractionMode: "plan" | "default";
stopped: boolean;
}

Expand Down Expand Up @@ -785,6 +787,49 @@ function extractExitPlanModePlan(value: unknown): string | undefined {
: undefined;
}

function looksLikePlan(text: string): boolean {
const trimmed = text.trim();

if (trimmed.length < 200) {
return false;
}

const headingMatch = /^#{1,4} \S/m.test(trimmed);
const orderedListMatch = /^\s*\d+\.\s+\S/m.test(trimmed);
const bulletedListMatch = /^\s*[-*]\s+\S/m.test(trimmed);

if (!headingMatch && !orderedListMatch && !bulletedListMatch) {
return false;
}

const firstLineChars = trimmed.substring(0, 120).replace(/^#+\s*/, "");
const refusalStems = [
/^i can't/i,
/^i cannot/i,
/^i'm not able/i,
/^i am unable/i,
/^i don't have enough/i,
/^could you clarify/i,
/^can you clarify/i,
/^before i /i,
/^to help you, i need/i,
];

if (refusalStems.some((stem) => stem.test(firstLineChars))) {
return false;
}

const orderedListCount = (trimmed.match(/^\s*\d+\.\s+\S/gm) || []).length;
const bulletedListCount = (trimmed.match(/^\s*[-*]\s+\S/gm) || []).length;
const totalListItems = orderedListCount + bulletedListCount;

const planHeadingMatch = /^#{1,4} [^\n]*(?:plan|approach|steps?|implementation|proposal)/i.test(
trimmed,
);

Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
return totalListItems >= 3 || planHeadingMatch;
}

function exitPlanCaptureKey(input: {
readonly toolUseId?: string | undefined;
readonly planMarkdown: string;
Expand Down Expand Up @@ -1359,7 +1404,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
input: {
readonly planMarkdown: string;
readonly toolUseId?: string | undefined;
readonly rawSource: "claude.sdk.message" | "claude.sdk.permission";
readonly rawSource:
| "claude.sdk.message"
| "claude.sdk.permission"
| "claude.sdk.text-fallback";
readonly rawMethod: string;
readonly rawPayload: unknown;
},
Expand Down Expand Up @@ -1543,6 +1591,35 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
});
}

if (
turnState.interactionMode === "plan" &&
turnState.capturedProposedPlanKeys.size === 0 &&
context.pendingUserInputs.size === 0 &&
status === "completed" &&
result?.subtype === "success"
) {
let lastNonEmptyBlock: AssistantTextBlockState | undefined;
for (let i = turnState.assistantTextBlockOrder.length - 1; i >= 0; i--) {
const block = turnState.assistantTextBlockOrder[i];
if (!block) {
continue;
}
if (block.fallbackText.trim().length > 0) {
lastNonEmptyBlock = block;
break;
}
}

if (lastNonEmptyBlock && looksLikePlan(lastNonEmptyBlock.fallbackText)) {
yield* emitProposedPlanCompleted(context, {
planMarkdown: lastNonEmptyBlock.fallbackText,
rawSource: "claude.sdk.text-fallback",
rawMethod: "claude/text-fallback",
rawPayload: result,
});
}
}

const stamp = yield* makeEventStamp();
yield* offerRuntimeEvent({
type: "turn.completed",
Expand Down Expand Up @@ -1956,6 +2033,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
assistantTextBlocks: new Map(),
assistantTextBlockOrder: [],
capturedProposedPlanKeys: new Set(),
interactionMode: "default",
nextSyntheticAssistantBlockIndex: -1,
};
context.session = {
Expand Down Expand Up @@ -2973,6 +3051,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
lastKnownTokenUsage: undefined,
lastAssistantUuid: resumeState?.resumeSessionAt,
lastThreadStartedId: undefined,
currentInteractionMode: permissionMode === "plan" ? "plan" : "default",
stopped: false,
};
yield* Ref.set(contextRef, context);
Expand Down Expand Up @@ -3085,21 +3164,25 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
try: () => context.query.setPermissionMode("plan"),
catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause),
});
context.currentInteractionMode = "plan";
} else if (input.interactionMode === "default") {
yield* Effect.tryPromise({
try: () => context.query.setPermissionMode(context.basePermissionMode ?? "default"),
catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause),
});
context.currentInteractionMode = "default";
}

const turnId = TurnId.make(yield* Random.nextUUIDv4);
const resolvedInteractionMode = context.currentInteractionMode;
const turnState: ClaudeTurnState = {
turnId,
startedAt: yield* nowIso,
items: [],
assistantTextBlocks: new Map(),
assistantTextBlockOrder: [],
capturedProposedPlanKeys: new Set(),
interactionMode: resolvedInteractionMode,
nextSyntheticAssistantBlockIndex: -1,
};

Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/providerRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const RuntimeEventRawSource = Schema.Union([
Schema.Literal("codex.eventmsg"),
Schema.Literal("claude.sdk.message"),
Schema.Literal("claude.sdk.permission"),
Schema.Literal("claude.sdk.text-fallback"),
Schema.Literal("codex.sdk.thread-event"),
Schema.Literal("opencode.sdk.event"),
Schema.Literal("acp.jsonrpc"),
Expand Down
Loading