Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
1 change: 0 additions & 1 deletion apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ const clientSettings: ClientSettings = {
confirmThreadDelete: false,
confirmWorktreeRemoval: true,
confirmThreadUnpin: false,
continueThreadsAfterServerUpdate: true,
contextWindowMeterEnabled: false,
composerCollapseOnBlur: false,
composerCollapseOnScroll: true,
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/connection/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ type ConnectionLayerSource =
| typeof mobileBackgroundActivityObserverLayer
| typeof mobileBackgroundActivityReporterLayer;

const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
const providedClientConnectionLayer = Layer.merge(
Connection.layerWithOptions({ usageLimitSources: true }),
snapshotLoaderLayer,
).pipe(
Layer.provideMerge(
Layer.mergeAll(
runtimeContextLayer,
Expand Down
13 changes: 11 additions & 2 deletions apps/mobile/src/features/review/shikiReviewHighlighter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,17 @@ describe("highlightSourceFile", () => {
.join(""),
).toBe(source);
expect(highlighted.flat().some((token) => token.color !== null)).toBe(true);
const snippet = await highlighter.highlightCodeSnippet({
code: source,
language: "ts",
theme: "dark",
});
expect(
await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }),
).toEqual(highlighted);
snippet
.flat()
.map((token) => token.content)
.join(""),
).toBe(source);
expect(snippet.flat().some((token) => token.color !== null)).toBe(true);
});
});
32 changes: 32 additions & 0 deletions apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose.
import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk";
import { vi } from "vite-plus/test";
import { ClaudeSettings } from "@t3tools/contracts";
import * as NodeFSP from "node:fs/promises";
import * as NodeServices from "@effect/platform-node/NodeServices";
Expand All @@ -14,6 +16,8 @@ import {
probeClaudeCapabilities,
} from "./ClaudeProvider.ts";

vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });

const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);

it("isolates Claude capability probes without dropping workspace setting sources", () => {
Expand Down Expand Up @@ -181,3 +185,31 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
}).pipe(Effect.scoped),
);
});

it.live("preserves initialized capabilities when optional usage times out", () =>
Effect.gen(function* () {
let abortSignal: AbortSignal | undefined;
const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
abortSignal = options?.abortController?.signal;
return {
initializationResult: async () => ({
account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },
commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],
}),
usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => new Promise(() => {}),
} as ReturnType<typeof ClaudeSdk.query>;
});
yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
const capabilities = yield* probeClaudeCapabilities(
decodeClaudeSettings({ binaryPath: "claude" }),
);
assert.equal(capabilities?.email, "dev@example.com");
assert.equal(capabilities?.subscriptionType, "pro");
assert.equal(capabilities?.tokenSource, "oauth");
assert.deepEqual(capabilities?.slashCommands, [
{ name: "review", description: "Review changes", input: { hint: "[path]" } },
]);
assert.equal(capabilities?.usage, undefined);
assert.equal(abortSignal?.aborted, true);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
63 changes: 33 additions & 30 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,44 +356,47 @@ const probeClaudeCapabilities = (
}),
});
const init = await q.initializationResult();
// Usage is a second control round trip on the same process; a failure
// there must not cost the slash commands and account we already have.
const usage = await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET().then(
(response) => ({
rate_limits_available: response.rate_limits_available,
rate_limits: response.rate_limits,
}),
() => undefined,
);
const account = init.account as
| {
readonly email?: string;
readonly subscriptionType?: string;
readonly tokenSource?: string;
readonly apiProvider?: string;
}
| undefined;
return {
email: account?.email,
subscriptionType: account?.subscriptionType,
tokenSource: account?.tokenSource,
apiProvider: account?.apiProvider,
slashCommands: parseClaudeInitializationCommands(init.commands),
...(usage ? { usage } : {}),
} satisfies ClaudeCapabilitiesProbe;
return { q, init };
});
}).pipe(
Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS),
Effect.flatMap(({ q, init }) =>
Effect.gen(function* () {
// Usage has its own deadline so a slow optional request cannot discard initialization.
const usageResult = yield* Effect.tryPromise(() =>
q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result);
const usage = Result.isSuccess(usageResult)
? {
rate_limits_available: usageResult.success.rate_limits_available,
rate_limits: usageResult.success.rate_limits,
}
: undefined;
const account = init.account as
| {
readonly email?: string;
readonly subscriptionType?: string;
readonly tokenSource?: string;
readonly apiProvider?: string;
}
| undefined;
return {
email: account?.email,
subscriptionType: account?.subscriptionType,
tokenSource: account?.tokenSource,
apiProvider: account?.apiProvider,
slashCommands: parseClaudeInitializationCommands(init.commands),
...(usage ? { usage } : {}),
} satisfies ClaudeCapabilitiesProbe;
}),
),
Effect.ensuring(
Effect.sync(() => {
if (!abort.signal.aborted) abort.abort();
}),
),
Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),
Effect.result,
Effect.map((result) => {
if (Result.isFailure(result)) return undefined;
return Option.isSome(result.success) ? result.success.value : undefined;
}),
Effect.map((result) => (Result.isSuccess(result) ? result.success : undefined)),
);
};

