Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
88 changes: 83 additions & 5 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3945,8 +3945,8 @@ describe("ClaudeAdapterLive", () => {
attachments: [],
});

// No explicit model/effort on the launch input: the task inherits the
// session's selection.
// No explicit model/effort on the launch input: the model starts from the
// session's selection, and effort stays unknown.
harness.query.emit({
type: "system",
subtype: "task_started",
Expand Down Expand Up @@ -3984,13 +3984,13 @@ describe("ClaudeAdapterLive", () => {
assert.equal(started?.type, "task.started");
if (started?.type === "task.started") {
assert.equal(started.payload.model, SYNTHETIC_CLAUDE_CAPABLE_MODEL);
assert.equal(started.payload.effort, "max");
assert.equal(started.payload.effort, undefined);
}
const progress = taskEvents[1];
assert.equal(progress?.type, "task.progress");
if (progress?.type === "task.progress") {
assert.equal(progress.payload.model, SYNTHETIC_SUBAGENT_MODEL);
assert.equal(progress.payload.effort, "max");
assert.equal(progress.payload.effort, undefined);
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Expand Down Expand Up @@ -4063,7 +4063,7 @@ describe("ClaudeAdapterLive", () => {
assert.equal(started?.type, "task.started");
if (started?.type === "task.started") {
assert.equal(started.payload.model, SYNTHETIC_SUBAGENT_MODEL);
assert.equal(started.payload.effort, "max");
assert.equal(started.payload.effort, undefined);
}
const progress = taskEvents[1];
assert.equal(progress?.type, "task.progress");
Expand All @@ -4076,6 +4076,84 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("a subagent resumed by SendMessage keeps its model and effort", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

const startedFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.type === "task.started"),
Stream.take(2),
Stream.runCollect,
Effect.forkChild,
);

const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
modelSelection: createModelSelection(
ProviderInstanceId.make("claudeAgent"),
SYNTHETIC_CLAUDE_CAPABLE_MODEL,
[{ id: "effort", value: "max" }],
),
runtimeMode: "full-access",
});
yield* adapter.sendTurn({
threadId: session.threadId,
input: "spawn an agent",
attachments: [],
});

const toolUse = (index: number, id: string, name: string, input: object) =>
harness.query.emit({
type: "stream_event",
session_id: "sdk-session",
uuid: `stream-${id}`,
parent_tool_use_id: null,
event: {
type: "content_block_start",
index,
content_block: { type: "tool_use", id, name, input },
},
} as unknown as SDKMessage);
const taskStarted = (toolUseId: string) =>
harness.query.emit({
type: "system",
subtype: "task_started",
task_id: "task-resumed",
description: "Agent R",
task_type: "local_agent",
tool_use_id: toolUseId,
uuid: `task-resumed-${toolUseId}`,
session_id: "sdk-session",
} as unknown as SDKMessage);

toolUse(0, "toolu_agent_r", "Agent", { description: "Agent R", effort: "high" });
taskStarted("toolu_agent_r");
harness.query.emit({
type: "assistant",
parent_tool_use_id: "toolu_agent_r",
message: { model: SYNTHETIC_SUBAGENT_MODEL, content: [] },
uuid: "resumed-snapshot-uuid",
session_id: "sdk-session",
} as unknown as SDKMessage);
// Reviving a finished agent re-registers the same task under the
// SendMessage call, whose input names neither model nor effort.
toolUse(1, "toolu_send_r", "SendMessage", { to: "task-resumed", message: "continue" });
taskStarted("toolu_send_r");

const resumed = Array.from(yield* Fiber.join(startedFiber))[1];
assert.equal(resumed?.type, "task.started");
if (resumed?.type === "task.started") {
assert.equal(resumed.payload.model, SYNTHETIC_SUBAGENT_MODEL);
assert.equal(resumed.payload.effort, "high");
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("closes the session when the Claude stream aborts after a turn starts", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
33 changes: 13 additions & 20 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,9 +417,6 @@ interface ClaudeSessionContext {
readonly startedAt: string;
readonly basePermissionMode: PermissionMode | undefined;
currentApiModelId: string | undefined;
/** Effective effort for the session's turns; subagents without an explicit
* effort override inherit this. */
currentEffort: string | undefined;
resumeSessionId: string | undefined;
readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
Expand Down Expand Up @@ -3720,28 +3717,33 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
) {
context.turnState.hasSubagents = true;
}
// Model/effort: the Agent tool's input carries explicit overrides;
// absent ones inherit the session's selection (SDK behavior).
// Subagent assistant snapshots refine model with the authoritative API
// id: one that already arrived is buffered and outranks the seed here,
// later ones refine the record in place. AgentInput.effort may be a
// named level or an integer.
// Model/effort: the Agent tool's input carries explicit overrides.
// A resumed agent re-registers under the same task_id, launched by a
// SendMessage call that carries neither, so it keeps what is already
// known. A fresh agent's model falls back to the session's, and
// subagent assistant snapshots refine it with the authoritative API id:
// one that already arrived is buffered and outranks the seed here,
// later ones refine the record in place. Effort has no such source (an
// agent definition may set its own), so it is only reported when
// explicit. AgentInput.effort may be a named level or an integer.
const launchInput = launchingTool?.input;
const toolUseId = message.tool_use_id;
const bufferedModel = toolUseId ? context.pendingTaskModels.get(toolUseId) : undefined;
if (toolUseId) {
context.pendingTaskModels.delete(toolUseId);
}
const known = context.taskAgents.get(message.task_id);
const model =
bufferedModel ??
trimmedString(launchInput?.model) ??
known?.model ??
trimmedString(context.session.model ?? undefined);
const rawLaunchEffort = launchInput?.effort;
const effort =
trimmedString(rawLaunchEffort) ??
(typeof rawLaunchEffort === "number" && Number.isFinite(rawLaunchEffort)
? String(rawLaunchEffort)
: context.currentEffort);
: known?.effort);
// Remember the agent identity so every later task.* payload for this
// taskId is self-describing (identity must survive activity retention).
context.taskAgents.set(message.task_id, {
Expand All @@ -3752,7 +3754,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
taskType: message.task_type,
workflowName: message.workflow_name,
skipTranscript: message.skip_transcript === true,
runHandles: context.taskAgents.get(message.task_id)?.runHandles,
runHandles: known?.runHandles,
owningAgentId,
model,
effort,
Expand Down Expand Up @@ -5036,7 +5038,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
startedAt,
basePermissionMode: permissionMode,
currentApiModelId: apiModelId,
currentEffort: effectiveEffort ?? undefined,
resumeSessionId: sessionId,
pendingApprovals,
pendingUserInputs,
Expand Down Expand Up @@ -5172,14 +5173,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...context.session,
model: modelSelection.model,
};
const turnEffort = resolveClaudeCatalogEffort(
modelCatalog,
modelSelection.model,
getModelSelectionStringOptionValue(modelSelection, "effort"),
);
context.currentEffort =
getEffectiveClaudeAgentEffort(modelCatalog, turnEffort ?? null, modelSelection.model) ??
undefined;
}

// Apply interaction mode by switching the SDK's permission mode.
Expand Down
Loading