diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000000..b87025f12e6a --- /dev/null +++ b/CONTEXT.md @@ -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 diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index a697ad6bc11f..7fa7c23bf4e6 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -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"; @@ -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, ); @@ -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); }, } : {})} diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 0e8d3997d94f..abf917b8419f 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -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"; @@ -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(), @@ -465,6 +488,7 @@ const program = Effect.gen(function* () { if (antigravityProfile) { yield* publishAntigravityCommands(request.sessionId); } + yield* publishConfiguredCommands(request.sessionId); return { modes: modeState(), models: modelState(), @@ -528,6 +552,7 @@ const program = Effect.gen(function* () { content: { type: "text", text: "replay" }, }, }); + yield* publishConfiguredCommands(requestedSessionId); return { modes: modeState(), models: modelState(), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 9bc701af0837..b8710271ae87 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -172,6 +172,7 @@ describe("ProviderCommandReactor", () => { readonly deferReactorStart?: boolean; readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; + readonly sessionRestartOptionIds?: ReadonlyArray; readonly requiresNewThreadForModelChange?: boolean; readonly unreadableHistory?: boolean; readonly titleRegenerationCompletionDispatchFailures?: number; @@ -367,6 +368,9 @@ describe("ProviderCommandReactor", () => { getCapabilities: (_provider) => Effect.succeed({ sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", + ...(input?.sessionRestartOptionIds + ? { sessionRestartOptionIds: input.sessionRestartOptionIds } + : {}), }), assertConversationRollbackSupported: () => unsupported(), getInstanceInfo: (instanceId) => { @@ -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"; @@ -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"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 6a42b7c67ae1..78cde114bfe2 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -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"; @@ -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 | 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; @@ -754,8 +771,8 @@ 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; @@ -763,18 +780,20 @@ const make = Effect.gen(function* () { 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; @@ -799,7 +818,7 @@ const make = Effect.gen(function* () { modelChanged, instanceChanged, shouldRestartForModelChange, - shouldRestartForModelSelectionChange, + shouldRestartForLaunchOptionChange, hasResumeCursor: resumeCursor !== undefined, }); const restartedSession = yield* startProviderSession( diff --git a/apps/server/src/provider/Drivers/OhMyPiDriver.test.ts b/apps/server/src/provider/Drivers/OhMyPiDriver.test.ts index 31465d09678d..d59b1fd17d9f 100644 --- a/apps/server/src/provider/Drivers/OhMyPiDriver.test.ts +++ b/apps/server/src/provider/Drivers/OhMyPiDriver.test.ts @@ -41,7 +41,10 @@ const testLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-omp-driver Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); const instanceId = ProviderInstanceId.make("omp-test"); +/** omp always follows session setup with a command update; the adapter waits for it. */ +const OMP_BOOTSTRAP_ENV = { T3_ACP_AVAILABLE_COMMANDS: "[]" } as const; const threadId = ThreadId.make("omp-thread"); +const otherThreadId = ThreadId.make("omp-thread-2"); it.layer(testLayer)("OhMyPi driver", (it) => { it.effect("does not start a disabled CLI", () => @@ -113,6 +116,14 @@ it.layer(testLayer)("OhMyPi driver", (it) => { id: "thinking", options: [{ id: "off" }, { id: "auto" }, { id: "low" }, { id: "high" }], }, + { id: "advisor", type: "boolean" }, + { id: "computerUse", type: "boolean" }, + { id: "prewalk", type: "boolean" }, + ]); + expect(first.models[0]?.capabilities?.optionDescriptors?.map((d) => d.id)).toEqual([ + "advisor", + "computerUse", + "prewalk", ]); for (const output of ["not json", "exit", '{"models":[{"name":"missing ID"}]}']) { yield* fs.writeFileString(catalogFile, output); @@ -128,6 +139,188 @@ it.layer(testLayer)("OhMyPi driver", (it) => { }).pipe(Effect.scoped), ); + it.effect("waits for the workspace catalog before preparing the first prompt", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const logPath = path.join(directory, "catalog-race.jsonl"); + const binaryPath = yield* Effect.sync(() => + writeFakeCli({ + directory, + name: "omp-catalog-mock", + env: { + T3_ACP_REQUEST_LOG_PATH: logPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + T3_ACP_AVAILABLE_COMMANDS: JSON.stringify([ + { name: "skill:grill-me", description: "Interview relentlessly" }, + ]), + T3_ACP_AVAILABLE_COMMANDS_DELAY_MS: "400", + }, + source: execScriptSource({ + scriptPath: NodeURL.fileURLToPath( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + }), + }), + ); + const instance = yield* OhMyPiDriver.create({ + instanceId, + displayName: undefined, + enabled: true, + environment: [], + config: { ...OhMyPiDriver.defaultConfig(), binaryPath }, + }); + yield* instance.adapter.startSession({ + threadId, + cwd: directory, + runtimeMode: "full-access", + }); + yield* instance.adapter.sendTurn({ threadId, input: "$grill-me", attachments: [] }); + const prompts = (yield* fs.readFileString(logPath)) + .split("\n") + .filter((line) => line.includes('"method":"session/prompt"')); + expect(prompts).toHaveLength(1); + expect(prompts[0]).toContain('"text":"/skill:grill-me"'); + expect(prompts[0]).not.toContain("runtime_info"); + yield* instance.adapter.stopAll(); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a live command list when a slower probe finishes after it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped(); + const binaryPath = yield* Effect.sync(() => + writeFakeCli({ + directory, + name: "omp-late-probe-mock", + env: { + // What a thread's own session advertises, reported at once. + // @effect-diagnostics-next-line preferSchemaOverJson:off + T3_ACP_AVAILABLE_COMMANDS: JSON.stringify([ + { name: "computer", description: "Drive the screen" }, + ]), + }, + // The probe is the launch carrying `--session-dir`. It reports late + // and disagrees, standing in for a read that the live session's own, + // later one should win over. + source: + ` + if (process.argv.includes("--session-dir")) { + process.env.T3_ACP_AVAILABLE_COMMANDS = JSON.stringify([ + { name: "compact", description: "Compact the conversation" }, + ]); + process.env.T3_ACP_AVAILABLE_COMMANDS_DELAY_MS = "400"; + } + ` + + execScriptSource({ + scriptPath: NodeURL.fileURLToPath( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + }), + }), + ); + const instance = yield* OhMyPiDriver.create({ + instanceId, + displayName: undefined, + enabled: true, + environment: [], + config: { ...OhMyPiDriver.defaultConfig(), binaryPath }, + }); + const probe = yield* instance.snapshotForCwd!(directory).pipe(Effect.forkChild); + yield* instance.adapter.startSession({ + threadId, + cwd: directory, + runtimeMode: "full-access", + }); + // The adapter settles its command wait only after the driver has recorded + // the session's list, so the live write has landed once this returns. + yield* instance.adapter.sendTurn({ threadId, input: "hello", attachments: [] }); + yield* Fiber.join(probe); + expect( + (yield* instance.snapshot.getSnapshot).workspaceSnapshots?.map((workspace) => + workspace.slashCommands.map((command) => command.name), + ), + ).toEqual([["computer"]]); + yield* instance.adapter.stopAll(); + }).pipe(Effect.scoped), + ); + + it.effect("dispatches from each session's own commands, not the workspace's", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const logPath = path.join(directory, "own-catalog.jsonl"); + const otherLogPath = path.join(directory, "other-catalog.jsonl"); + const binaryPath = yield* Effect.sync(() => + writeFakeCli({ + directory, + name: "omp-two-thread-mock", + env: { + T3_ACP_REQUEST_LOG_PATH: logPath, + T3_OTHER_REQUEST_LOG_PATH: otherLogPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + T3_ACP_AVAILABLE_COMMANDS: JSON.stringify([ + { name: "computer", description: "Drive the screen" }, + ]), + }, + // Approval mode stands in for anything that makes two omp processes + // in one workspace disagree — a skill added or a plugin installed + // between the two session starts: the second advertises another set. + source: + ` + if (process.argv.includes("always-ask")) { + process.env.T3_ACP_REQUEST_LOG_PATH = process.env.T3_OTHER_REQUEST_LOG_PATH; + process.env.T3_ACP_AVAILABLE_COMMANDS = JSON.stringify([ + { name: "compact", description: "Compact the conversation" }, + ]); + } + ` + + execScriptSource({ + scriptPath: NodeURL.fileURLToPath( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + }), + }), + ); + const instance = yield* OhMyPiDriver.create({ + instanceId, + displayName: undefined, + enabled: true, + environment: [], + config: { ...OhMyPiDriver.defaultConfig(), binaryPath }, + }); + yield* instance.adapter.startSession({ + threadId, + cwd: directory, + runtimeMode: "full-access", + }); + yield* instance.adapter.startSession({ + threadId: otherThreadId, + cwd: directory, + runtimeMode: "approval-required", + }); + // Returns only once the second thread's list has reached the workspace + // snapshot, so the shared entry now disagrees with the first thread. + yield* instance.adapter.sendTurn({ + threadId: otherThreadId, + input: "hello", + attachments: [], + }); + yield* instance.adapter.sendTurn({ threadId, input: "/computer status", attachments: [] }); + const prompts = (yield* fs.readFileString(logPath)) + .split("\n") + .filter((line) => line.includes('"method":"session/prompt"')); + expect(prompts).toHaveLength(1); + expect(prompts[0]).toContain('"text":"/computer status"'); + // A builtin travels alone; omp folds a second block into its arguments. + expect(prompts[0]).not.toContain("runtime_info"); + yield* instance.adapter.stopAll(); + }).pipe(Effect.scoped), + ); + for (const sendDuringPreparation of [false, true]) { it.effect(`steers within one turn (send during preparation: ${sendDuringPreparation})`, () => Effect.gen(function* () { @@ -140,6 +333,7 @@ it.layer(testLayer)("OhMyPi driver", (it) => { directory, name: "omp-steering-mock", env: { + ...OMP_BOOTSTRAP_ENV, T3_ACP_REQUEST_LOG_PATH: logPath, T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1", }, @@ -235,7 +429,7 @@ it.layer(testLayer)("OhMyPi driver", (it) => { writeFakeCli({ directory, name: "omp-stop-mock", - env: { T3_ACP_REQUEST_LOG_PATH: logPath }, + env: { ...OMP_BOOTSTRAP_ENV, T3_ACP_REQUEST_LOG_PATH: logPath }, source: execScriptSource({ scriptPath: NodeURL.fileURLToPath( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -335,7 +529,7 @@ it.layer(testLayer)("OhMyPi driver", (it) => { writeFakeCli({ directory, name: "omp-background-tool-updates-mock", - env: { T3_ACP_EMIT_ASSISTANT_DURING_TOOL_UPDATES: "1" }, + env: { ...OMP_BOOTSTRAP_ENV, T3_ACP_EMIT_ASSISTANT_DURING_TOOL_UPDATES: "1" }, source: execScriptSource({ scriptPath: NodeURL.fileURLToPath( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -437,7 +631,7 @@ it.layer(testLayer)("OhMyPi driver", (it) => { writeFakeCli({ directory, name: "omp-task-progress-mock", - env: { T3_ACP_EMIT_OH_MY_PI_TASK_UPDATES: "1" }, + env: { ...OMP_BOOTSTRAP_ENV, T3_ACP_EMIT_OH_MY_PI_TASK_UPDATES: "1" }, source: execScriptSource({ scriptPath: NodeURL.fileURLToPath( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -659,7 +853,7 @@ it.layer(testLayer)("OhMyPi driver", (it) => { writeFakeCli({ directory, name: "omp-active-child-stop-mock", - env: { T3_ACP_EMIT_OH_MY_PI_TASK_UPDATES: "1" }, + env: { ...OMP_BOOTSTRAP_ENV, T3_ACP_EMIT_OH_MY_PI_TASK_UPDATES: "1" }, source: execScriptSource({ scriptPath: NodeURL.fileURLToPath( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -733,6 +927,12 @@ it.layer(testLayer)("OhMyPi driver", (it) => { T3_ACP_REQUEST_LOG_PATH: logPath, T3_ACP_EMIT_TOOL_CALLS: "1", T3_ACP_ALLOW_ONCE_OPTION_ID: "omp-allow-42", + // @effect-diagnostics-next-line preferSchemaOverJson:off + T3_ACP_AVAILABLE_COMMANDS: JSON.stringify([ + { name: "compact", description: "Compact the conversation" }, + { name: "skill:grill-me", description: "Interview relentlessly" }, + { name: "advisor", description: "Toggle advisor", input: { hint: "[on|off]" } }, + ]), }, source: ` @@ -770,6 +970,29 @@ it.layer(testLayer)("OhMyPi driver", (it) => { expect(refreshed.models.map((model) => model.slug)).toContain("openai/gpt"); expect(refreshed.version).toBe("18.1.14"); expect(yield* fs.exists(logPath)).toBe(false); + + // The workspace probe reads omp's command list without a thread. + const { stateDir } = yield* ServerConfig; + const instanceDir = path.join(stateDir, "ohmypi", instanceId); + const scoped = yield* instance.snapshotForCwd!(directory); + expect(scoped.skills).toEqual([ + { + name: "grill-me", + description: "Interview relentlessly", + path: "skill://grill-me", + enabled: true, + }, + ]); + expect(scoped.slashCommands.map((command) => command.name)).toEqual(["compact", "advisor"]); + expect((yield* instance.snapshot.getSnapshot).workspaceSnapshots?.[0]?.cwd).toBe(directory); + const probeArgv = (yield* fs.readFileString(argvPath)).trim().split("\n"); + expect(probeArgv).toHaveLength(1); + expect(probeArgv[0]).toContain( + `acp\t--session-dir\t${path.join(instanceDir, "probe-sessions")}`, + ); + expect(yield* fs.readDirectory(path.join(instanceDir, "probe-sessions"))).toEqual([]); + expect(yield* fs.readFileString(logPath)).toContain('"method":"session/close"'); + const events = yield* Queue.unbounded(); yield* instance.adapter.streamEvents.pipe( Stream.runForEach((event) => Queue.offer(events, event)), @@ -779,9 +1002,20 @@ it.layer(testLayer)("OhMyPi driver", (it) => { threadId, cwd: directory, runtimeMode: "approval-required", - modelSelection: { instanceId, model: OH_MY_PI_DEFAULT_MODEL }, + modelSelection: { + instanceId, + model: OH_MY_PI_DEFAULT_MODEL, + options: [{ id: "advisor", value: true }], + }, }); expect(session.provider).toBe("ohMyPi"); + const overlayPath = path.join(instanceDir, "config", "advisor-on.computer-off.yml"); + expect((yield* fs.readFileString(argvPath)).trim().split("\n")[1]).toBe( + `acp\t--approval-mode\talways-ask\t--no-prewalk\t--config\t${overlayPath}`, + ); + expect(yield* fs.readFileString(overlayPath)).toBe( + "advisor:\n enabled: true\ncomputer:\n enabled: false\n", + ); expect((yield* instance.snapshot.getSnapshot).models).toEqual(refreshed.models); const turn = yield* instance.adapter .sendTurn({ @@ -809,13 +1043,46 @@ it.layer(testLayer)("OhMyPi driver", (it) => { expect((yield* instance.snapshot.getSnapshot).models).toEqual(refreshed.models); expect(seen.some((event) => event.type === "content.delta")).toBe(true); expect(seen.some((event) => event.type === "request.resolved")).toBe(true); + + // A skill mention becomes omp's invocation and travels without the runtime block. + const skillTurn = yield* instance.adapter + .sendTurn({ threadId, input: "please $grill-me now", attachments: [] }) + .pipe(Effect.forkChild); + while (true) { + const event = yield* Queue.take(events); + if (event.type === "request.opened") { + yield* instance.adapter.respondToRequest( + threadId, + ApprovalRequestId.make(event.requestId!), + "accept", + ); + } + if (event.type === "turn.completed") break; + } + yield* Fiber.join(skillTurn); + yield* instance.adapter.stopSession(threadId); yield* instance.adapter.startSession({ threadId, cwd: directory, runtimeMode: "approval-required", resumeCursor: session.resumeCursor, + modelSelection: { + instanceId, + model: OH_MY_PI_DEFAULT_MODEL, + options: [ + { id: "computerUse", value: true }, + { id: "prewalk", value: true }, + ], + }, }); + const resumeOverlayPath = path.join(instanceDir, "config", "advisor-off.computer-on.yml"); + expect((yield* fs.readFileString(argvPath)).trim().split("\n")[2]).toBe( + `acp\t--approval-mode\talways-ask\t--prewalk\t--config\t${resumeOverlayPath}`, + ); + expect(yield* fs.readFileString(resumeOverlayPath)).toBe( + "advisor:\n enabled: false\ncomputer:\n enabled: true\n", + ); const interruptedTurn = yield* instance.adapter .sendTurn({ threadId, input: "wait for approval", attachments: [] }) .pipe(Effect.forkChild); @@ -827,6 +1094,19 @@ it.layer(testLayer)("OhMyPi driver", (it) => { expect(requests).toContain('"methodId":"agent"'); expect(requests).toContain('"method":"session/load"'); expect(requests).not.toContain('"value":"oh-my-pi-default"'); + const promptTexts = requests + .split("\n") + .filter((line) => line.includes('"method":"session/prompt"')) + .map((line) => + ( + JSON.parse(line) as { + params: { prompt: Array<{ type: string; text?: string }> }; + } + ).params.prompt.flatMap((block) => (block.type === "text" ? [block.text] : [])), + ); + expect(promptTexts[0]?.[0]).toBe("hello"); + expect(promptTexts[0]?.[1]).toContain(""); + expect(promptTexts[1]).toEqual(["please /skill:grill-me now"]); expect(yield* fs.readFileString(argvPath)).toContain("acp\t--approval-mode\talways-ask"); yield* instance.adapter.stopAll(); expect(yield* instance.adapter.listSessions()).toEqual([]); diff --git a/apps/server/src/provider/Drivers/OhMyPiDriver.ts b/apps/server/src/provider/Drivers/OhMyPiDriver.ts index fc810e196a1a..83e07d1c3c4d 100644 --- a/apps/server/src/provider/Drivers/OhMyPiDriver.ts +++ b/apps/server/src/provider/Drivers/OhMyPiDriver.ts @@ -18,7 +18,12 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; +import { probeOhMyPiWorkspaceCommands } from "../acp/OhMyPiAcpSupport.ts"; import { makeOhMyPiAdapter } from "../Layers/OhMyPiAdapter.ts"; +import { + OH_MY_PI_SESSION_OPTION_DESCRIPTORS, + ohMyPiInstanceStateDir, +} from "../OhMyPiSessionOptions.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { @@ -34,11 +39,16 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { probeOhMyPiModels } from "./OhMyPiModels.ts"; +import { splitOhMyPiAvailableCommands } from "./OhMyPiSkillDispatch.ts"; import { withInstanceIdentity } from "./instanceIdentity.ts"; const DRIVER = ProviderDriverKind.make("ohMyPi"); const decodeSettings = Schema.decodeSync(OhMyPiSettings); -const capabilities = createModelCapabilities({ optionDescriptors: [] }); +const capabilities = createModelCapabilities({ + optionDescriptors: OH_MY_PI_SESSION_OPTION_DESCRIPTORS, +}); +/** Live sessions and probes both report per workspace; keep a bounded set. */ +const MAX_WORKSPACE_SNAPSHOTS = 32; export type OhMyPiDriverEnv = | BackgroundPolicy.BackgroundPolicy @@ -60,6 +70,13 @@ export const OhMyPiDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* ServerConfig; const eventLoggers = yield* ProviderEventLoggers; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const probeSessionsDir = path.join( + ohMyPiInstanceStateDir(path, serverConfig.stateDir, instanceId), + "probe-sessions", + ); const effectiveConfig = { ...config, enabled }; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -102,6 +119,70 @@ export const OhMyPiDriver: ProviderDriver = { } satisfies ServerProviderDraft; const metadata = yield* SubscriptionRef.make(initial); const getSnapshot = SubscriptionRef.get(metadata).pipe(Effect.map(stampIdentity)); + const findWorkspace = (cwd: string) => + SubscriptionRef.get(metadata).pipe( + Effect.map((draft) => + draft.workspaceSnapshots?.find((workspace) => workspace.cwd === cwd), + ), + ); + // omp's command list is the one source for a workspace's skills and slash + // commands, whether a live session or the probe reported it. + const recordWorkspaceCommands = ( + source: "live" | "probe", + cwd: string, + commands: ReadonlyArray<{ + readonly name: string; + readonly description?: string | null; + readonly input?: { readonly hint: string } | null; + }>, + ) => + SubscriptionRef.update(metadata, (draft) => { + const recorded = draft.workspaceSnapshots ?? []; + // A probe only runs for a workspace with no entry, so an entry that + // appeared since came from a live session: the same list, read later, + // by the process the user is talking to. Reading the entry rather than + // remembering the cwd keeps this in step with eviction, so a workspace + // that ages out can be probed again. + if (source === "probe" && recorded.some((workspace) => workspace.cwd === cwd)) { + return draft; + } + return { + ...draft, + workspaceSnapshots: [ + ...recorded.filter((workspace) => workspace.cwd !== cwd), + { cwd, checkedAt: draft.checkedAt, ...splitOhMyPiAvailableCommands(commands) }, + ].slice(-MAX_WORKSPACE_SNAPSHOTS), + }; + }); + // A throwaway ACP session in a T3-owned session directory; see ADR 0006. + const probeWorkspace = (cwd: string) => + Effect.gen(function* () { + yield* fileSystem.makeDirectory(probeSessionsDir, { recursive: true }); + const sessionDir = yield* fileSystem.makeTempDirectoryScoped({ + directory: probeSessionsDir, + prefix: "session-", + }); + const commands = yield* probeOhMyPiWorkspaceCommands({ + childProcessSpawner: spawner, + ohMyPiSettings: effectiveConfig, + environment: processEnv, + cwd, + sessionDir, + }); + yield* recordWorkspaceCommands("probe", cwd, commands); + }).pipe( + Effect.scoped, + Effect.provideService(Crypto.Crypto, crypto), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: `Could not read OhMyPi commands for '${cwd}'.`, + cause, + }), + ), + ); const checkProvider = Effect.gen(function* () { if (!enabled) return yield* getSnapshot; const result = yield* probeOhMyPiModels(effectiveConfig, processEnv, serverConfig.cwd).pipe( @@ -169,25 +250,7 @@ export const OhMyPiDriver: ProviderDriver = { instanceId, environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), - onAvailableCommands: (commands, cwd) => - SubscriptionRef.update(metadata, (draft) => ({ - ...draft, - workspaceSnapshots: [ - ...(draft.workspaceSnapshots ?? []).filter((workspace) => workspace.cwd !== cwd), - { - cwd, - checkedAt: draft.checkedAt, - skills: [], - slashCommands: commands - .filter((command) => command.name.trim()) - .map((command) => ({ - name: command.name, - description: command.description, - ...(command.input ? { input: command.input } : {}), - })), - }, - ].slice(-32), - })), + onAvailableCommands: (commands, cwd) => recordWorkspaceCommands("live", cwd, commands), }); const unsupported = (operation: string) => Effect.fail( @@ -206,14 +269,18 @@ export const OhMyPiDriver: ProviderDriver = { enabled, snapshot: { ...snapshot, getSnapshot }, snapshotForCwd: (cwd) => - getSnapshot.pipe( - Effect.map((snapshot) => ({ + Effect.gen(function* () { + if (enabled && (yield* findWorkspace(cwd)) === undefined) { + yield* probeWorkspace(cwd); + } + const snapshot = yield* getSnapshot; + const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd); + return { ...snapshot, - slashCommands: - snapshot.workspaceSnapshots?.find((workspace) => workspace.cwd === cwd) - ?.slashCommands ?? [], - })), - ), + slashCommands: workspace?.slashCommands ?? [], + skills: workspace?.skills ?? [], + }; + }), adapter, textGeneration: { generateCommitMessage: () => unsupported("generateCommitMessage"), diff --git a/apps/server/src/provider/Drivers/OhMyPiModels.ts b/apps/server/src/provider/Drivers/OhMyPiModels.ts index f5adedcd25ce..51480d5d3bd8 100644 --- a/apps/server/src/provider/Drivers/OhMyPiModels.ts +++ b/apps/server/src/provider/Drivers/OhMyPiModels.ts @@ -4,6 +4,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { ChildProcess } from "effect/unstable/process"; +import { OH_MY_PI_SESSION_OPTION_DESCRIPTORS } from "../OhMyPiSessionOptions.ts"; import { parseGenericCliVersion, spawnAndCollect } from "../providerSnapshot.ts"; const ModelsOutput = Schema.Struct({ @@ -72,20 +73,22 @@ export const probeOhMyPiModels = Effect.fn("probeOhMyPiModels")(function* ( subProvider: model.provider, isCustom: false, capabilities: createModelCapabilities({ - optionDescriptors: - thinking.length > 0 + optionDescriptors: [ + ...(thinking.length > 0 ? [ { id: "thinking", label: "Thinking", - type: "select", + type: "select" as const, options: [...new Set(["off", "auto", ...thinking])].map((id) => ({ id, label: id === "off" ? "Off" : id === "auto" ? "Auto" : id, })), }, ] - : [], + : []), + ...OH_MY_PI_SESSION_OPTION_DESCRIPTORS, + ], }), }, ]; diff --git a/apps/server/src/provider/Drivers/OhMyPiSkillDispatch.test.ts b/apps/server/src/provider/Drivers/OhMyPiSkillDispatch.test.ts new file mode 100644 index 000000000000..c8ab1987a9f3 --- /dev/null +++ b/apps/server/src/provider/Drivers/OhMyPiSkillDispatch.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + ohMyPiWorkspaceCatalog, + prepareOhMyPiPrompt, + splitOhMyPiAvailableCommands, +} from "./OhMyPiSkillDispatch.ts"; + +const catalog = ohMyPiWorkspaceCatalog({ + slashCommands: [{ name: "compact" }, { name: "computer", input: { hint: "[on|off|status]" } }], + skills: [ + { name: "grill-me", path: "skill://grill-me", enabled: true }, + { name: "retired", path: "skill://retired", enabled: false }, + ], +}); + +describe("prepareOhMyPiPrompt", () => { + it("rewrites known skill mentions to omp's invocation and sends them alone", () => { + expect(prepareOhMyPiPrompt("please $grill-me the plan", catalog)).toEqual({ + text: "please /skill:grill-me the plan", + consumedByCommand: true, + }); + expect(prepareOhMyPiPrompt("$grill-me", catalog)).toEqual({ + text: "/skill:grill-me", + consumedByCommand: true, + }); + }); + + it("leaves unknown, disabled, and money-looking mentions as prose", () => { + expect(prepareOhMyPiPrompt("$HOME is set, $retired too, costs $5k", catalog)).toEqual({ + text: "$HOME is set, $retired too, costs $5k", + consumedByCommand: false, + }); + }); + + it("leaves attached context verbatim and never dispatches from it", () => { + const envelope = + '\n\n\n$grill-me\n'; + expect(prepareOhMyPiPrompt(`fix this${envelope}`, catalog)).toEqual({ + text: `fix this${envelope}`, + consumedByCommand: false, + }); + expect(prepareOhMyPiPrompt(`$grill-me${envelope}`, catalog)).toEqual({ + text: `/skill:grill-me${envelope}`, + consumedByCommand: true, + }); + // A builtin never reaches the model, so attached context would only break its arguments. + expect( + prepareOhMyPiPrompt(`/computer status [Terminal: log; ref=ctx_1]${envelope}`, catalog), + ).toEqual({ + text: "/computer status", + consumedByCommand: true, + }); + }); + + it("follows omp's prefix rules for inline skill tokens", () => { + expect(prepareOhMyPiPrompt("/tmp/x is broken, $grill-me", catalog).consumedByCommand).toBe( + false, + ); + expect(prepareOhMyPiPrompt("!ls then $grill-me", catalog).consumedByCommand).toBe(false); + expect(prepareOhMyPiPrompt("/skill:unknown args", catalog).consumedByCommand).toBe(false); + }); + + it("recognises advertised commands by their opening name", () => { + expect(prepareOhMyPiPrompt("/computer status", catalog).consumedByCommand).toBe(true); + expect(prepareOhMyPiPrompt(" /compact focus on tests", catalog).consumedByCommand).toBe(true); + expect(prepareOhMyPiPrompt("/compact:soft", catalog).consumedByCommand).toBe(true); + expect(prepareOhMyPiPrompt("/vibe", catalog).consumedByCommand).toBe(false); + expect(prepareOhMyPiPrompt("use /compact later", catalog).consumedByCommand).toBe(false); + }); +}); + +describe("splitOhMyPiAvailableCommands", () => { + it("turns skill entries into skills and keeps the rest as slash commands", () => { + expect( + splitOhMyPiAvailableCommands([ + { name: "compact", description: "Compact the conversation", input: { hint: "[focus]" } }, + { + name: "skill:grill-me", + description: "Interview relentlessly", + input: { hint: "arguments" }, + }, + { name: "skill:grill-me", description: "duplicate" }, + { name: "skill:", description: "nameless" }, + { name: " ", description: "blank" }, + { name: "trace", description: "" }, + ]), + ).toEqual({ + slashCommands: [ + { name: "compact", description: "Compact the conversation", input: { hint: "[focus]" } }, + { name: "trace" }, + ], + skills: [ + { + name: "grill-me", + description: "Interview relentlessly", + path: "skill://grill-me", + enabled: true, + }, + ], + }); + }); +}); diff --git a/apps/server/src/provider/Drivers/OhMyPiSkillDispatch.ts b/apps/server/src/provider/Drivers/OhMyPiSkillDispatch.ts new file mode 100644 index 000000000000..9dbd8965ae69 --- /dev/null +++ b/apps/server/src/provider/Drivers/OhMyPiSkillDispatch.ts @@ -0,0 +1,152 @@ +/** + * OhMyPiSkillDispatch — turns a composer prompt into what omp runs over ACP. + * + * omp invokes a skill as `/skill:`. The token is honored when it opens + * the prompt, or inline when the prompt does not open with another `/`, `!`, + * or `$` prefix; the text on either side becomes the skill's arguments. The + * composer inserts `$name` for every provider, so known mentions are rewritten + * in place, like the Cursor rewrite. + * + * omp joins ACP text blocks with a blank line before parsing. A prompt that a + * builtin command or a skill consumes must therefore travel alone: the runtime + * instructions block would be folded into the command's arguments, and strict + * commands such as `/computer status` then print their usage line instead of + * running. Verified against omp 18.2.7. + * + * @module provider/Drivers/OhMyPiSkillDispatch + */ +import type { + ServerProviderSkill, + ServerProviderSlashCommand, + ServerProviderWorkspaceSnapshot, +} from "@t3tools/contracts"; +import { + splitComposerContextEnvelope, + stripComposerContextMarkers, +} from "@t3tools/shared/composerContextReferences"; + +/** + * Same token shape the Claude and Cursor skill dispatchers use, so a `$name` + * one provider runs, another runs too. It is one character looser than the + * composer chip pattern (`packages/shared/src/composerInlineTokens.ts`), which + * also needs trailing whitespace: a mention that ends the prompt still + * dispatches, matching the CLIs' own parsing. + */ +const SKILL_MENTION_PATTERN = + /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; +/** omp's inline skill token: the first match decides, and its name may not contain `/`. */ +const SKILL_TOKEN_PATTERN = /(^|\s)\/skill:([^\s/]+)(?=\s|$)/u; +const SKILL_COMMAND_PREFIX = "skill:"; + +export interface OhMyPiWorkspaceCatalog { + /** Names omp advertised as commands, without the `skill:` entries. */ + readonly commandNames: ReadonlySet; + readonly skillNames: ReadonlySet; +} + +export interface OhMyPiPreparedPrompt { + /** + * What omp receives. A skill keeps the attached context, which reaches the + * model as the skill's arguments; a builtin command never reaches the model, + * so it travels bare or its arguments would not parse. + */ + readonly text: string; + /** omp itself consumes the prompt (a command or a skill), so nothing else may share it. */ + readonly consumedByCommand: boolean; +} + +export function ohMyPiWorkspaceCatalog( + workspace: Pick | undefined, +): OhMyPiWorkspaceCatalog { + return { + commandNames: new Set(workspace?.slashCommands.map((command) => command.name) ?? []), + skillNames: new Set( + workspace?.skills.filter((skill) => skill.enabled).map((skill) => skill.name) ?? [], + ), + }; +} + +export function prepareOhMyPiPrompt( + prompt: string, + catalog: OhMyPiWorkspaceCatalog, +): OhMyPiPreparedPrompt { + // Only the user's own text carries mentions; attached context is data. + const { body, envelope } = splitComposerContextEnvelope(prompt); + const text = body.replace(SKILL_MENTION_PATTERN, (match, prefix: string, name: string) => + catalog.skillNames.has(name) ? `${prefix}/${SKILL_COMMAND_PREFIX}${name}` : match, + ); + const skill = invokesSkill(text, catalog.skillNames); + const command = !skill && opensWithCommand(text, catalog.commandNames); + return { + text: command ? stripComposerContextMarkers(text) : `${text}${envelope}`, + consumedByCommand: skill || command, + }; +} + +/** Mirrors omp's `parseSkillInvocation`: an unknown name falls through to the model as text. */ +function invokesSkill(text: string, skillNames: ReadonlySet): boolean { + const trimmed = text.trimStart(); + if (trimmed.startsWith(`/${SKILL_COMMAND_PREFIX}`)) { + const end = trimmed.search(/\s/); + const name = trimmed.slice(1 + SKILL_COMMAND_PREFIX.length, end === -1 ? undefined : end); + return name.length > 0 && skillNames.has(name); + } + if (trimmed.startsWith("/") || trimmed.startsWith("!") || trimmed.startsWith("$")) { + return false; + } + const match = SKILL_TOKEN_PATTERN.exec(text); + return match !== null && skillNames.has(match[2] ?? ""); +} + +/** omp ends a command name at the first whitespace or `:`. */ +function opensWithCommand(text: string, commandNames: ReadonlySet): boolean { + const trimmed = text.trimStart(); + if (!trimmed.startsWith("/")) return false; + const name = trimmed.slice(1).split(/[\s:]/u, 1)[0] ?? ""; + return name.length > 0 && commandNames.has(name); +} + +/** + * omp advertises each skill as a `skill:` command. Those become `$` + * skills and leave the slash menu; everything else stays a slash command. A + * skill's only identifier is omp's own `skill://` scheme: the clients pick a + * source badge from it and do not offer to open it as a file. + */ +export function splitOhMyPiAvailableCommands( + commands: ReadonlyArray<{ + readonly name: string; + readonly description?: string | null; + readonly input?: { readonly hint: string } | null; + }>, +): { + readonly slashCommands: ReadonlyArray; + readonly skills: ReadonlyArray; +} { + const slashCommands: ServerProviderSlashCommand[] = []; + const skills: ServerProviderSkill[] = []; + const seenSkills = new Set(); + for (const command of commands) { + const name = command.name.trim(); + if (!name) continue; + const description = command.description?.trim(); + if (name.startsWith(SKILL_COMMAND_PREFIX)) { + const skillName = name.slice(SKILL_COMMAND_PREFIX.length); + if (!skillName || seenSkills.has(skillName)) continue; + seenSkills.add(skillName); + skills.push({ + name: skillName, + ...(description ? { description } : {}), + path: `skill://${skillName}`, + enabled: true, + }); + continue; + } + const hint = command.input?.hint.trim(); + slashCommands.push({ + name, + ...(description ? { description } : {}), + ...(hint ? { input: { hint } } : {}), + }); + } + return { slashCommands, skills }; +} diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 2bbe0a6b05d3..1588194a9b83 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -5572,6 +5572,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", + // Claude Code applies effort, fast mode, context window, and thinking + // when its query starts, so a change restarts on the session id. + sessionRestartOptionIds: ["effort", "fastMode", "contextWindow", "thinking"], }, compaction: { type: "slash-command", command: "/compact" }, startSession, diff --git a/apps/server/src/provider/Layers/OhMyPiAdapter.ts b/apps/server/src/provider/Layers/OhMyPiAdapter.ts index 1cce77ad3b3d..976fb3079e3d 100644 --- a/apps/server/src/provider/Layers/OhMyPiAdapter.ts +++ b/apps/server/src/provider/Layers/OhMyPiAdapter.ts @@ -24,6 +24,7 @@ import { import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -41,8 +42,22 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { writeFileStringAtomically } from "../../atomicWrite.ts"; import { ServerConfig } from "../../config.ts"; +import { + OH_MY_PI_SESSION_OPTION_IDS, + ohMyPiConfigOverlay, + ohMyPiInstanceStateDir, + ohMyPiLaunchArgs, + resolveOhMyPiSessionToggles, +} from "../OhMyPiSessionOptions.ts"; import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; +import { + type OhMyPiWorkspaceCatalog, + ohMyPiWorkspaceCatalog, + prepareOhMyPiPrompt, + splitOhMyPiAvailableCommands, +} from "../Drivers/OhMyPiSkillDispatch.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, @@ -75,6 +90,14 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonStri const PROVIDER = ProviderDriverKind.make("ohMyPi"); const OH_MY_PI_RESUME_VERSION = 1 as const; +/** + * omp reports its command list about fifty milliseconds after session setup, + * on new and resumed sessions alike; past this bound a prompt goes out + * dispatching nothing, reaching the model as the text the user typed. + */ +const OH_MY_PI_COMMANDS_WAIT = Duration.seconds(5); +/** What a session that never reported dispatches from: nothing. */ +const OH_MY_PI_EMPTY_CATALOG = ohMyPiWorkspaceCatalog(undefined); function encodeJsonStringForDiagnostics(input: unknown): string | undefined { const result = encodeUnknownJsonStringExit(input); return Exit.isSuccess(result) ? result.value : undefined; @@ -318,6 +341,15 @@ interface OhMyPiSessionContext { promptsInFlight: number; interruptionVersion: number; stopped: boolean; + /** Settled once this session reported its own command list. */ + readonly commandsReported: Deferred.Deferred; + /** + * What this session's own omp advertised. The workspace snapshot is a menu + * cache keyed by cwd, written by whichever probe or session reported last; a + * skill added or a plugin installed since then makes it disagree. Dispatch + * from the process that will actually receive the prompt. + */ + catalog: OhMyPiWorkspaceCatalog | undefined; } function settlePendingApprovalsAsCancelled( @@ -414,6 +446,10 @@ export function makeOhMyPiAdapter( const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); + const launchConfigDir = path.join( + ohMyPiInstanceStateDir(path, serverConfig.stateDir, boundInstanceId), + "config", + ); const crypto = yield* Crypto.Crypto; const nativeEventLogger = options?.nativeEventLogger ?? @@ -778,6 +814,26 @@ export function makeOhMyPiAdapter( }); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + // The toggles are stated at every launch, resume included; see OhMyPiSessionOptions. + const toggles = resolveOhMyPiSessionToggles(ohMyPiModelSelection?.options); + const overlay = ohMyPiConfigOverlay(toggles); + const overlayPath = path.join(launchConfigDir, overlay.fileName); + yield* writeFileStringAtomically({ + filePath: overlayPath, + contents: overlay.contents, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: `Could not write the OhMyPi launch config at '${overlayPath}'.`, + cause, + }), + ), + ); const acp = yield* makeOhMyPiAcpRuntime({ ohMyPiSettings, environment: { @@ -790,6 +846,7 @@ export function makeOhMyPiAdapter( childProcessSpawner, cwd, runtimeMode: input.runtimeMode, + launchArgs: ohMyPiLaunchArgs({ toggles, overlayPath }), observeToolCallUpdate: (toolCall) => mapExtensionFailure( Effect.suspend(() => { @@ -960,6 +1017,8 @@ export function makeOhMyPiAdapter( promptsInFlight: 0, interruptionVersion: 0, stopped: false, + commandsReported: yield* Deferred.make(), + catalog: undefined, }; for (const observed of pendingObservedToolCalls.splice(0)) { yield* emitOhMyPiChildTaskEvents(ctx, observed); @@ -975,9 +1034,13 @@ export function makeOhMyPiAdapter( case "ConfigOptionsUpdated": return; case "AvailableCommandsUpdated": + ctx.catalog = ohMyPiWorkspaceCatalog( + splitOhMyPiAvailableCommands(event.availableCommands), + ); yield* ( options?.onAvailableCommands?.(event.availableCommands, cwd) ?? Effect.void ); + yield* Deferred.succeed(ctx.commandsReported, undefined); return; case "ConnectionTerminated": ctx.session = { ...ctx.session, status: "error", updatedAt: yield* nowIso }; @@ -1190,9 +1253,24 @@ export function makeOhMyPiAdapter( } const promptParts: Array = []; - const rawPrompt = input.input?.trim() ?? ""; - if (rawPrompt) { - promptParts.push({ type: "text", text: rawPrompt }); + // The first prompt waits for this session's own command list, since + // dispatch reads it and omp reports it shortly after setup. A + // process that never reports settles the wait on its first timeout, + // so later prompts do not pay it again and dispatch nothing. + yield* Deferred.await(ctx.commandsReported).pipe( + Effect.timeoutOption(OH_MY_PI_COMMANDS_WAIT), + Effect.flatMap((reported) => + Option.isSome(reported) + ? Effect.void + : Deferred.succeed(ctx.commandsReported, undefined), + ), + ); + const prompt = prepareOhMyPiPrompt( + input.input?.trim() ?? "", + ctx.catalog ?? OH_MY_PI_EMPTY_CATALOG, + ); + if (prompt.text) { + promptParts.push({ type: "text", text: prompt.text }); } if (input.attachments && input.attachments.length > 0) { for (const attachment of input.attachments) { @@ -1248,23 +1326,27 @@ export function makeOhMyPiAdapter( ); } - // ACP has no system-message field; keep runtime context separate from the user's text. + // ACP has no system-message field; keep runtime context separate from the + // user's text. A prompt omp consumes itself travels alone, or the block + // would become the command's arguments; the next ordinary turn carries it. const result = interruptionVersion !== ctx.interruptionVersion ? { stopReason: "cancelled" as const } : yield* ctx.acp .prompt( { - prompt: [ - ...promptParts, - { - type: "text", - text: buildRuntimeInstructions({ - harness: "OhMyPi", - model: resolvedModel, - }), - }, - ], + prompt: prompt.consumedByCommand + ? promptParts + : [ + ...promptParts, + { + type: "text", + text: buildRuntimeInstructions({ + harness: "OhMyPi", + model: resolvedModel, + }), + }, + ], }, { dispatched }, ) @@ -1417,7 +1499,11 @@ export function makeOhMyPiAdapter( return { provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, + capabilities: { + sessionModelSwitch: "in-session", + supportsConversationRollback: false, + sessionRestartOptionIds: OH_MY_PI_SESSION_OPTION_IDS, + }, compaction: { type: "slash-command", command: "/compact" }, startSession, sendTurn, diff --git a/apps/server/src/provider/OhMyPiSessionOptions.test.ts b/apps/server/src/provider/OhMyPiSessionOptions.test.ts new file mode 100644 index 000000000000..c0cc987382c9 --- /dev/null +++ b/apps/server/src/provider/OhMyPiSessionOptions.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + OH_MY_PI_SESSION_OPTION_DESCRIPTORS, + OH_MY_PI_SESSION_OPTION_IDS, + ohMyPiConfigOverlay, + ohMyPiLaunchArgs, + resolveOhMyPiSessionToggles, +} from "./OhMyPiSessionOptions.ts"; + +describe("OhMyPi session options", () => { + it("treats absent and false selections as off", () => { + expect(resolveOhMyPiSessionToggles(undefined)).toEqual({ + advisor: false, + computerUse: false, + prewalk: false, + }); + expect( + resolveOhMyPiSessionToggles([ + { id: "advisor", value: false }, + { id: "computerUse", value: true }, + { id: "thinking", value: "high" }, + ]), + ).toEqual({ advisor: false, computerUse: true, prewalk: false }); + }); + + it("states every toggle explicitly at launch", () => { + const off = resolveOhMyPiSessionToggles([]); + expect(ohMyPiConfigOverlay(off)).toEqual({ + fileName: "advisor-off.computer-off.yml", + contents: "advisor:\n enabled: false\ncomputer:\n enabled: false\n", + }); + expect(ohMyPiLaunchArgs({ toggles: off, overlayPath: "/tmp/off.yml" })).toEqual([ + "--no-prewalk", + "--config", + "/tmp/off.yml", + ]); + + const on = resolveOhMyPiSessionToggles([ + { id: "advisor", value: true }, + { id: "computerUse", value: true }, + { id: "prewalk", value: true }, + ]); + expect(ohMyPiConfigOverlay(on)).toEqual({ + fileName: "advisor-on.computer-on.yml", + contents: "advisor:\n enabled: true\ncomputer:\n enabled: true\n", + }); + expect(ohMyPiLaunchArgs({ toggles: on, overlayPath: "/tmp/on.yml" })).toEqual([ + "--prewalk", + "--config", + "/tmp/on.yml", + ]); + }); + + it("lists exactly the launch-time descriptors as restart options", () => { + expect(OH_MY_PI_SESSION_OPTION_IDS).toEqual(["advisor", "computerUse", "prewalk"]); + expect(OH_MY_PI_SESSION_OPTION_DESCRIPTORS.every((d) => d.type === "boolean")).toBe(true); + }); +}); diff --git a/apps/server/src/provider/OhMyPiSessionOptions.ts b/apps/server/src/provider/OhMyPiSessionOptions.ts new file mode 100644 index 000000000000..5d5fb70d0a65 --- /dev/null +++ b/apps/server/src/provider/OhMyPiSessionOptions.ts @@ -0,0 +1,87 @@ +/** + * OhMyPiSessionOptions — the omp session behaviors T3 Code owns as provider + * options: the advisor, computer use, and prewalk. + * + * omp's CLI users switch these on with slash commands before their first + * message. Over ACP those commands only change the running process: the + * session file never records them, and `session/load` in a fresh process comes + * back with all three off. T3 stops idle sessions and resumes them by id, so + * the desired state is stated again at every launch instead, and every launch + * states all three explicitly so omp's global config never decides. + * + * Prewalk travels as `--prewalk` / `--no-prewalk`: omp ignores its config key + * while restoring a session, but honors the flags. Advisor and computer use + * have no off flag, so they ride a `--config` overlay that omp deep-merges over + * its global and project config. Verified against omp 18.2.7; see ADR 0005. + * + * @module provider/OhMyPiSessionOptions + */ +import type { ProviderOptionDescriptor, ProviderOptionSelection } from "@t3tools/contracts"; +import { getProviderOptionBooleanSelectionValue } from "@t3tools/shared/model"; + +export const OH_MY_PI_SESSION_OPTION_DESCRIPTORS: ReadonlyArray = [ + { id: "advisor", label: "Advisor", type: "boolean" }, + { id: "computerUse", label: "Computer use", type: "boolean" }, + { id: "prewalk", label: "Prewalk", type: "boolean" }, +]; + +/** Option ids that only apply when the omp process starts, for the adapter's restart capability. */ +export const OH_MY_PI_SESSION_OPTION_IDS: ReadonlyArray = + OH_MY_PI_SESSION_OPTION_DESCRIPTORS.map((descriptor) => descriptor.id); + +export interface OhMyPiSessionToggles { + readonly advisor: boolean; + readonly computerUse: boolean; + readonly prewalk: boolean; +} + +/** An absent selection is off: composers omit descriptor defaults from dispatch. */ +export function resolveOhMyPiSessionToggles( + selections: ReadonlyArray | null | undefined, +): OhMyPiSessionToggles { + return { + advisor: getProviderOptionBooleanSelectionValue(selections, "advisor") === true, + computerUse: getProviderOptionBooleanSelectionValue(selections, "computerUse") === true, + prewalk: getProviderOptionBooleanSelectionValue(selections, "prewalk") === true, + }; +} + +/** + * The overlay omp reads through `--config`, carrying the two toggles that have + * no off flag; prewalk travels as a flag. Keys are nested, not dotted: omp + * splits its own dotted setting paths when it reads the merged tree. The file + * name encodes the two values, so concurrent sessions that differ on either + * never share one file. + */ +export function ohMyPiConfigOverlay(toggles: OhMyPiSessionToggles): { + readonly fileName: string; + readonly contents: string; +} { + const state = (enabled: boolean) => (enabled ? "on" : "off"); + return { + fileName: `advisor-${state(toggles.advisor)}.computer-${state(toggles.computerUse)}.yml`, + contents: [ + "advisor:", + ` enabled: ${toggles.advisor}`, + "computer:", + ` enabled: ${toggles.computerUse}`, + "", + ].join("\n"), + }; +} + +export function ohMyPiLaunchArgs(input: { + readonly toggles: OhMyPiSessionToggles; + readonly overlayPath: string; +}): ReadonlyArray { + return [input.toggles.prewalk ? "--prewalk" : "--no-prewalk", "--config", input.overlayPath]; +} + +/** Per-instance runtime state under T3's userdata: launch overlays and probe session files. */ +export function ohMyPiInstanceStateDir( + path: { readonly join: (...segments: ReadonlyArray) => string }, + stateDir: string, + instanceId: string, +): string { + return path.join(stateDir, "ohmypi", instanceId); +} diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index c9b62fd79525..dec0c7c187fb 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -52,6 +52,12 @@ export interface ProviderAdapterCapabilities { readonly promptlessTurnContinuation?: boolean; /** False when native conversation history cannot be rewound. */ readonly supportsConversationRollback?: boolean; + /** + * Option ids the provider applies only when its process starts. A turn that + * changes one of them restarts the session on its resume cursor; every other + * option is applied in-session by `sendTurn`. + */ + readonly sessionRestartOptionIds?: ReadonlyArray; } export interface ProviderThreadTurnSnapshot { diff --git a/apps/server/src/provider/acp/OhMyPiAcpSupport.ts b/apps/server/src/provider/acp/OhMyPiAcpSupport.ts index f47f2421a436..8aff787ac559 100644 --- a/apps/server/src/provider/acp/OhMyPiAcpSupport.ts +++ b/apps/server/src/provider/acp/OhMyPiAcpSupport.ts @@ -5,13 +5,22 @@ import { type ProviderOptionSelection, type RuntimeMode, } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import type * as AcpErrors from "effect-acp/errors"; import type * as AcpSchema from "effect-acp/schema"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +const OH_MY_PI_CLIENT_INFO = { name: "t3-code", version: "0.0.0" } as const; +/** omp sends `available_commands_update` about fifty milliseconds after `session/new`. */ +const OH_MY_PI_WORKSPACE_PROBE_TIMEOUT = Duration.seconds(20); +/** The probe session is disposable, so closing it politely is worth only a moment. */ +const OH_MY_PI_WORKSPACE_PROBE_CLOSE_TIMEOUT = Duration.seconds(2); + interface OhMyPiAcpRuntimeInput extends Omit< AcpSessionRuntime.AcpSessionRuntimeOptions, "authMethodId" | "clientCapabilities" | "spawn" @@ -20,6 +29,8 @@ interface OhMyPiAcpRuntimeInput extends Omit< readonly ohMyPiSettings: Pick; readonly environment?: NodeJS.ProcessEnv; readonly runtimeMode?: RuntimeMode; + /** Launch flags appended after the approval mode; see `ohMyPiLaunchArgs`. */ + readonly launchArgs?: ReadonlyArray; } export const makeOhMyPiAcpRuntime = Effect.fn("makeOhMyPiAcpRuntime")(function* ( @@ -33,6 +44,7 @@ export const makeOhMyPiAcpRuntime = Effect.fn("makeOhMyPiAcpRuntime")(function* args: [ "acp", ...(input.runtimeMode ? ["--approval-mode", ohMyPiApprovalMode(input.runtimeMode)] : []), + ...(input.launchArgs ?? []), ], cwd: input.cwd, ...(input.environment ? { env: input.environment } : {}), @@ -49,6 +61,63 @@ export const makeOhMyPiAcpRuntime = Effect.fn("makeOhMyPiAcpRuntime")(function* return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(Effect.provide(context)); }); +/** + * Start a throwaway ACP session to read omp's own command list for a cwd: its + * skills, advertised as `skill:`, and every other command. omp has no + * non-interactive listing, and its discovery walks a dozen gated directory + * families, so mirroring it on disk would drift. See ADR 0006. + * + * `--session-dir` keeps the probe out of the user's resume list, since + * `omp acp` ignores `--no-session`. The caller owns that directory. + */ +export const probeOhMyPiWorkspaceCommands = Effect.fn("probeOhMyPiWorkspaceCommands")( + function* (input: { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly ohMyPiSettings: Pick; + readonly environment?: NodeJS.ProcessEnv; + readonly cwd: string; + readonly sessionDir: string; + }) { + const acp = yield* makeOhMyPiAcpRuntime({ + childProcessSpawner: input.childProcessSpawner, + ohMyPiSettings: input.ohMyPiSettings, + ...(input.environment ? { environment: input.environment } : {}), + cwd: input.cwd, + launchArgs: ["--session-dir", input.sessionDir], + clientInfo: OH_MY_PI_CLIENT_INFO, + }); + const commands = yield* Deferred.make< + ReadonlyArray, + AcpErrors.AcpError + >(); + yield* Stream.runForEach(acp.getEvents(), (event) => { + switch (event._tag) { + case "EventStreamBarrier": + return Deferred.succeed(event.acknowledge, undefined); + case "AvailableCommandsUpdated": + return Deferred.succeed(commands, event.availableCommands); + case "ConnectionTerminated": + return Deferred.fail(commands, event.error); + default: + return Effect.void; + } + }).pipe(Effect.forkScoped); + // Every wait is bounded on its own: an omp that hangs during handshake, + // before reporting, or on close would otherwise keep both the probe process + // and the caller alive forever. Leaving the scope kills the process, so a + // close that does not answer promptly is abandoned rather than waited on. + const started = yield* acp.start().pipe(Effect.timeout(OH_MY_PI_WORKSPACE_PROBE_TIMEOUT)); + const available = yield* Deferred.await(commands).pipe( + Effect.timeout(OH_MY_PI_WORKSPACE_PROBE_TIMEOUT), + ); + yield* acp + .request("session/close", { sessionId: started.sessionId }) + .pipe(Effect.timeout(OH_MY_PI_WORKSPACE_PROBE_CLOSE_TIMEOUT), Effect.ignore); + return available; + }, + Effect.scoped, +); + /** Permission IDs are opaque; select by the ACP kind supplied by the agent. */ export function selectOhMyPiPermissionOption( request: AcpSchema.RequestPermissionRequest, diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index c108207304c4..30417a70c454 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -75,7 +75,10 @@ import { ComposerContextRecordsContext, } from "./composerContextPresentation"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; -import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; +import { + formatProviderSkillDisplayName, + resolveProviderSkillInstructionsPath, +} from "@t3tools/client-runtime/providerSkills"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { importPastedComposerText } from "./composerInlineTokenPaste"; import { didComposerSelectionChangeVisibly } from "./composerSelection"; @@ -278,6 +281,7 @@ function ComposerSkillNodeView({ node }: NodeViewProps) { const skillLabel = (node.attrs.skillLabel as string) || skillName; const skillDescription = (node.attrs.skillDescription as string | null) ?? null; const skill = skills.find((candidate) => candidate.name === skillName); + const skillInstructionsPath = skill ? resolveProviderSkillInstructionsPath(skill) : undefined; return ( - {skill?.path ? ( - ) : null} diff --git a/apps/web/src/components/chat/TraitsPicker.test.ts b/apps/web/src/components/chat/TraitsPicker.test.ts index dbe97cdd54e0..b696a8e34b67 100644 --- a/apps/web/src/components/chat/TraitsPicker.test.ts +++ b/apps/web/src/components/chat/TraitsPicker.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; -import { buildTraitsTriggerDisplay, buildUnavailableModelOptionDescriptors } from "./TraitsPicker"; +import { + buildTraitsTriggerDisplay, + buildUnavailableModelOptionDescriptors, + shouldRenderTraitsControls, +} from "./TraitsPicker"; function selectDescriptor( id: string, @@ -156,6 +160,32 @@ describe("buildTraitsTriggerDisplay", () => { }); }); +describe("shouldRenderTraitsControls", () => { + it("renders the control for a model whose only options are toggles", () => { + const models = [ + { + slug: "oh-my-pi-default", + name: "OhMyPi default", + isCustom: false, + isDefault: true, + capabilities: { + optionDescriptors: [{ id: "advisor", label: "Advisor", type: "boolean" as const }], + }, + }, + { slug: "bare", name: "Bare", isCustom: false, capabilities: { optionDescriptors: [] } }, + ]; + const input = { + provider: ProviderDriverKind.make("ohMyPi"), + models, + prompt: "", + modelOptions: undefined, + planModeEnabled: true, + }; + expect(shouldRenderTraitsControls({ ...input, model: "oh-my-pi-default" })).toBe(true); + expect(shouldRenderTraitsControls({ ...input, model: "bare" })).toBe(false); + }); +}); + describe("buildUnavailableModelOptionDescriptors", () => { it("shows only saved values without inventing alternatives", () => { expect( diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index da293c38ddc9..a1ad8cb6cc0e 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -247,12 +247,15 @@ function getTraitsSectionVisibility(input: { showFastMode, showContextWindow, showAgent, + // Every boolean renders as an On/Off group, so a provider whose only + // options are toggles (OhMyPi's default model) still gets the control. hasAnyControls: showEffort || showThinking || showFastMode || showContextWindow || showAgent || + selected.booleanDescriptors.length > 0 || (selected.modelIsUnavailable && selected.descriptors.length > 0), }; } diff --git a/docs/adr/0005-ohmypi-session-toggles-as-provider-options.md b/docs/adr/0005-ohmypi-session-toggles-as-provider-options.md new file mode 100644 index 000000000000..bd6958e04ab3 --- /dev/null +++ b/docs/adr/0005-ohmypi-session-toggles-as-provider-options.md @@ -0,0 +1,47 @@ +# ADR 0005: OhMyPi session toggles are provider options applied at process launch + +- Status: accepted +- Date: 2026-09-21 +- Compared with: OhMyPi 18.2.7 over ACP + +OhMyPi's computer use, advisor, and prewalk are session behaviors that its CLI +users enable with slash commands before their first message. Over ACP they are +not config options, and enabling them through `/computer on`, `/advisor on`, or +`/prewalk` only changes the running process: the omp session file never records +them, and a `session/load` in a fresh process comes back with all three off. +T3 Code stops idle provider sessions and resumes them by session id, so a state +set once by commands silently decays. + +The fork therefore treats these as provider options: per-thread choices that +T3 persists with the thread and re-applies whenever the session starts. They +appear as boolean option descriptors on every OhMyPi model, so the existing +traits picker, mobile thread settings, new-thread defaults, and project model +defaults carry them with no OhMyPi-specific UI. + +The adapter applies them at launch rather than by sending commands. `omp acp` +forwards launch flags, so prewalk travels as `--prewalk` or `--no-prewalk`; omp +ignores prewalk's config key while restoring a session but honors the flags. +Advisor and computer use have no off flag, so both ride a small YAML overlay +that T3 writes under its userdata and passes with `--config`. The overlay +deep-merges on top of omp's global and project config instead of replacing it, +and an explicit `false` there overrides a global `true`. A process launched this +way reports the toggles on for new and resumed sessions alike, and the same +session resumed without them reports them off, so the desired state is exactly +what the process was told. Every launch states all three explicitly, on or off, +so the control never depends on omp's global config and a user preference lives +in T3's new-thread and project defaults. Changing a toggle +mid-thread reuses the reactor's restart-with-resume path that already handles +permission mode and Claude model selection changes. The reactor learns which +options need a restart from an adapter capability that lists their ids, so a +thinking-level change keeps its in-session path and does not reset the +advisor's accumulated context. + +Sending the commands as hidden prompts was rejected: prewalk has no off command, +each command is a separate round trip that can fail halfway, and the adapter +would have to swallow the agent output chunks those commands emit. Vibe mode is +not exposed over ACP at all in 18.2.7 and is out of scope. + +See [OhMyPiAcpSupport.ts](../../apps/server/src/provider/acp/OhMyPiAcpSupport.ts) +for the launch arguments and +[ProviderCommandReactor.ts](../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts) +for the restart decision. diff --git a/docs/adr/0006-ohmypi-skills-and-commands-from-acp-probe.md b/docs/adr/0006-ohmypi-skills-and-commands-from-acp-probe.md new file mode 100644 index 000000000000..eeb8ea153c5d --- /dev/null +++ b/docs/adr/0006-ohmypi-skills-and-commands-from-acp-probe.md @@ -0,0 +1,31 @@ +# ADR 0006: OhMyPi skills and commands come from an ephemeral ACP probe + +- Status: accepted +- Date: 2026-09-21 +- Compared with: OhMyPi 18.2.7 over ACP + +Claude and Cursor populate the `$` skill picker by scanning skill directories +on disk. OhMyPi does not get the same treatment. Its CLI has no command that +lists skills, and its discovery walks a dozen directory families, each gated +by its own settings, plus plugin and managed-skill packages. A scan that +mirrors that would drift, and a false positive is worse than a miss: an +unknown `/skill:name` is not rejected, it reaches the model as literal text. + +Instead the driver refreshes a workspace by starting a throwaway ACP session: +spawn `omp acp --session-dir `, create a session for the +cwd, read the `available_commands_update` that omp sends about fifty +milliseconds later, then close and kill. That notification is omp's own view +of its skills, as `skill:` entries with descriptions, and of every other +command, so the `/` menu is complete before the first turn too. The skill +entries are surfaced as skills and dropped from the slash command list. A +skill's only identifier is omp's `skill://`, which the clients use just +to pick a source badge. + +The session directory override matters: `omp acp` ignores `--no-session`, and +a probe without the override leaves an empty session in the user's omp resume +list on every refresh. A live session's own `available_commands_update` keeps +replacing the probed snapshot, so a running thread never sees stale data. + +See [OhMyPiDriver.ts](../../apps/server/src/provider/Drivers/OhMyPiDriver.ts) +and, for the pattern this mirrors, the Claude capabilities probe in +[ClaudeProvider.ts](../../apps/server/src/provider/Layers/ClaudeProvider.ts). diff --git a/docs/user/install.md b/docs/user/install.md index de556535608c..27b56a3800c7 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -149,7 +149,15 @@ Refresh provider status after changing your OhMyPi credentials or model configur Choose **OhMyPi default** to use the model configured in OhMyPi, or select a model from the picker. You can switch models, stop turns, and continue a saved session after reconnecting. -OhMyPi's native slash commands appear once a session has started in that workspace. + +**Advisor**, **Computer use**, and **Prewalk** sit with the thinking level in the +composer's model options. T3 Code applies them every time it starts or resumes the +thread's session, so they stay set for the thread; a change takes effect on the next +turn. Your OhMyPi configuration does not switch them on for T3 Code threads, and vibe +mode is not available. + +OhMyPi's slash commands and skills are available in a workspace before its first turn. +Start a skill with a `$` mention, as with other providers. **Auto** uses the same approval policy as **Supervised**. **Auto-accept edits** allows workspace writes, and **Full access** allows all tool tiers. OhMyPi's diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 62d0319db1bc..7cd6063911ca 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -8,6 +8,7 @@ import { getProviderSkillsForSlashMenu, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, + resolveProviderSkillInstructionsPath, resolveProviderSkillSourceKind, } from "./providerSkills.ts"; @@ -185,6 +186,21 @@ describe("getProviderSlashCommandsForSlashMenu", () => { }); }); +describe("resolveProviderSkillInstructionsPath", () => { + it("offers filesystem paths and withholds scheme identifiers", () => { + expect( + resolveProviderSkillInstructionsPath({ path: "/home/dev/.claude/skills/x/SKILL.md" }), + ).toBe("/home/dev/.claude/skills/x/SKILL.md"); + expect(resolveProviderSkillInstructionsPath({ path: "C:\\Users\\dev\\SKILL.md" })).toBe( + "C:/Users/dev/SKILL.md", + ); + expect(resolveProviderSkillInstructionsPath({ path: "skill://grill-me" })).toBeUndefined(); + expect(resolveProviderSkillInstructionsPath({ path: "file:///tmp/SKILL.md" })).toBeUndefined(); + expect(resolveProviderSkillInstructionsPath({ path: "mailto:dev" })).toBeUndefined(); + expect(resolveProviderSkillInstructionsPath({})).toBeUndefined(); + }); +}); + describe("resolveProviderSkillSourceKind", () => { it("marks plugin-backed skills as app installs", () => { expect( diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index b80cd5803809..1758e7f80229 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -19,6 +19,22 @@ function normalizePathSeparators(pathValue: string): string { return pathValue.replaceAll("\\", "/"); } +/** + * The skill's instruction file, when the provider reported one, with the `/` + * separators the clients use for every path. OhMyPi names its skills with its + * own `skill://` scheme, which no client can open, so a composer must not + * offer to view those. A URI scheme has two or more characters before its + * colon, which keeps Windows drive letters openable. + */ +export function resolveProviderSkillInstructionsPath( + skill: Partial>, +): string | undefined { + const path = skill.path?.trim() ?? ""; + return path.length > 0 && !/^[a-z][a-z0-9+.-]+:/iu.test(path) + ? normalizePathSeparators(path) + : undefined; +} + export function formatProviderSkillDisplayName( skill: Pick, ): string { diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index 99d16b6c8569..54d5c90b2e28 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -10,6 +10,8 @@ import { projectComposerContextForProvider, replaceComposerContextReferences, sanitizeComposerContextLabel, + splitComposerContextEnvelope, + stripComposerContextMarkers, } from "./composerContextReferences.ts"; const ctx = (value: string) => value as ComposerContextId; @@ -263,3 +265,39 @@ describe("provider projection", () => { expect(projected).not.toContain("boom"); }); }); + +describe("splitComposerContextEnvelope", () => { + it("separates the user's text from the projected envelope", () => { + const projected = + 'run it\n\n\n$grill-me\n'; + expect(splitComposerContextEnvelope(projected)).toEqual({ + body: "run it", + envelope: projected.slice("run it".length), + }); + expect(splitComposerContextEnvelope("plain prose")).toEqual({ + body: "plain prose", + envelope: "", + }); + const prose = 'quote this:\n\n\nnot an envelope'; + expect(splitComposerContextEnvelope(`${prose}${projected.slice("run it".length)}`)).toEqual({ + body: prose, + envelope: projected.slice("run it".length), + }); + }); +}); + +describe("stripComposerContextMarkers", () => { + it("removes projected markers and tidies the spacing", () => { + expect( + stripComposerContextMarkers( + "/computer [Terminal: build log; ref=ctx_1] status [Pull request: #39; ref=ctx_2]", + ), + ).toBe("/computer status"); + expect(stripComposerContextMarkers("/advisor on [Foo 2: later kind; ref=ctx_3]")).toBe( + "/advisor on", + ); + expect(stripComposerContextMarkers("plain [not a marker] text")).toBe( + "plain [not a marker] text", + ); + }); +}); diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index 23993cb9d407..694eeb718463 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -287,5 +287,45 @@ export function projectComposerContextForProvider(input: { entries.push(entry); } if (entries.length === 0) return body; - return `${body}\n\n<${CONTEXT_ENVELOPE_TAG} version="1">\n${entries.join("\n")}\n`; + return `${body}${CONTEXT_ENVELOPE_OPENING}${entries.join("\n")}\n`; +} + +const CONTEXT_ENVELOPE_OPENING = `\n\n<${CONTEXT_ENVELOPE_TAG} version="1">\n`; +/** + * The in-place marker `formatComposerContextProviderMarker` writes. Kinds are + * `[a-z0-9-]` and display with spaces for dashes, so a label like `Foo 2` + * carries digits; captured labels never contain `]`. + */ +const CONTEXT_MARKER_PATTERN = /\[[A-Z][A-Za-z0-9 ]*: [^\]]*; ref=[^\]\s]+\]/gu; + +/** + * Remove the in-place markers a projection left in the user's text, for a + * provider command that never reaches the model and would otherwise receive + * them as arguments. + */ +export function stripComposerContextMarkers(text: string): string { + return text + .replace(CONTEXT_MARKER_PATTERN, "") + .replace(/[ \t]{2,}/gu, " ") + .trim(); +} + +/** + * Split a projected prompt into the user-authored text and the context + * envelope, empty when there is none. Payload escaping keeps the opening tag + * out of captured data, and the envelope is appended last, so its final + * occurrence is the envelope even when the user's own prose contains the tag. + * Adapters that rewrite the user's text, such as skill dispatch, must leave + * the envelope verbatim: a `$name` inside an attached terminal line is data, + * not a mention. + */ +export function splitComposerContextEnvelope(text: string): { + readonly body: string; + readonly envelope: string; +} { + const start = text.lastIndexOf(CONTEXT_ENVELOPE_OPENING); + if (start === -1 || !text.endsWith(``)) { + return { body: text, envelope: "" }; + } + return { body: text.slice(0, start), envelope: text.slice(start) }; }