Expand Down
122 changes: 122 additions & 0 deletions apps/server/src/provider/Layers/ProviderService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,128 @@ function makeProviderServiceLayer(
};
}

for (const [enabled, completed] of [
[false, false],
[true, false],
[true, true],
] as const) {
it.effect(
`persists shutdown recovery before stopping providers when enabled=${enabled}, completed=${completed}`,
() =>
Effect.gen(function* () {
const codex = makeFakeCodexAdapter();
const persistence = yield* Layer.build(
ProviderSessionDirectoryLive.pipe(
Layer.provide(
ProviderSessionRuntime.layer.pipe(Layer.provide(SqlitePersistenceMemory)),
),
),
);
const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe(
Effect.provide(persistence),
);
const threadId = asThreadId("shutdown-recovery");
const turnId = asTurnId("shutdown-recovery-turn");
const scope = yield* Scope.make();
const services = yield* Layer.build(
makeProviderServiceLive({
shutdownInterruptGracePeriod: "0 millis",
shutdownGracePeriod: "50 millis",
}).pipe(
Layer.provide(
Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory),
),
Layer.provide(
Layer.succeed(
ProviderAdapterRegistry.ProviderAdapterRegistry,
makeStaticInstanceRegistry([[codexInstanceId, codex.adapter]]),
),
),
Layer.provide(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: enabled })),
Layer.provide(serverConfigTestLayer),
Layer.provide(AnalyticsService.layerTest),
Layer.provide(
Layer.succeed(
ProviderEventLoggers.ProviderEventLoggers,
ProviderEventLoggers.NoOpProviderEventLoggers,
),
),
),
).pipe(Scope.provide(scope));
const provider = yield* ProviderService.ProviderService.pipe(Effect.provide(services));
const session = yield* provider.startSession(threadId, {
provider: CODEX_DRIVER,
providerInstanceId: codexInstanceId,
threadId,
runtimeMode: "full-access",
});
codex.listSessions.mockReturnValue(
Effect.succeed([
{
...session,
status: completed ? "ready" : "running",
activeTurnId: completed ? undefined : turnId,
},
]),
);
const pending = yield* directory.getBinding(threadId);
assert(Option.isSome(pending));
yield* directory.upsert({
...pending.value,
runtimePayload: { activeTurnId: null, continueAfterServerUpdate: turnId },
});
const accepted = yield* provider.sendTurn({ threadId, continuation: true });
const admitted = yield* directory.getBinding(threadId);
assert(Option.isSome(admitted));
assert.propertyVal(admitted.value.runtimePayload, "activeTurnId", accepted.turnId);
assert.propertyVal(admitted.value.runtimePayload, "continueAfterServerUpdate", null);
if (completed) {
// Updates can mark an already-admitted turn immediately before it finishes.
yield* directory.upsert({
...admitted.value,
runtimePayload: {
continueAfterServerUpdate: accepted.turnId,
continueAfterServerUpdatePrepared: null,
},
});
}
const markers: unknown[] = [];
codex.stopAll.mockImplementation(() =>
Effect.gen(function* () {
const binding = yield* directory.getBinding(threadId);
assert(Option.isSome(binding));
markers.push(binding.value.runtimePayload);
}).pipe(Effect.orDie),
);
yield* Scope.close(scope, Exit.void);
const binding = yield* directory.getBinding(threadId);
assert(Option.isSome(binding));
assert.equal(codex.stopAll.mock.calls.length, 1);
assert.deepStrictEqual(binding.value.resumeCursor, session.resumeCursor);
assert.equal(binding.value.status, "stopped");
assert.propertyVal(markers[0], "activeTurnId", completed ? null : turnId);
if (!completed) {
// Graceful stopAll still marks working sessions with a resume cursor,
// even when the restart opt-in is off. Crash/machine-restart recovery
// is what the setting gates.
assert.propertyVal(markers[0], "continueAfterServerUpdate", turnId);
assert.propertyVal(binding.value.runtimePayload, "continueAfterServerUpdate", turnId);
} else {
assert.propertyVal(
binding.value.runtimePayload,
"continueAfterServerUpdate",
accepted.turnId,
);
assert.propertyVal(
binding.value.runtimePayload,
"continueAfterServerUpdatePrepared",
null,
);
}
}).pipe(Effect.provide(NodeServices.layer)),
);
}

