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
24 changes: 2 additions & 22 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
type ComposerDraft,
} from "../../state/use-composer-drafts";
import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
import { resolveSelectableModelSelection } from "../../lib/modelOptions";
import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions";
import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
Expand Down Expand Up @@ -543,27 +543,7 @@ export function NewTaskDraftScreen(props: {
);

const modelMenuActions = useMemo(
() =>
flow.providerGroups.map((group) => ({
id: `provider:${group.providerKey}`,
title: group.providerLabel,
subtitle: group.models.find(
(model) =>
flow.selectedModel &&
model.selection.instanceId === flow.selectedModel.instanceId &&
model.selection.model === flow.selectedModel.model,
)?.label,
subactions: group.models.map((option) => ({
id: `model:${option.key}`,
title: option.label,
state:
flow.selectedModel &&
option.selection.instanceId === flow.selectedModel.instanceId &&
option.selection.model === flow.selectedModel.model
? ("on" as const)
: undefined,
})),
})),
() => buildModelMenuActions(flow.providerGroups, flow.selectedModel),
[flow.providerGroups, flow.selectedModel],
);
const providerOptionDescriptors = useMemo(
Expand Down
22 changes: 2 additions & 20 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import {
import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
import { ProviderIcon } from "../../components/ProviderIcon";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
import { buildModelMenuActions, buildModelOptions, groupByProvider } from "../../lib/modelOptions";
import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
import type { RemoteClientConnectionState } from "../../lib/connection";
import {
Expand Down Expand Up @@ -606,25 +606,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
[providerOptionDescriptors],
);
const modelMenuActions = useMemo(
() =>
providerGroups.map((group) => ({
id: `provider:${group.providerKey}`,
title: group.providerLabel,
subtitle: group.models.find(
(model) =>
model.selection.instanceId === currentModelSelection.instanceId &&
model.selection.model === currentModelSelection.model,
)?.label,
subactions: group.models.map((option) => ({
id: `model:${option.key}`,
title: option.label,
state:
option.selection.instanceId === currentModelSelection.instanceId &&
option.selection.model === currentModelSelection.model
? ("on" as const)
: undefined,
})),
})),
() => buildModelMenuActions(providerGroups, currentModelSelection),
[providerGroups, currentModelSelection],
);

Expand Down
85 changes: 84 additions & 1 deletion apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,92 @@ import { describe, expect, it } from "vite-plus/test";

import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";

import { buildModelOptions, resolveSelectableModelSelection } from "./modelOptions";
import {
buildModelMenuActions,
buildModelOptions,
groupByProvider,
resolveSelectableModelSelection,
} from "./modelOptions";

describe("mobile model options", () => {
it("folds legacy models into a provider-scoped menu", () => {
const config = {
providers: [
{
instanceId: "codex",
driver: "codex",
displayName: "Codex",
enabled: true,
installed: true,
auth: { status: "authenticated" },
models: [
{
slug: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
isCustom: false,
capabilities: null,
},
{
slug: "gpt-5.4",
name: "GPT-5.4",
isCustom: false,
isLegacy: true,
capabilities: null,
},
],
},
],
} as unknown as ServerConfig;

const actions = buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null);

expect(actions).toMatchObject([
{
title: "Codex",
subactions: [{ id: "model:codex:gpt-5.6-sol", title: "GPT-5.6 Sol" }],
},
{
id: "legacy-models:codex",
title: "Codex legacy models",
subactions: [{ id: "model:codex:gpt-5.4", title: "GPT-5.4" }],
},
]);
});

it("omits an empty provider menu when every model is legacy", () => {
const config = {
providers: [
{
instanceId: "codex",
driver: "codex",
displayName: "Codex",
enabled: true,
installed: true,
auth: { status: "authenticated" },
models: [
{
slug: "gpt-5.4",
name: "GPT-5.4",
isCustom: false,
isLegacy: true,
capabilities: null,
},
],
},
],
} as unknown as ServerConfig;

expect(
buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null),
).toMatchObject([
{
id: "legacy-models:codex",
title: "Codex legacy models",
subactions: [{ id: "model:codex:gpt-5.4" }],
},
]);
});

it("normalizes a legacy fallback selection against current capabilities", () => {
const config = {
providers: [
Expand Down
54 changes: 54 additions & 0 deletions apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ModelSelection,
ServerConfig as T3ServerConfig,
} from "@t3tools/contracts";
import type { MenuAction } from "@react-native-menu/menu";
import {
buildProviderOptionSelectionsFromDescriptors,
getProviderOptionDescriptors,
Expand All @@ -16,6 +17,7 @@ export type ModelOption = {
readonly providerLabel: string;
readonly providerDriver: string;
readonly isDefault: boolean;
readonly isLegacy: boolean;
readonly capabilities: ModelCapabilities | null;
readonly selection: ModelSelection;
};
Expand Down Expand Up @@ -105,6 +107,7 @@ export function buildModelOptions(
providerLabel,
providerDriver: provider.driver,
isDefault: model.isDefault === true,
isLegacy: model.isLegacy === true,
capabilities: model.capabilities,
selection: normalizeSelectionOptions(
{
Expand Down Expand Up @@ -135,6 +138,7 @@ export function buildModelOptions(
providerLabel,
providerDriver: fallbackModelSelection.instanceId,
isDefault: false,
isLegacy: false,
capabilities: null,
selection: fallbackModelSelection,
});
Expand Down Expand Up @@ -164,3 +168,53 @@ export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyAr
models: group.models,
}));
}

function modelMenuAction(option: ModelOption, selectedModel: ModelSelection | null): MenuAction {
return {
id: `model:${option.key}`,
title: option.label,
state:
option.selection.instanceId === selectedModel?.instanceId &&
option.selection.model === selectedModel.model
? "on"
: undefined,
};
}

export function buildModelMenuActions(
groups: ReadonlyArray<ProviderGroup>,
selectedModel: ModelSelection | null,
): MenuAction[] {
return groups.flatMap((group) => {
const currentModels = group.models.filter((model) => !model.isLegacy);
const legacyModels = group.models.filter((model) => model.isLegacy);
const selected = group.models.find(
(model) =>
model.selection.instanceId === selectedModel?.instanceId &&
model.selection.model === selectedModel.model,
);

return [
...(currentModels.length > 0
? [
{
id: `provider:${group.providerKey}`,
title: group.providerLabel,
subtitle: selected && !selected.isLegacy ? selected.label : undefined,
subactions: currentModels.map((option) => modelMenuAction(option, selectedModel)),
},
]
: []),
...(legacyModels.length > 0
? [
{
id: `legacy-models:${group.providerKey}`,
title: `${group.providerLabel} legacy models`,
subtitle: selected?.isLegacy ? selected.label : undefined,
subactions: legacyModels.map((option) => modelMenuAction(option, selectedModel)),
},
]
: []),
];
});
}
19 changes: 17 additions & 2 deletions apps/server/src/cloud/bootService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ it("installs under Marcode's own service identity", () => {

const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
platform: NodeJS.Platform = "linux",
usePinnedLauncher = false,
) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand All @@ -68,6 +69,10 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3");
yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true });
yield* fs.writeFileString(runtime.entryPath, "export {};\n");
yield* fs.writeFileString(
path.join(path.dirname(runtime.entryPath), "service-launcher.mjs"),
"export const source = 'pinned runtime';\n",
);
yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");

const commands: string[] = [];
Expand All @@ -93,8 +98,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
cliVersion: "1.2.3",
host: {
execPath: "/usr/bin/node",
cliEntryPath: path.join(home, "bin.mjs"),
launcherSourcePath: sourceLauncher,
...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }),
},
}).pipe(
Effect.provideService(ProcessRunner.ProcessRunner, runner),
Expand Down Expand Up @@ -133,6 +137,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
}),
);

