Skip to content
Draft
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
134 changes: 134 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2866,6 +2866,140 @@ describe("ProviderCommandReactor", () => {
});
});

it.each([
{
name: "omitted selection",
instanceId: "claudeAgent",
failed: true,
running: false,
selection: false,
restart: true,
},
{
name: "unchanged selection",
instanceId: "claudeAgent",
failed: true,
running: false,
selection: true,
restart: true,
},
{
name: "named Claude instance",
instanceId: "claude_work",
failed: true,
running: false,
selection: false,
restart: true,
},
{
name: "healthy Claude session",
instanceId: "claudeAgent",
failed: false,
running: false,
selection: false,
restart: false,
},
{
name: "active Claude turn",
instanceId: "claudeAgent",
failed: true,
running: true,
selection: false,
restart: false,
},
{
name: "another provider",
instanceId: "codex",
failed: true,
running: false,
selection: false,
restart: false,
},
])("restarts only failed idle Claude sessions on retry: $name", async (scenario) => {
const instanceId = ProviderInstanceId.make(scenario.instanceId);
const modelSelection = createModelSelection(
instanceId,
scenario.instanceId === "codex" ? "gpt-5-codex" : "claude-fable-5-1",
scenario.instanceId === "codex" ? [] : [{ id: "effort", value: "high" }],
);
const harness = await createHarness({ threadModelSelection: modelSelection });
const threadId = ThreadId.make("thread-1");
const now = "2026-01-01T00:00:00.000Z";
const send = async (id: string, selection?: ModelSelection) => {
const sent = await harness.runEffect(Deferred.make<void>());
const sendTurn = harness.sendTurn.getMockImplementation()!;
harness.sendTurn.mockImplementationOnce((input) =>
sendTurn(input).pipe(Effect.tap(() => Deferred.succeed(sent, undefined))),
);
await harness.runEffect(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make(`cmd-${id}`),
threadId,
message: {
messageId: asMessageId(`message-${id}`),
role: "user",
text: "Continue",
attachments: [],
},
...(selection ? { modelSelection: selection } : {}),
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);
await harness.runEffect(Deferred.await(sent));
await harness.drain();
};
await send("initial", modelSelection);
expect(harness.startSession).toHaveBeenCalledTimes(1);
const original = harness.runtimeSessions[0]!;
const lastError = scenario.failed ? "Claude gave up after repeated API errors." : undefined;
harness.runtimeSessions[0] = {
...original,
status: scenario.running ? "running" : "ready",
...(scenario.running ? { activeTurnId: asTurnId("active-turn") } : {}),
...(lastError ? { lastError } : {}),
};
await harness.runEffect(
harness.engine.dispatch({
type: "thread.session.set",
commandId: CommandId.make("cmd-failed-session"),
threadId,
session: {
threadId,
providerName: original.provider,
providerInstanceId: instanceId,
status: scenario.running ? "running" : scenario.failed ? "error" : "ready",
runtimeMode: "approval-required",
activeTurnId: scenario.running ? asTurnId("active-turn") : null,
lastError: lastError ?? null,
updatedAt: now,
},
createdAt: now,
}),
);
await harness.drain();

await send("retry", scenario.selection ? modelSelection : undefined);

expect(harness.startSession).toHaveBeenCalledTimes(scenario.restart ? 2 : 1);
expect(harness.sendTurn).toHaveBeenCalledTimes(2);
if (scenario.restart) {
expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
threadId,
providerInstanceId: instanceId,
modelSelection,
resumeCursor: original.resumeCursor,
runtimeMode: original.runtimeMode,
cwd: original.cwd,
});
expect(harness.startSession.mock.invocationCallOrder[1]).toBeLessThan(
harness.sendTurn.mock.invocationCallOrder[1]!,
);
}
});

