Skip to content

Commit def0715

Browse files
committed
Surface Claude model metadata in provider and chat UI
- Resolve live Claude model capabilities so effort, fast mode, and runtime mode selections map from instance metadata - Add subagent transcript inspection in the chat activity UI and update transcript slicing behavior - Extend shared contracts and analytics/session logic to carry the new provider metadata
1 parent 9f0c318 commit def0715

28 files changed

Lines changed: 1781 additions & 259 deletions

apps/server/src/provider/Drivers/ClaudeDriver.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,10 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
376376
const adapter = yield* makeClaudeAdapter(effectiveConfig, {
377377
instanceId,
378378
environment: processEnv,
379+
resolveModelMetadata: (model) =>
380+
snapshot.getSnapshot.pipe(
381+
Effect.map((provider) => provider.models.find((candidate) => candidate.slug === model)),
382+
),
379383
...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
380384
onAccountRateLimitsUpdated: (rateLimitInfo) =>
381385
Effect.gen(function* () {

apps/server/src/provider/Layers/ClaudeAdapter.test.ts

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
ThreadId,
2525
ProviderInstanceId,
2626
} from "@threadlines/contracts";
27-
import { createModelSelection } from "@threadlines/shared/model";
27+
import { createModelCapabilities, createModelSelection } from "@threadlines/shared/model";
2828
import { assert, describe, it } from "@effect/vitest";
2929
import * as Context from "effect/Context";
3030
import * as Effect from "effect/Effect";
@@ -272,6 +272,7 @@ function makeHarness(config?: {
272272
readonly instanceId?: ProviderInstanceId;
273273
readonly environment?: NodeJS.ProcessEnv;
274274
readonly onChatAuthStateChanged?: ClaudeAdapterLiveOptions["onChatAuthStateChanged"];
275+
readonly resolveModelMetadata?: ClaudeAdapterLiveOptions["resolveModelMetadata"];
275276
}) {
276277
const query = new FakeClaudeQuery();
277278
let createInput:
@@ -301,6 +302,7 @@ function makeHarness(config?: {
301302
...(config?.onChatAuthStateChanged
302303
? { onChatAuthStateChanged: config.onChatAuthStateChanged }
303304
: {}),
305+
...(config?.resolveModelMetadata ? { resolveModelMetadata: config.resolveModelMetadata } : {}),
304306
};
305307

306308
return {
@@ -455,6 +457,34 @@ describe("mapClaudeSubagentTranscript", () => {
455457
const limited = mapClaudeSubagentTranscript(many, { limit: 3 });
456458
assert.equal(limited.entries.length, 3);
457459
assert.equal(limited.truncated, true);
460+
assert.equal(limited.offset, 0);
461+
assert.equal(limited.totalEntries, 5);
462+
463+
const latest = mapClaudeSubagentTranscript(many, { limit: 2, fromEnd: true });
464+
assert.deepStrictEqual(latest, {
465+
entries: [
466+
{ role: "assistant", text: "step", toolUses: [] },
467+
{ role: "assistant", text: "step", toolUses: [] },
468+
],
469+
truncated: true,
470+
offset: 3,
471+
totalEntries: 5,
472+
});
473+
474+
const earlier = mapClaudeSubagentTranscript(many, { limit: 2, offset: 1 });
475+
assert.deepStrictEqual(earlier, {
476+
entries: [
477+
{ role: "assistant", text: "step", toolUses: [] },
478+
{ role: "assistant", text: "step", toolUses: [] },
479+
],
480+
truncated: true,
481+
offset: 1,
482+
totalEntries: 5,
483+
});
484+
485+
const zeroLimit = mapClaudeSubagentTranscript(many, { limit: 0 });
486+
assert.equal(zeroLimit.entries.length, 5);
487+
assert.equal(zeroLimit.truncated, false);
458488
});
459489
});
460490

@@ -807,6 +837,91 @@ describe("ClaudeAdapterLive", () => {
807837
);
808838
});
809839

840+
it.effect("forwards Claude Opus 5 effort and fast mode on the canonical model id", () => {
841+
const harness = makeHarness();
842+
return Effect.gen(function* () {
843+
const adapter = yield* ClaudeAdapter;
844+
yield* adapter.startSession({
845+
threadId: THREAD_ID,
846+
provider: ProviderDriverKind.make("claudeAgent"),
847+
modelSelection: createModelSelection(
848+
ProviderInstanceId.make("claudeAgent"),
849+
"claude-opus-5",
850+
[
851+
{ id: "effort", value: "max" },
852+
{ id: "fastMode", value: true },
853+
{ id: "thinking", value: false },
854+
],
855+
),
856+
runtimeMode: "full-access",
857+
});
858+
859+
const createInput = harness.getLastCreateQueryInput();
860+
assert.equal(createInput?.options.model, "claude-opus-5");
861+
assert.equal(createInput?.options.effort, "max");
862+
assert.deepEqual(createInput?.options.settings, {
863+
fastMode: true,
864+
});
865+
}).pipe(
866+
Effect.provideService(Random.Random, makeDeterministicRandomService()),
867+
Effect.provide(harness.layer),
868+
);
869+
});
870+
871+
it.effect("forwards live-discovered Claude model options through instance metadata", () => {
872+
const harness = makeHarness({
873+
resolveModelMetadata: () =>
874+
Effect.succeed({
875+
capabilities: createModelCapabilities({
876+
optionDescriptors: [
877+
{
878+
id: "effort",
879+
label: "Reasoning",
880+
type: "select",
881+
options: [
882+
{ id: "low", label: "Low" },
883+
{ id: "max", label: "Max" },
884+
],
885+
},
886+
{
887+
id: "fastMode",
888+
label: "Fast Mode",
889+
type: "boolean",
890+
},
891+
],
892+
}),
893+
supportedRuntimeModes: ["approval-required", "auto", "full-access"],
894+
}),
895+
});
896+
return Effect.gen(function* () {
897+
const adapter = yield* ClaudeAdapter;
898+
yield* adapter.startSession({
899+
threadId: THREAD_ID,
900+
provider: ProviderDriverKind.make("claudeAgent"),
901+
modelSelection: createModelSelection(
902+
ProviderInstanceId.make("claudeAgent"),
903+
"claude-opus-6",
904+
[
905+
{ id: "effort", value: "max" },
906+
{ id: "fastMode", value: true },
907+
],
908+
),
909+
runtimeMode: "auto",
910+
});
911+
912+
const createInput = harness.getLastCreateQueryInput();
913+
assert.equal(createInput?.options.model, "claude-opus-6");
914+
assert.equal(createInput?.options.effort, "max");
915+
assert.equal(createInput?.options.permissionMode, "auto");
916+
assert.deepEqual(createInput?.options.settings, {
917+
fastMode: true,
918+
});
919+
}).pipe(
920+
Effect.provideService(Random.Random, makeDeterministicRandomService()),
921+
Effect.provide(harness.layer),
922+
);
923+
});
924+
810925
it.effect("forwards max effort for Claude Sonnet 5", () => {
811926
const harness = makeHarness();
812927
return Effect.gen(function* () {

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
type ClaudeSettings,
3232
EventId,
3333
MessageId,
34+
type ModelCapabilities,
3435
type ModelSelection,
3536
type ProviderApprovalDecision,
3637
ProviderDriverKind,
@@ -47,6 +48,7 @@ import {
4748
type ThreadTokenUsageSnapshot,
4849
type ProviderUserInputAnswers,
4950
type RuntimeSessionExitKind,
51+
type ServerProviderModel,
5052
type RuntimeContentStreamKind,
5153
RuntimeItemId,
5254
RuntimeRequestId,
@@ -387,6 +389,11 @@ export interface ClaudeAdapterLiveOptions {
387389
readonly prompt: AsyncIterable<SDKUserMessage>;
388390
readonly options: ClaudeQueryOptions;
389391
}) => ClaudeQueryRuntime;
392+
readonly resolveModelMetadata?: (
393+
model: string,
394+
) => Effect.Effect<
395+
Pick<ServerProviderModel, "capabilities" | "supportedRuntimeModes"> | undefined
396+
>;
390397
readonly nativeEventLogPath?: string;
391398
readonly nativeEventLogger?: EventNdjsonLogger;
392399
/**
@@ -495,11 +502,11 @@ interface ClaudeFlagSettingsSnapshot {
495502

496503
function deriveClaudeFlagSettings(
497504
modelSelection: ModelSelection | undefined,
505+
capabilities: ModelCapabilities,
498506
): ClaudeFlagSettingsSnapshot {
499-
const caps = getClaudeModelCapabilities(modelSelection?.model);
500-
const descriptors = getProviderOptionDescriptors({ caps });
507+
const descriptors = getProviderOptionDescriptors({ caps: capabilities });
501508
const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort");
502-
const effort = resolveClaudeEffort(caps, rawEffort) ?? null;
509+
const effort = resolveClaudeEffort(capabilities, rawEffort) ?? null;
503510
const fastModeSupported = descriptors.some(
504511
(descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode",
505512
);
@@ -1935,28 +1942,35 @@ function capTranscriptText(value: string, maxChars: number): string {
19351942
*/
19361943
export function mapClaudeSubagentTranscript(
19371944
jsonl: string,
1938-
options?: { readonly limit?: number },
1945+
options?: {
1946+
readonly limit?: number;
1947+
readonly offset?: number;
1948+
readonly fromEnd?: boolean;
1949+
},
19391950
): ProviderSubagentTranscriptResult {
19401951
const limit =
19411952
options?.limit !== undefined && options.limit > 0
19421953
? options.limit
19431954
: SUBAGENT_TRANSCRIPT_DEFAULT_LIMIT;
1955+
const requestedOffset = options?.offset ?? 0;
19441956
const entries: Array<ProviderSubagentTranscriptEntry> = [];
1945-
let truncated = false;
1946-
1947-
const push = (entry: ProviderSubagentTranscriptEntry): boolean => {
1948-
if (entries.length >= limit) {
1949-
truncated = true;
1950-
return false;
1957+
let totalEntries = 0;
1958+
const push = (entry: ProviderSubagentTranscriptEntry): void => {
1959+
const entryIndex = totalEntries;
1960+
totalEntries += 1;
1961+
if (options?.fromEnd) {
1962+
entries.push(entry);
1963+
if (entries.length > limit) {
1964+
entries.shift();
1965+
}
1966+
return;
1967+
}
1968+
if (entryIndex >= requestedOffset && entries.length < limit) {
1969+
entries.push(entry);
19511970
}
1952-
entries.push(entry);
1953-
return true;
19541971
};
19551972

19561973
for (const line of jsonl.split("\n")) {
1957-
if (truncated) {
1958-
break;
1959-
}
19601974
const record = line.trim();
19611975
if (record.length === 0) {
19621976
continue;
@@ -2053,7 +2067,15 @@ export function mapClaudeSubagentTranscript(
20532067
}
20542068
}
20552069

2056-
return { entries, truncated };
2070+
const offset = options?.fromEnd
2071+
? Math.max(0, totalEntries - limit)
2072+
: Math.min(requestedOffset, totalEntries);
2073+
return {
2074+
entries,
2075+
truncated: offset > 0 || offset + entries.length < totalEntries,
2076+
offset,
2077+
totalEntries,
2078+
};
20572079
}
20582080

20592081
/** Non-null `parent_tool_use_id` marks a message forwarded from inside a
@@ -2519,6 +2541,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
25192541
prompt: input.prompt,
25202542
options: input.options,
25212543
}) as ClaudeQueryRuntime);
2544+
const resolveModelMetadata =
2545+
options?.resolveModelMetadata ??
2546+
(() =>
2547+
Effect.succeed<
2548+
Pick<ServerProviderModel, "capabilities" | "supportedRuntimeModes"> | undefined
2549+
>(undefined));
25222550

25232551
const sessions = new Map<ThreadId, ClaudeSessionContext>();
25242552
const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
@@ -5397,13 +5425,20 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
53975425
const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
53985426
const modelSelection =
53995427
input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
5400-
const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined;
5428+
const modelMetadata = modelSelection
5429+
? yield* resolveModelMetadata(modelSelection.model)
5430+
: undefined;
5431+
const modelCapabilities =
5432+
modelMetadata?.capabilities ?? getClaudeModelCapabilities(modelSelection?.model);
5433+
const apiModelId = modelSelection
5434+
? resolveClaudeApiModelId(modelSelection, modelCapabilities)
5435+
: undefined;
54015436
const fallbackModel = resolveClaudeFallbackModelOption(claudeSettings.fallbackModel, [
54025437
modelSelection?.model,
54035438
apiModelId,
54045439
]);
54055440
const fallbackModelIds = splitClaudeFallbackModelOption(fallbackModel);
5406-
const flagSettings = deriveClaudeFlagSettings(modelSelection);
5441+
const flagSettings = deriveClaudeFlagSettings(modelSelection, modelCapabilities);
54075442
const effectiveEffort = flagSettings.effortLevel;
54085443
const runtimeModeToPermission: Record<string, PermissionMode> = {
54095444
"auto-accept-edits": "acceptEdits",
@@ -5416,7 +5451,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
54165451
// else falls back to in-app approval prompts instead of erroring.
54175452
const autoModeClamped =
54185453
requestedPermissionMode === "auto" &&
5419-
!claudeModelSupportsAutoRuntimeMode(modelSelection?.model);
5454+
!(modelMetadata
5455+
? (modelMetadata.supportedRuntimeModes?.includes("auto") ?? true)
5456+
: claudeModelSupportsAutoRuntimeMode(modelSelection?.model));
54205457
const permissionMode = autoModeClamped ? "acceptEdits" : requestedPermissionMode;
54215458
const settings = {
54225459
...(flagSettings.alwaysThinkingEnabled !== null
@@ -5668,6 +5705,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
56685705
input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId
56695706
? input.modelSelection
56705707
: undefined;
5708+
const modelMetadata = modelSelection
5709+
? yield* resolveModelMetadata(modelSelection.model)
5710+
: undefined;
5711+
const modelCapabilities =
5712+
modelMetadata?.capabilities ?? getClaudeModelCapabilities(modelSelection?.model);
56715713

56725714
if (context.turnState) {
56735715
// Auto-close a stale synthetic turn (from background agent responses
@@ -5676,7 +5718,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
56765718
}
56775719

56785720
if (modelSelection?.model) {
5679-
const apiModelId = resolveClaudeApiModelId(modelSelection);
5721+
const apiModelId = resolveClaudeApiModelId(modelSelection, modelCapabilities);
56805722
if (context.currentApiModelId !== apiModelId) {
56815723
yield* Effect.tryPromise({
56825724
try: () => context.query.setModel(apiModelId),
@@ -5695,7 +5737,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
56955737
// apply must not block the user's turn, and an un-updated snapshot means
56965738
// the next turn retries.
56975739
if (modelSelection !== undefined) {
5698-
const desiredFlagSettings = deriveClaudeFlagSettings(modelSelection);
5740+
const desiredFlagSettings = deriveClaudeFlagSettings(modelSelection, modelCapabilities);
56995741
if (!claudeFlagSettingsEqual(desiredFlagSettings, context.currentFlagSettings)) {
57005742
const applyFlagSettings = context.query.applyFlagSettings?.bind(context.query);
57015743
if (applyFlagSettings === undefined) {
@@ -5952,10 +5994,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
59525994
requestError(`Failed to read the subagent transcript: ${cause.message}`),
59535995
),
59545996
);
5955-
return mapClaudeSubagentTranscript(
5956-
transcript,
5957-
input.limit !== undefined ? { limit: input.limit } : {},
5958-
);
5997+
return mapClaudeSubagentTranscript(transcript, {
5998+
...(input.limit !== undefined ? { limit: input.limit } : {}),
5999+
...(input.offset !== undefined ? { offset: input.offset } : {}),
6000+
...(input.fromEnd !== undefined ? { fromEnd: input.fromEnd } : {}),
6001+
});
59596002
});
59606003

59616004
const rewindFilesForRollback = Effect.fn("rewindFilesForRollback")(function* (

0 commit comments

Comments
 (0)