it.effect("copies the launcher from the prepared pinned runtime", () =>
Effect.gen(function* () {
const { service, fs } = yield* makeHarness("linux", true);
const plan = yield* service.install;

expect(yield* fs.readFileString(plan.launcherPath)).toBe(
"export const source = 'pinned runtime';\n",
);
}),
);

it.effect("restarts an installed service when repair fails", () =>
Effect.gen(function* () {
const { service, commands, control } = yield* makeHarness();
Expand Down
18 changes: 5 additions & 13 deletions apps/server/src/cloud/bootService.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
HostProcessArguments,
HostProcessExecutablePath,
HostProcessPlatform,
} from "@t3tools/shared/hostProcess";
import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Config from "effect/Config";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -140,7 +136,6 @@ export class BootService extends Context.Service<

export interface BootServiceHost {
readonly execPath: string;
readonly cliEntryPath: string;
readonly launcherSourcePath?: string;
}

Expand All @@ -150,26 +145,23 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
readonly cliVersion: string;
readonly host?: BootServiceHost;
}) {
const hostArguments = yield* HostProcessArguments;
const hostExecPath = yield* HostProcessExecutablePath;
const platform = yield* HostProcessPlatform;
const homeDir = yield* Config.string("HOME").pipe(Config.withDefault(""));
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const runner = yield* ProcessRunner.ProcessRunner;
const host = input.host ?? {
execPath: hostExecPath,
cliEntryPath: hostArguments[1] ?? "",
};
const host = input.host ?? { execPath: hostExecPath };

const unitDir = path.join(homeDir, ".config", "systemd", "user");
const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE);
const logPath = path.join(input.logsDir, "boot-service.log");
const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE);
const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);
const launcherSourcePath =
host.launcherSourcePath ?? path.join(path.dirname(host.cliEntryPath), SERVICE_LAUNCHER_FILE);
const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion);
const launcherSourcePath =
host.launcherSourcePath ??
path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE);
const writeDurably = (filePath: string, contents: string) =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,27 @@ import * as Schema from "effect/Schema";
import {
buildClaudeCapabilitiesProbeQueryOptions,
CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,
isLegacyClaudeModel,
probeClaudeCapabilities,
} from "./ClaudeProvider.ts";

const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);

it("keeps only the Claude 5 family out of legacy models", () => {
assert.deepStrictEqual(
["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [
model,
isLegacyClaudeModel(model),
]),
[
["claude-fable-5", false],
["claude-opus-5", false],
["claude-sonnet-5", false],
["claude-opus-4-8", true],
],
);
});

it("isolates Claude capability probes without dropping workspace setting sources", () => {
const abortController = new AbortController();
const options = buildClaudeCapabilitiesProbeQueryOptions({
Expand Down
Loading