it("restarts claude sessions when claude effort changes", async () => {
const harness = await createHarness({
threadModelSelection: {
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,13 +783,23 @@ const make = Effect.gen(function* () {
preferredProvider === "claudeAgent" &&
requestedModelSelection !== undefined &&
!Equal.equals(previousModelSelection, requestedModelSelection);
// Claude leaves its CLI alive after a failed result, including terminal
// usage-limit errors. Resume the next user turn in a fresh runtime so
// process-local failure state cannot keep rejecting an unchanged retry.
// Keep the conversation cursor and never interrupt an active turn.
const shouldRestartAfterFailure =
preferredProvider === "claudeAgent" &&
activeSession?.status === "ready" &&
activeSession.activeTurnId === undefined &&
activeSession.lastError !== undefined;

if (
!runtimeModeChanged &&
!cwdChanged &&
!instanceChanged &&
!shouldRestartForModelChange &&
!shouldRestartForModelSelectionChange
!shouldRestartForModelSelectionChange &&
!shouldRestartAfterFailure
) {
yield* refreshWorkspaceSnapshot;
return existingSessionThreadId;
Expand All @@ -815,6 +825,7 @@ const make = Effect.gen(function* () {
instanceChanged,
shouldRestartForModelChange,
shouldRestartForModelSelectionChange,
shouldRestartAfterFailure,
hasResumeCursor: resumeCursor !== undefined,
});
const restartedSession = yield* startProviderSession(
Expand Down
63 changes: 63 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3746,6 +3746,69 @@ describe("ClaudeAdapterLive", () => {
return { runtimeEvents, runtimeEventsFiber, drainSdkMessages };
});

it.effect(
"retains the failed result and resume cursor after a zero-API-time limit rejection",
() => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } =
yield* observeUsageLimitEvents(adapter, harness.query);
yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});
yield* adapter.sendTurn({ threadId: THREAD_ID, input: "Continue", attachments: [] });
harness.query.emit({
type: "rate_limit_event",
rate_limit_info: {
status: "rejected",
rateLimitType: "seven_day_overage_included",
resetsAt: 1788825600,
overageStatus: "rejected",
isUsingOverage: false,
},
session_id: "sdk-session-limit",
uuid: "limit-rejected",
} as unknown as SDKMessage);
harness.query.emit({
type: "result",
subtype: "success",
is_error: true,
terminal_reason: "api_error",
api_error_status: 429,
duration_ms: 500,
duration_api_ms: 0,
total_cost_usd: 0,
num_turns: 1,
errors: [],
result: "",
session_id: "sdk-session-limit",
uuid: "result-limit-rejected",
} as unknown as SDKMessage);
yield* drainSdkMessages;
const sessions = yield* adapter.listSessions();
assert.equal(sessions.length, 1);
assert.equal(sessions[0]?.status, "ready");
assert.equal(sessions[0]?.activeTurnId, undefined);
assert.equal(sessions[0]?.lastError, "Claude gave up after repeated API errors.");
assert.equal(
(sessions[0]?.resumeCursor as { resume?: string })?.resume,
"sdk-session-limit",
);
assert.equal(harness.query.closeCalls, 0);
assert.equal(runtimeEvents.filter((event) => event.type === "runtime.warning").length, 1);
const completed = runtimeEvents.find((event) => event.type === "turn.completed");
assert.equal(completed?.type === "turn.completed" && completed.payload.state, "failed");
yield* Fiber.interrupt(runtimeEventsFiber);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
},
);

it.effect("surfaces a rejected Claude usage limit once per turn", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
32 changes: 22 additions & 10 deletions apps/swift-ios/App/NativeFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4110,7 +4110,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,

var changedIDs = Set(mutations.messages.map(\.id))
for activity in mutations.activities {
if activity.tone == "error" {
if Self.activityNoticeText(activity) != nil {
changedIDs.insert("activity-\(activity.id)")
} else if NativeWorkLogAccumulator.accepts(activity) {
changedIDs.insert("work-log-\(activity.turnId ?? "unscoped")")
Expand Down Expand Up @@ -4527,10 +4527,10 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
}
cache.approvals = pendingApprovals(thread, environment: environment)
cache.userInputs = pendingUserInputs(thread, environment: environment)
let errors = thread.activities.compactMap(mapErrorActivity)
let notices = thread.activities.compactMap(mapNoticeActivity)
let sessionIsLive = thread.session?.status == "starting"
|| thread.session?.status == "running"
let activityMessages = (errors + collapsedWorkLogs(
let activityMessages = (notices + collapsedWorkLogs(
thread.activities,
sessionIsLive: sessionIsLive
))
Expand Down Expand Up @@ -4743,7 +4743,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
let workIsLive = thread.session?.status == "starting"
|| thread.session?.status == "running"
|| backgroundLiveness(threadID: thread.id, environmentID: environmentID) == .working
let activities = thread.activities.compactMap(mapErrorActivity)
let activities = thread.activities.compactMap(mapNoticeActivity)
+ collapsedWorkLogs(thread.activities, sessionIsLive: workIsLive)
return (messages + activities).sorted { $0.createdAt < $1.createdAt }
}
Expand Down Expand Up @@ -4838,8 +4838,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
environment: environment,
cache: cache
)
if let error = mapErrorActivity(activity) {
upsertMergedMessage(error, cache: cache)
if let notice = mapNoticeActivity(activity) {
upsertMergedMessage(notice, cache: cache)
}
guard NativeWorkLogAccumulator.accepts(activity),
cache.workLogActivityIDs.insert(activity.id).inserted else {
Expand Down Expand Up @@ -5012,10 +5012,22 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
)
}

private func mapErrorActivity(_ activity: OrchestrationActivity) -> FeatureMessage? {
guard activity.tone == "error" else { return nil }
let detail = activity.payload["detail"]?.stringValue
let text = detail.map { "\(activity.summary)\n\($0)" } ?? activity.summary
static func activityNoticeText(_ activity: OrchestrationActivity) -> String? {
guard activity.tone == "error" || activity.kind == "runtime.warning" else { return nil }
if let message = activity.payload["message"]?.stringValue,
!message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return message
}
if let detail = activity.payload["detail"]?.stringValue,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
detail != activity.summary {
return "\(activity.summary)\n\(detail)"
}
return activity.summary
}

private func mapNoticeActivity(_ activity: OrchestrationActivity) -> FeatureMessage? {
guard let text = Self.activityNoticeText(activity) else { return nil }
return FeatureMessage(
id: "activity-\(activity.id)",
role: .system,
Expand Down
48 changes: 48 additions & 0 deletions apps/swift-ios/Tests/FeatureTests/RuntimeNoticeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation
import Testing
@testable import T3Code

@MainActor
@Suite("Runtime notices")
struct RuntimeNoticeTests {
@Test
func showsTheUsageLimitWarningWithoutAnAssistantMessage() {
let message = "Claude usage limit reached. This turn is paused until the 7-day Fable limit resets in 27h 18m."
let warning = activity(tone: "warning", kind: "runtime.warning", summary: message,
payload: ["message": .string(message), "detail": .object(["status": .string("rejected")])])
#expect(NativeFeatureClient.activityNoticeText(warning) == message)
}

@Test
func showsTheRuntimeErrorMessageInsteadOfItsGenericSummary() {
let error = activity(tone: "error", kind: "runtime.error", summary: "Runtime error",
payload: ["message": .string("Claude gave up after repeated API errors.")])
#expect(NativeFeatureClient.activityNoticeText(error) == "Claude gave up after repeated API errors.")
}

@Test
func preservesLegacyDetailsAndAvoidsDuplicatingTheSummary() {
let legacy = activity(tone: "error", kind: "provider.turn.start.failed", summary: "Turn failed",
payload: ["detail": .string("Provider unavailable")])
#expect(NativeFeatureClient.activityNoticeText(legacy) == "Turn failed\nProvider unavailable")
let repeated = activity(tone: "warning", kind: "runtime.warning", summary: "Limit reached",
payload: ["message": .string(" "), "detail": .string("Limit reached")])
#expect(NativeFeatureClient.activityNoticeText(repeated) == "Limit reached")
}

@Test
func keepsOrdinaryActivitiesOutOfTheTranscriptAndDoesNotDumpStructuredDetails() {
let progress = activity(tone: "info", kind: "tool.updated", summary: "Running",
payload: ["message": .string("Tool progress")])
#expect(NativeFeatureClient.activityNoticeText(progress) == nil)
let diagnostic = activity(tone: "error", kind: "runtime.error", summary: "Runtime error",
payload: ["detail": .object(["internal": .string("diagnostic")])])
#expect(NativeFeatureClient.activityNoticeText(diagnostic) == "Runtime error")
}

private func activity(tone: String, kind: String, summary: String, payload: [String: JSONValue]) -> OrchestrationActivity {
OrchestrationActivity(id: "notice", tone: tone, kind: kind, summary: summary,
payload: .object(payload), turnId: "turn", sequence: 1,
createdAt: "2026-09-06T20:42:22Z")
}
}