it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>
Effect.gen(function* () {
const codex = makeFakeCodexAdapter();
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/provider/Layers/ProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ function toRuntimePayloadFromSession(
extra?: {
readonly modelSelection?: unknown;
readonly interactionMode?: unknown;
readonly continueAfterServerUpdate?: TurnId;
readonly lastRuntimeEvent?: string;
readonly lastRuntimeEventAt?: string;
},
Expand All @@ -260,6 +261,9 @@ function toRuntimePayloadFromSession(
model: session.model ?? null,
activeTurnId: session.activeTurnId ?? null,
lastError: session.lastError ?? null,
...(extra?.continueAfterServerUpdate !== undefined
? { continueAfterServerUpdate: extra.continueAfterServerUpdate }
: {}),
...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}),
...(extra?.interactionMode !== undefined ? { interactionMode: extra.interactionMode } : {}),
...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}),
Expand Down Expand Up @@ -914,6 +918,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
extra?: {
readonly modelSelection?: unknown;
readonly interactionMode?: unknown;
readonly continueAfterServerUpdate?: TurnId;
readonly lastRuntimeEvent?: string;
readonly lastRuntimeEventAt?: string;
},
Expand Down Expand Up @@ -1563,6 +1568,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
? { interactionMode: input.interactionMode }
: {}),
activeTurnId: turn.turnId,
// Admission and marker consumption must survive the same restart.
continueAfterServerUpdate: null,
continueAfterServerUpdatePrepared: null,
lastRuntimeEvent: "provider.sendTurn",
lastRuntimeEventAt: turnStartedAt,
},
Expand Down Expand Up @@ -1900,6 +1908,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
status: "stopped",
runtimePayload: {
activeTurnId: null,
continueAfterServerUpdate: null,
continueAfterServerUpdatePrepared: null,
},
});
yield* analytics.record("provider.session.stopped", {
Expand Down Expand Up @@ -2109,6 +2119,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
);

const runStopAll = Effect.fn("runStopAll")(function* () {
const continueAfterRestart = yield* serverSettings.getSettings.pipe(
Effect.map((settings) => settings.continueThreadsAfterServerUpdate),
Effect.orElseSucceed(() => false),
);
const properties = yield* Ref.modify(turnAnalytics, (state) => {
const completed: Array<Readonly<Record<string, unknown>>> = [];
for (const [sessionKey, session] of state.sessions) {
Expand Down Expand Up @@ -2206,6 +2220,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (

yield* Effect.forEach(activeSessions, (session) =>
upsertSessionBinding(session, session.threadId, {
...(continueAfterRestart && session.status === "running" && session.activeTurnId
? { continueAfterServerUpdate: session.activeTurnId }
: {}),
lastRuntimeEvent: "provider.stopAll",
lastRuntimeEventAt,
}),
Expand Down
Loading
Loading