Skip to content

Commit d07c035

Browse files
chore(v2): merge the V2 branch head (0481b76) into the main merge
Brings in the V2 bug-hunt fixes merged since this branch was cut (#13541, #13775, #13786, #13793, #13796, #13802). No conflicts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2 parents fe76961 + 0481b76 commit d07c035

19 files changed

Lines changed: 629 additions & 49 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,12 +1049,17 @@ function selectPermissionOptionId(
10491049
return request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined;
10501050
}
10511051

1052+
/**
1053+
* The runtime policy approves one request, so answer with the agent's
1054+
* allow-once option. Its allow-always option can outlive the session (Grok
1055+
* saves it for the whole project); use it only when no allow-once exists.
1056+
*/
10521057
function selectAutoApprovedPermissionOption(
10531058
request: EffectAcpSchema.RequestPermissionRequest,
10541059
): string | undefined {
10551060
return (
1056-
selectPermissionOptionId(request, "acceptForSession") ??
1057-
selectPermissionOptionId(request, "accept")
1061+
selectPermissionOptionId(request, "accept") ??
1062+
selectPermissionOptionId(request, "acceptForSession")
10581063
);
10591064
}
10601065

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,114 @@ describe("ClaudeAdapterV2 session permissions", () => {
920920
});
921921
});
922922

