Skip to content
Open
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
10 changes: 8 additions & 2 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -648,8 +648,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
? undefined
: currentModelSelection.instanceId;
const modelOptions = useMemo(
() => buildModelOptions(props.serverConfig, currentModelSelection, lockedProviderInstanceId),
[props.serverConfig, currentModelSelection, lockedProviderInstanceId],
() =>
buildModelOptions(
props.serverConfig,
currentModelSelection,
lockedProviderInstanceId,
props.projectCwd,
),
[props.serverConfig, currentModelSelection, lockedProviderInstanceId, props.projectCwd],
);
const threadProviderGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
const currentModelOption =
Expand Down
21 changes: 20 additions & 1 deletion apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -555,14 +555,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedEnvironmentServerConfig,
storedStickyModelSelection,
);
const modelCwd =
(workspaceMode === "worktree"
? selectedProject?.workspaceRoot
: (selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null;
const modelOptions = useMemo(
() =>
buildModelOptions(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
selectedEnvironmentServerConfig,
draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection,
undefined,
modelCwd,
),
[
selectedEnvironmentServerConfig,
modelCwd,
draftModelSelection,
projectDefaultModelSelection,
stickyModelSelection,
Expand Down Expand Up @@ -1077,11 +1084,22 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const text = draft.text.trim();
// Use the displayed selection rules without substituting an unavailable
// Antigravity model while the task is queued.
const draftModelSelection =
const currentModelSelection =
resolveSelectableModelSelection(
selectedEnvironmentServerConfig,
draft.modelSelection ?? null,
) ?? selectedModel;
const draftModelSelection = resolveNewTaskModelSelection({
draftSelection: currentModelSelection,
projectDefaultSelection: null,
stickySelection: null,
modelOptions: buildModelOptions(
selectedEnvironmentServerConfig,
currentModelSelection,
undefined,
modelCwd,
),
});
if (text.length === 0 || !draftModelSelection) {
return null;
}
Expand Down Expand Up @@ -1151,6 +1169,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
defaultRuntimeMode,
editingPendingProject,
editingPendingTask,
modelCwd,
selectedEnvironmentServerConfig,
selectedModel,
selectedProject,
Expand Down
95 changes: 95 additions & 0 deletions apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,101 @@ import {
} from "./modelOptions";

describe("mobile model options", () => {
it("preserves a selected project agent only in the matching workspace catalog", () => {
const config = {
providers: [
{
instanceId: "opencode",
driver: "opencode",
enabled: true,
installed: true,
auth: { status: "authenticated" },
models: [
{
slug: "opencode/model",
name: "Model",
isCustom: false,
capabilities: {
optionDescriptors: [
{
id: "variant",
label: "Reasoning",
type: "select",
options: [
{ id: "high", label: "High" },
{ id: "low", label: "Low", isDefault: true },
],
currentValue: "low",
},
{
id: "agent",
label: "Agent",
type: "select",
options: [{ id: "build", label: "Build", isDefault: true }],
currentValue: "build",
},
],
},
},
],
workspaceSnapshots: ["readonly", "audit"].map((name) => ({
cwd: "/work/" + name,
checkedAt: "2026-01-01T00:00:00.000Z",
skills: [],
slashCommands: [],
optionDescriptors: [
{
id: "agent",
label: "Agent",
type: "select",
currentValue: "build",
options: [
{ id: "build", label: "Build", isDefault: true },
{ id: name, label: name },
],
},
],
})),
},
],
} as unknown as ServerConfig;
const selection: ModelSelection = {
instanceId: ProviderInstanceId.make("opencode"),
model: "opencode/model",
options: [
{ id: "variant", value: "high" },
{ id: "agent", value: "readonly" },
],
};
const localOptions = buildModelOptions(config, selection, undefined, "/work/readonly");
const [local] = localOptions;
expect(local?.selection).toEqual(selection);
const otherOptions = buildModelOptions(config, selection, undefined, "/work/audit");
const [other] = otherOptions;
expect(other?.selection.options).toEqual([
{ id: "variant", value: "high" },
{ id: "agent", value: "build" },
]);
const [global] = buildModelOptions(config, selection);
expect(global?.selection.options).toEqual(other?.selection.options);
for (const modelOptions of [localOptions, otherOptions]) {
for (const source of [
"draftSelection",
"projectDefaultSelection",
"stickySelection",
] as const) {
expect(
resolveNewTaskModelSelection({
draftSelection: null,
projectDefaultSelection: null,
stickySelection: null,
[source]: selection,
modelOptions,
}),
).toEqual(modelOptions[0]?.selection);
}
}
});
it("groups models by provider and flags legacy entries", () => {
const config = {
providers: [
Expand Down
17 changes: 14 additions & 3 deletions apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolveProviderForCwd } from "@t3tools/client-runtime/providerSkills";
import type { MenuAction } from "@react-native-menu/menu";
import type {
ModelCapabilities,
Expand Down Expand Up @@ -145,24 +146,34 @@ export function resolveNewTaskModelSelection(input: {
readonly stickySelection: ModelSelection | null;
readonly modelOptions: ReadonlyArray<ModelOption>;
}): ModelSelection | null {
return (
const selection =
input.draftSelection ??
input.projectDefaultSelection ??
input.stickySelection ??
input.modelOptions.find((option) => option.isDefault && !option.isUnavailable)?.selection ??
input.modelOptions.find((option) => !option.isUnavailable)?.selection ??
null
null;
if (!selection) return null;
const option = input.modelOptions.find(
(option) =>
option.selection.instanceId === selection.instanceId &&
option.selection.model === selection.model,
);
return option && option.providerDriver !== "antigravity"
? normalizeSelectionOptions(selection, option.capabilities)
: selection;
}

export function buildModelOptions(
config: T3ServerConfig | null | undefined,
fallbackModelSelection: ModelSelection | null,
providerInstanceId?: ModelSelection["instanceId"],
cwd?: string | null,
): ReadonlyArray<ModelOption> {
const options = new Map<string, ModelOption>();

for (const provider of config?.providers ?? []) {
for (const snapshot of config?.providers ?? []) {
const provider = resolveProviderForCwd(snapshot, cwd);
if (
(providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) ||
!provider.enabled ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,59 @@ const history = {
};

describe("OpenCode2 adapter", () => {
it.effect("switches to the selected project agent and back to build before prompting", () =>
Effect.gen(function* () {
const { runtime, thread } = yield* resumed([
out("session.switchAgent", { sessionID: SESSION, agent: "readonly" }),
reply("session.switchAgent", null),
out("session.prompt", { sessionID: SESSION, text: "<any>" }),
promptAccepted,
event("session.execution.succeeded", { sessionID: SESSION }),
out("session.switchAgent", { sessionID: SESSION, agent: "build" }),
reply("session.switchAgent", null),
out("session.prompt", { sessionID: SESSION, text: "<any>" }),
promptAccepted,
event("session.execution.succeeded", { sessionID: SESSION }),
]);
const first = yield* terminalOf(runtime).pipe(Effect.forkScoped);
yield* runtime.startTurn(
turnInput(thread, { ...bigPickle, options: [{ id: "agent", value: "readonly" }] }),
);
assert.equal((yield* Fiber.join(first))?.status, "completed");
const second = yield* terminalOf(runtime).pipe(Effect.forkScoped);
yield* runtime.startTurn(turnInput(thread));
assert.equal((yield* Fiber.join(second))?.status, "completed");
}).pipe(Effect.scoped),
);

it.effect("keeps plan mode authoritative over an explicit project agent", () =>
Effect.gen(function* () {
const { runtime, thread } = yield* resumed([
out("session.switchAgent", { sessionID: SESSION, agent: "plan" }),
reply("session.switchAgent", null),
out("agent.list", "<any>"),
reply("agent.list", agentList),
out("session.update", {
sessionID: SESSION,
permissions: [
{ action: "*", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
...mcpRules,
],
}),
reply("session.update", null),
out("session.prompt", { sessionID: SESSION, text: "<any>" }),
promptAccepted,
event("session.execution.succeeded", { sessionID: SESSION }),
]);
const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
yield* runtime.startTurn({
...turnInput(thread, { ...bigPickle, options: [{ id: "agent", value: "readonly" }] }),
runtimePolicy: { ...policy(), interactionMode: "plan" },
});
assert.equal((yield* Fiber.join(terminal))?.status, "completed");
}).pipe(Effect.scoped),
);
it.effect("switches the session's model and variant before a turn that changed them", () =>
Effect.gen(function* () {
const { runtime, thread } = yield* resumed([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -765,9 +765,11 @@ const sameModel = (left: ModelRef, right: ModelRef | undefined) =>
left.id === right?.id &&
(left.variant ?? "default") === (right?.variant ?? "default");

/** OpenCode's own agents for T3's interaction modes; plan mode is its read-only `plan` agent. */
/** Plan mode takes precedence over the selected workspace agent. */
const agentFor = (input: ProviderAdapter.ProviderAdapterV2TurnInput) =>
input.runtimePolicy.interactionMode === "plan" ? "plan" : "build";
input.runtimePolicy.interactionMode === "plan"
? "plan"
: (getModelSelectionStringOptionValue(input.modelSelection, "agent") ?? "build");

/** `/name args` naming one of the workspace's commands, which OpenCode expands itself. */
const commandOf = (text: string) => {
Expand Down
Loading
Loading