Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 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
22 changes: 22 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# T3 Code

A GUI that drives coding agents through provider CLIs. This glossary holds product and
provider-integration terms; orchestration vocabulary (command, decider, event, projector,
adapter, reactor, receipt, checkpoint) stays in `docs/internals/glossary.md`.

## Language

**Provider option**:
A per-thread choice that T3 Code persists with the thread and re-applies to the provider
session every time that session starts, such as reasoning effort or OhMyPi's advisor.
_Avoid_: model option, trait, session toggle

**Provider setting**:
Global configuration of a provider instance, such as its binary path or enabled state. It
applies to every thread that uses the instance.
_Avoid_: provider config, provider option

**Skill**:
A named instruction bundle the provider discovers on disk and the user starts with a
`$name` mention. How a provider runs it, such as OhMyPi's `/skill:name`, is not part of the term.
_Avoid_: skill command, slash skill
8 changes: 6 additions & 2 deletions apps/mobile/src/components/ComposerEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolveProviderSkillInstructionsPath } from "@t3tools/client-runtime/providerSkills";
import { ComposerContextId } from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
Expand Down Expand Up @@ -159,6 +160,9 @@ export function ComposerEditor({
const selectedSkill = selectedSkillName
? props.skills?.find((skill) => skill.name === selectedSkillName)
: undefined;
const selectedSkillInstructionsPath = selectedSkill
? resolveProviderSkillInstructionsPath(selectedSkill)
: undefined;
const record = draft.context?.records.find(
(entry) => entry.contextId === selectedReference?.contextId,
);
Expand Down Expand Up @@ -221,11 +225,11 @@ export function ComposerEditor({
: undefined)
}
{...(selectedSkill?.description ? { skillDescription: selectedSkill.description } : {})}
{...(selectedSkill?.path && onOpenMention
{...(selectedSkillInstructionsPath && onOpenMention
? {
onOpenSkill: () => {
setSelected(null);
onOpenMention(selectedSkill.path!);
onOpenMention(selectedSkillInstructionsPath);
},
}
: {})}
Expand Down
25 changes: 25 additions & 0 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import type * as AcpSchema from "effect-acp/schema";
const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH;
const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH;
const antigravityProfile = process.env.T3_ACP_ANTIGRAVITY === "1";
/** JSON `AvailableCommand[]` published after session setup, like omp's bootstrap update. */
const availableCommandsJson = process.env.T3_ACP_AVAILABLE_COMMANDS;
/** Delay that publication past the setup response, as omp does. */
const availableCommandsDelayMs = Number(process.env.T3_ACP_AVAILABLE_COMMANDS_DELAY_MS ?? "0");
const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1";
const emitInterleavedAssistantToolCalls =
process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
Expand Down Expand Up @@ -436,11 +440,30 @@ const program = Effect.gen(function* () {
yield* agent.handleLogout(() => Effect.succeed({}));
}

const publishConfiguredCommands = (targetSessionId: string) => {
if (availableCommandsJson === undefined) return Effect.void;
const publish = agent.client.sessionUpdate({
sessionId: targetSessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands: JSON.parse(availableCommandsJson),
},
});
return availableCommandsDelayMs > 0
? Effect.sleep(`${availableCommandsDelayMs} millis`).pipe(
Effect.andThen(publish),
Effect.forkDetach,
Effect.asVoid,
)
: publish;
};

yield* agent.handleCreateSession(() =>
Effect.gen(function* () {
if (antigravityProfile) {
yield* publishAntigravityCommands(sessionId);
}
yield* publishConfiguredCommands(sessionId);
return {
sessionId,
modes: modeState(),
Expand All @@ -465,6 +488,7 @@ const program = Effect.gen(function* () {
if (antigravityProfile) {
yield* publishAntigravityCommands(request.sessionId);
}
yield* publishConfiguredCommands(request.sessionId);
return {
modes: modeState(),
models: modelState(),
Expand Down Expand Up @@ -528,6 +552,7 @@ const program = Effect.gen(function* () {
content: { type: "text", text: "replay" },
},
});
yield* publishConfiguredCommands(requestedSessionId);
return {
modes: modeState(),
models: modelState(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ describe("ProviderCommandReactor", () => {
readonly deferReactorStart?: boolean;
readonly threadModelSelection?: ModelSelection;
readonly sessionModelSwitch?: "unsupported" | "in-session";
readonly sessionRestartOptionIds?: ReadonlyArray<string>;
readonly requiresNewThreadForModelChange?: boolean;
readonly unreadableHistory?: boolean;
readonly titleRegenerationCompletionDispatchFailures?: number;
Expand Down Expand Up @@ -367,6 +368,9 @@ describe("ProviderCommandReactor", () => {
getCapabilities: (_provider) =>
Effect.succeed({
sessionModelSwitch: input?.sessionModelSwitch ?? "in-session",
...(input?.sessionRestartOptionIds
? { sessionRestartOptionIds: input.sessionRestartOptionIds }
: {}),
}),
assertConversationRollbackSupported: () => unsupported(),
getInstanceInfo: (instanceId) => {
Expand Down Expand Up @@ -3187,6 +3191,7 @@ describe("ProviderCommandReactor", () => {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "claude-sonnet-4-6",
},
sessionRestartOptionIds: ["effort", "fastMode", "contextWindow", "thinking"],
});
const now = "2026-01-01T00:00:00.000Z";

Expand Down Expand Up @@ -3249,6 +3254,74 @@ describe("ProviderCommandReactor", () => {
});
});

it("restarts only when a launch-time option changes, not an in-session one", async () => {
const instanceId = ProviderInstanceId.make("omp");
const harness = await createHarness({
threadModelSelection: { instanceId, model: "oh-my-pi-default" },
sessionRestartOptionIds: ["advisor", "computerUse", "prewalk"],
});
const now = "2026-01-01T00:00:00.000Z";
const startTurn = (
suffix: string,
options: ReadonlyArray<{ id: string; value: string | boolean }>,
) =>
Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make(`cmd-turn-start-launch-option-${suffix}`),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId(`user-message-launch-option-${suffix}`),
role: "user",
text: `turn ${suffix}`,
attachments: [],
},
modelSelection: createModelSelection(instanceId, "oh-my-pi-default", options),
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await startTurn("1", [{ id: "thinking", value: "low" }]);
await waitFor(() => harness.sendTurn.mock.calls.length === 1);
expect(harness.startSession.mock.calls.length).toBe(1);

// Thinking applies in-session.
await startTurn("2", [{ id: "thinking", value: "high" }]);
await waitFor(() => harness.sendTurn.mock.calls.length === 2);
expect(harness.startSession.mock.calls.length).toBe(1);

await startTurn("3", [
{ id: "thinking", value: "high" },
{ id: "advisor", value: true },
]);
await waitFor(() => harness.startSession.mock.calls.length === 2);
await waitFor(() => harness.sendTurn.mock.calls.length === 3);
expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
resumeCursor: { opaque: "resume-1" },
modelSelection: createModelSelection(instanceId, "oh-my-pi-default", [
{ id: "thinking", value: "high" },
{ id: "advisor", value: true },
]),
});

// Switching a launch-time option off is a change too.
await startTurn("4", [
{ id: "thinking", value: "high" },
{ id: "advisor", value: false },
]);
await waitFor(() => harness.startSession.mock.calls.length === 3);
await waitFor(() => harness.sendTurn.mock.calls.length === 4);
expect(harness.startSession.mock.calls[2]?.[1]).toMatchObject({
resumeCursor: { opaque: "resume-1" },
modelSelection: createModelSelection(instanceId, "oh-my-pi-default", [
{ id: "thinking", value: "high" },
{ id: "advisor", value: false },
]),
});
});

it("restarts the provider session when runtime mode is updated on the thread", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";
Expand Down
37 changes: 28 additions & 9 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Equal from "effect/Equal";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -209,6 +208,24 @@ function buildGeneratedWorktreeBranchName(raw: string): string {
return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`;
}

/**
* Whether an option the provider applies only at launch (its adapter's
* `sessionRestartOptionIds`) differs between the selection the session was last
* given and the requested one. Values compare strictly: an explicit `false`
* differs from unset, since Claude's thinking toggle and OhMyPi's session
* toggles both change behavior when switched off. An unknown previous
* selection reads as every listed option unset.
*/
function haveSessionRestartOptionsChanged(
optionIds: ReadonlyArray<string> | undefined,
previous: ModelSelection | undefined,
requested: ModelSelection,
): boolean {
const valueOf = (selection: ModelSelection | undefined, id: string) =>
selection?.options?.find((option) => option.id === id)?.value;
return (optionIds ?? []).some((id) => valueOf(previous, id) !== valueOf(requested, id));
}

const make = Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
const orchestrationEngine = yield* OrchestrationEngineService;
Expand Down Expand Up @@ -754,27 +771,29 @@ const make = Effect.gen(function* () {
if (existingSessionThreadId) {
const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;
const cwdChanged = effectiveCwd !== activeSession?.cwd;
const sessionModelSwitch = (yield* providerService.getCapabilities(desiredInstanceId))
.sessionModelSwitch;
const capabilities = yield* providerService.getCapabilities(desiredInstanceId);
const sessionModelSwitch = capabilities.sessionModelSwitch;
const modelChanged =
requestedModelSelection !== undefined &&
requestedModelSelection.model !== activeSession?.model;
const instanceChanged =
requestedModelSelection !== undefined &&
activeSession?.providerInstanceId !== requestedModelSelection.instanceId;
const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "unsupported";
const previousModelSelection = threadModelSelections.get(threadId);
const shouldRestartForModelSelectionChange =
preferredProvider === "claudeAgent" &&
const shouldRestartForLaunchOptionChange =
requestedModelSelection !== undefined &&
!Equal.equals(previousModelSelection, requestedModelSelection);
haveSessionRestartOptionsChanged(
capabilities.sessionRestartOptionIds,
threadModelSelections.get(threadId),
requestedModelSelection,
);

if (
!runtimeModeChanged &&
!cwdChanged &&
!instanceChanged &&
!shouldRestartForModelChange &&
!shouldRestartForModelSelectionChange
!shouldRestartForLaunchOptionChange
) {
yield* refreshWorkspaceSnapshot;
return existingSessionThreadId;
Expand All @@ -799,7 +818,7 @@ const make = Effect.gen(function* () {
modelChanged,
instanceChanged,
shouldRestartForModelChange,
shouldRestartForModelSelectionChange,
shouldRestartForLaunchOptionChange,
hasResumeCursor: resumeCursor !== undefined,
});
const restartedSession = yield* startProviderSession(
Expand Down
Loading