923+
describe("ClaudeAdapterV2 Auto-accept edits", () => {
924+
it.effect("asks before a command instead of allowing it", () =>
925+
Effect.scoped(
926+
Effect.gen(function* () {
927+
const fileSystem = yield* FileSystem.FileSystem;
928+
const idAllocator = yield* IdAllocatorV2;
929+
const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
930+
prefix: "t3-claude-accept-edits-",
931+
});
932+
let openedOptions: ClaudeAgentSdkQueryOptions | undefined;
933+
const adapter = makeClaudeAdapterV2({
934+
instanceId: CLAUDE_DEFAULT_INSTANCE_ID,
935+
settings: DEFAULT_CLAUDE_SETTINGS,
936+
environment: {},
937+
attachmentsDir,
938+
fileSystem,
939+
path: yield* Path.Path,
940+
idAllocator,
941+
queryRunner: {
942+
allocateSessionId: Effect.succeed("native-thread-claude-accept-edits"),
943+
open: (input) =>
944+
Effect.sync(() => {
945+
openedOptions = input.options;
946+
return {
947+
messages: Stream.never,
948+
offer: () => Effect.void,
949+
setModel: () => Effect.void,
950+
interrupt: Effect.void,
951+
close: Effect.void,
952+
};
953+
}),
954+
forkSession: () => Effect.die("unused"),
955+
subagentLaunchToolUseId: () => Effect.succeed(null),
956+
assertComplete: Effect.void,
957+
},
958+
});
959+
const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
960+
runtimeMode: "auto-accept-edits",
961+
interactionMode: "default",
962+
cwd: "/workspace",
963+
});
964+
const threadId = ThreadId.make("thread-claude-accept-edits");
965+
const runtime = yield* adapter.openSession({
966+
threadId,
967+
providerSessionId: ProviderSessionId.make("provider-session-claude-accept-edits"),
968+
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
969+
runtimePolicy,
970+
});
971+
const providerThread = yield* runtime.ensureThread({
972+
threadId,
973+
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
974+
runtimePolicy,
975+
});
976+
const now = yield* DateTime.now;
977+
yield* runtime.startTurn(
978+
makeClaudeTestTurnInput({
979+
threadId,
980+
providerThread,
981+
now,
982+
attemptId: RunAttemptId.make("attempt-claude-accept-edits"),
983+
text: "Run node.",
984+
attachments: [],
985+
runtimePolicy,
986+
}),
987+
);
988+
assert.equal(openedOptions?.permissionMode, "acceptEdits");
989+
const canUseTool = openedOptions?.canUseTool;
990+
assert.isFunction(canUseTool);
991+
992+
const requestEvent = yield* runtime.events.pipe(
993+
Stream.filter((event) => event.type === "runtime_request.updated"),
994+
Stream.runHead,
995+
Effect.forkScoped,
996+
);
997+
const command = { command: "node -e 'console.log(42)'" };
998+
const decision = yield* Effect.promise(() =>
999+
canUseTool!("Bash", command, {
1000+
signal: new AbortController().signal,
1001+
toolUseID: "tool-bash-accept-edits",
1002+
requestId: "request-bash-accept-edits",
1003+
}),
1004+
).pipe(Effect.forkScoped);
1005+
// Without a callback that asks, the command is allowed before any
1006+
// request is raised.
1007+
const first = yield* Effect.raceFirst(
1008+
Fiber.join(requestEvent).pipe(
1009+
Effect.map((event) => ({ type: "request", event }) as const),
1010+
),
1011+
Fiber.join(decision).pipe(
1012+
Effect.map((result) => ({ type: "decision", result }) as const),
1013+
),
1014+
);
1015+
assert.equal(first.type, "request", "the command ran without asking");
1016+
if (first.type !== "request") return;
1017+
const event = first.event;
1018+
if (Option.isNone(event) || event.value.type !== "runtime_request.updated") return;
1019+
assert.equal(event.value.runtimeRequest.kind, "command");
1020+
1021+
yield* runtime.respondToRuntimeRequest({
1022+
requestId: event.value.runtimeRequest.id,
1023+
decision: "accept",
1024+
});
1025+
assert.equal((yield* Fiber.join(decision))?.behavior, "allow");
1026+
}).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))),
1027+
),
1028+
);
1029+
});
1030+
9231031
describe("ClaudeAdapterV2 approval cancellation", () => {
9241032
it.effect("observes an approval signal that was already aborted", () =>
9251033
Effect.gen(function* () {

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1477,9 +1477,12 @@ export function claudeRuntimeQueryPolicyForRuntimePolicy(
14771477
readOnlyTools !== undefined && readOnlyPolicyAllowsGlobalReads(runtimePolicy)
14781478
? readOnlyTools
14791479
: undefined;
1480+
// acceptEdits approves edits before the callback runs; everything else it
1481+
// leaves to the callback, which must ask rather than allow.
14801482
const installPermissionCallback =
14811483
runtimePolicy.approvalPolicy === undefined
1482-
? runtimePolicy.runtimeMode === "approval-required"
1484+
? runtimePolicy.runtimeMode === "approval-required" ||
1485+
runtimePolicy.runtimeMode === "auto-accept-edits"
14831486
: runtimePolicy.approvalPolicy !== "never";
14841487

14851488
if (permissionMode === "plan") {

‎apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4292,10 +4292,11 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
42924292
payload.item.text.length > 0
42934293
? payload.item.text
42944294
: (deltas.get(payload.item.id) ?? "");
4295+
// A finished proposal stays active until Implement consumes it.
42954296
const artifacts = yield* buildProposedPlanArtifacts({
42964297
context,
42974298
nativeItemId: payload.item.id,
4298-
status: "completed",
4299+
status: "active",
42994300
markdown,
43004301
completed: true,
43014302
});

‎apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.
2424
import {
2525
applyGrokAcpModelSelection,
2626
currentGrokModelIdFromSessionSetup,
27+
grokApprovalOptions,
2728
makeGrokAcpRuntime,
2829
resolveGrokAcpBaseModelId,
2930
} from "../../provider/acp/GrokAcpSupport.ts";
@@ -280,6 +281,7 @@ export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdap
280281
// what its classifier blocked, so every prompt it sends goes to the user.
281282
permissionDisposition: (policy, request) =>
282283
grokLaunchRuntimeMode(policy) === "auto" ? "ask" : acpPermissionDisposition(policy, request),
284+
approvalOptions: grokApprovalOptions,
283285
promptFailure: (cause) =>
284286
makeProviderFailure({
285287
cause,

‎apps/server/src/orchestration-v2/CommandPolicy.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,7 @@ layer("CommandPolicyV2", (it) => {
473473
capabilities: CodexProviderCapabilitiesV2,
474474
sameProvider: true,
475475
hasStrongNativeSource: true,
476+
sourceRunStatus: "completed",
476477
fromSpecificTurn: true,
477478
});
478479

@@ -491,6 +492,7 @@ layer("CommandPolicyV2", (it) => {
491492
capabilities: CursorProviderCapabilitiesV2,
492493
sameProvider: true,
493494
hasStrongNativeSource: true,
495+
sourceRunStatus: "completed",
494496
fromSpecificTurn: true,
495497
});
496498

@@ -509,6 +511,7 @@ layer("CommandPolicyV2", (it) => {
509511
capabilities: GrokProviderCapabilitiesV2,
510512
sameProvider: true,
511513
hasStrongNativeSource: true,
514+
sourceRunStatus: "completed",
512515
fromSpecificTurn: true,
513516
});
514517

@@ -538,6 +541,7 @@ layer("CommandPolicyV2", (it) => {
538541
})),
539542
sameProvider: true,
540543
hasStrongNativeSource: true,
544+
sourceRunStatus: "completed",
541545
fromSpecificTurn: true,
542546
})
543547
.pipe(Effect.flip);

‎apps/server/src/orchestration-v2/CommandPolicy.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ModelSelection,
44
type OrchestrationV2Command,
55
OrchestrationV2ProviderCapabilities,
6+
type OrchestrationV2Run,
67
OrchestrationV2ThreadProjection,
78
ProviderInstanceId,
89
ProviderTurnId,
@@ -202,6 +203,7 @@ export interface CommandPolicyV2Shape {
202203
input: CapabilityCheckInput & {
203204
readonly sameProvider: boolean;
204205
readonly hasStrongNativeSource: boolean;
206+
readonly sourceRunStatus: OrchestrationV2Run["status"];
205207
readonly fromSpecificTurn: boolean;
206208
},
207209
) => Effect.Effect<ForkExecutionPolicyV2, CommandPolicyV2Error>;
@@ -361,7 +363,10 @@ const ensureContextHandoff: CommandPolicyV2Shape["ensureContextHandoff"] = (inpu
361363
};
362364

363365
const decideForkExecution: CommandPolicyV2Shape["decideForkExecution"] = (input) => {
366+
// Unsuccessful runs may have no native turn or assistant cursor. Forking
367+
// those at native head can include later turns, so use the bounded transcript.
364368
const canForkNatively =
369+
(input.sourceRunStatus === "completed" || input.sourceRunStatus === "waiting") &&
365370
input.sameProvider &&
366371
input.hasStrongNativeSource &&
367372
input.capabilities.threads.canForkThread &&

‎apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
MessageId,
55
EventId,
66
NodeId,
7+
PlanId,
78
ProjectId,
89
ProviderDriverKind,
910
ProviderInstanceId,
@@ -274,3 +275,57 @@ it.effect(
274275
assert.isNotNull((yield* projections.getThread(threadId)).deletedAt);
275276
}).pipe(Effect.provide(testLayer)),
276277
);
278+
279+
it.effect("implements a proposed plan that the command projection leaves out", () =>
280+
Effect.gen(function* () {
281+
const orchestrator = yield* OrchestratorV2;
282+
const projections = yield* ProjectionStoreV2;
283+
const threadId = ThreadId.make("thread:implement-plan");
284+
const planId = PlanId.make("plan:implement-plan");
285+
const now = yield* DateTime.now;
286+
yield* orchestrator.dispatch({
287+
type: "thread.create",
288+
commandId: CommandId.make("create-implement-plan"),
289+
threadId,
290+
projectId: ProjectId.make("project:implement-plan"),
291+
title: "Plan",
292+
modelSelection,
293+
runtimeMode: "full-access",
294+
interactionMode: "plan",
295+
branch: null,
296+
worktreePath: null,
297+
createdBy: "user",
298+
creationSource: "web",
299+
});
300+
yield* projections.apply({
301+
id: EventId.make("plan:implement-plan"),
302+
type: "plan.updated",
303+
threadId,
304+
occurredAt: now,
305+
payload: {
306+
id: planId,
307+
threadId,
308+
runId: null,
309+
nodeId: NodeId.make("node:implement-plan"),
310+
kind: "proposed_plan",
311+
status: "active",
312+
markdown: "# Plan\n\n1. Do the thing.",
313+
},
314+
});
315+
316+
yield* orchestrator.dispatch({
317+
type: "message.dispatch",
318+
commandId: CommandId.make("implement-plan"),
319+
threadId,
320+
messageId: MessageId.make("implement-plan-input"),
321+
text: "Implement the plan.",
322+
attachments: [],
323+
sourcePlanRef: { threadId, planId },
324+
dispatchMode: { type: "defer_start" },
325+
createdBy: "user",
326+
creationSource: "web",
327+
});
328+
329+
assert.equal((yield* projections.getPlan(threadId, planId))?.status, "completed");
330+
}).pipe(Effect.provide(testLayer)),
331+
);

‎apps/server/src/orchestration-v2/Orchestrator.ts‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,11 @@ import {
9595
delegatedTaskProgress,
9696
subagentThreadTitle,
9797
} from "./SubagentProjection.ts";
98-
import { ThreadForkServiceV2 } from "./ThreadForkService.ts";
98+
import {
99+
forkableSourceRunStatusError,
100+
isForkableSourceRunStatus,
101+
ThreadForkServiceV2,
102+
} from "./ThreadForkService.ts";
99103
import { planThreadDeletion } from "./ThreadDeletion.ts";
100104

101105
export class OrchestratorDispatchError extends Schema.TaggedError<OrchestratorDispatchError>()(
@@ -3121,11 +3125,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
31213125
cause: `No stable source run was found for fork source ${command.sourcePoint.type}.`,
31223126
});
31233127
}
3124-
if (sourceRun.status !== "completed") {
3128+
if (!isForkableSourceRunStatus(sourceRun.status)) {
31253129
return yield* new OrchestratorDispatchError({
31263130
commandId: command.commandId,
31273131
commandType: command.type,
3128-
cause: `Fork source run ${sourceRun.id} is ${sourceRun.status}; only completed runs are supported.`,
3132+
cause: forkableSourceRunStatusError(sourceRun),
31293133
});
31303134
}
31313135
const sourceProviderThread = providerThreadForRun(sourceProjection, sourceRun);
@@ -4280,12 +4284,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
42804284
command.sourcePlanRef === undefined
42814285
? null
42824286
: yield* getProjectionWithPendingEvents(command.sourcePlanRef.threadId, events);
4283-
const sourcePlan =
4287+
// Command projections leave plans out, so read the source plan directly.
4288+
const sourcePlanArtifact =
42844289
command.sourcePlanRef === undefined
4285-
? null
4286-
: (sourcePlanProjection?.plans.find(
4287-
(plan) => plan.id === command.sourcePlanRef?.planId && plan.kind === "proposed_plan",
4288-
) ?? null);
4290+
? undefined
4291+
: yield* projectionStore
4292+
.getPlan(command.sourcePlanRef.threadId, command.sourcePlanRef.planId)
4293+
.pipe(mapDispatchError(command));
4294+
const sourcePlan = sourcePlanArtifact?.kind === "proposed_plan" ? sourcePlanArtifact : null;
42894295
if (command.sourcePlanRef !== undefined && sourcePlan === null) {
42904296
return yield* new OrchestratorDispatchError({
42914297
commandId: command.commandId,
@@ -5145,7 +5151,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
51455151
),
51465152
);
51475153
const forkExecution =
5148-
pendingForkTransfer === undefined
5154+
pendingForkTransfer === undefined || sourceRun === null
51495155
? null
51505156
: yield* enforceCommandPolicy(command)(
51515157
commandPolicy.decideForkExecution({
@@ -5156,6 +5162,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
51565162
sameProvider:
51575163
pendingForkTransfer.sourceProviderInstanceId === modelSelection.instanceId,
51585164
hasStrongNativeSource: sourceProviderThread?.nativeThreadRef?.strength === "strong",
5165+
sourceRunStatus: sourceRun.status,
51595166
fromSpecificTurn: sourceRun !== null,
51605167
}),
51615168
);

0 commit comments

Comments
 (0)