diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 8ad497c9a79b..908ac84dd2fc 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -61,6 +61,20 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "pi") { + const foreground = isDarkMode ? "#F5F5F5" : "#0F0F0F"; + return ( + + + + + ); + } + if (props.provider === "opencode") { return ( diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 75aef9acf27c..f1c4014015a1 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -48,6 +48,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; +import { parsePiSessionCommand } from "@t3tools/client-runtime/state/threads"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; import { ShimmeringWorkContent } from "./thread-work-log"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; @@ -1010,6 +1011,15 @@ export function NewTaskDraftScreen(props: { ); return; } + if ( + selectedEnvironmentServerConfig?.providers.find( + (provider) => provider.instanceId === modelSelection.instanceId, + )?.driver === "pi" && + parsePiSessionCommand(initialMessageText) + ) { + Alert.alert("Start a Pi thread first", "Send a message before using session commands."); + return; + } // A failed-send restore can leave the draft over the cap on purpose (it // never drops the user's files); starting anyway would upload everything // and have the server reject the turn. diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index d093f54459cd..8b1b5d751140 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -129,7 +129,7 @@ export interface ThreadComposerProps { readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; - readonly onSendMessage: () => Promise; + readonly onSendMessage: (providerCommand?: string) => Promise; /** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */ readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; @@ -409,6 +409,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer // With attachments aboard the pick just inserts the text, so it sends as a prompt. onUsageLimits: usageLimitsOffered && props.draftAttachments.length === 0 ? openUsageLimits : undefined, + onProviderCommand: + props.draftAttachments.length === 0 ? (name) => void onSendMessage(name) : undefined, }); const voiceInput = useVoiceInputController({ ownerKey: composerOwnerKey, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 6ff6bb02e3a1..a216fb0b8b56 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -139,7 +139,7 @@ export interface ThreadDetailScreenProps { readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; - readonly onSendMessage: () => Promise; + readonly onSendMessage: (providerCommand?: string) => Promise; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; @@ -693,40 +693,43 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread selectedThreadKey, ]); - const handleSendMessage = useCallback(async () => { - const targetThreadKey = selectedThreadKey; - const hasUserMessage = selectedThreadFeed.some( - (entry) => entry.type === "message" && entry.message.role === "user", - ); - const messageId = await props.onSendMessage(); - if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { - return messageId; - } + const handleSendMessage = useCallback( + async (providerCommand?: string) => { + const targetThreadKey = selectedThreadKey; + const hasUserMessage = selectedThreadFeed.some( + (entry) => entry.type === "message" && entry.message.role === "user", + ); + const messageId = await props.onSendMessage(providerCommand); + if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { + return messageId; + } - // A sent message makes the snapshot stale; a refused send leaves it in place. - clearUsageLimitsFor(targetThreadKey); - - setSubmittedMessageId(messageId); - setAnchorMessageId( - resolveThreadFeedSubmissionAnchor({ - currentAnchorMessageId: anchorMessageId, - submittedMessageId: messageId, - hasStartedTurn: props.selectedThread.latestTurn !== null, - hasUserMessage, - queuedMessageCount: props.selectedThreadQueueCount, - }), - ); - composerEditorRef.current?.blur(); - return messageId; - }, [ - anchorMessageId, - clearUsageLimitsFor, - props.onSendMessage, - props.selectedThread.latestTurn, - props.selectedThreadQueueCount, - selectedThreadFeed, - selectedThreadKey, - ]); + // A sent message makes the snapshot stale; a refused send leaves it in place. + clearUsageLimitsFor(targetThreadKey); + + setSubmittedMessageId(messageId); + setAnchorMessageId( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: anchorMessageId, + submittedMessageId: messageId, + hasStartedTurn: props.selectedThread.latestTurn !== null, + hasUserMessage, + queuedMessageCount: props.selectedThreadQueueCount, + }), + ); + composerEditorRef.current?.blur(); + return messageId; + }, + [ + anchorMessageId, + clearUsageLimitsFor, + props.onSendMessage, + props.selectedThread.latestTurn, + props.selectedThreadQueueCount, + selectedThreadFeed, + selectedThreadKey, + ], + ); const collapseComposer = useCallback(() => { composerEditorRef.current?.blur(); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index f550db0754cd..f8070404b39e 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -90,6 +90,12 @@ export function buildComposerSlashCommandItems(input: { for (const command of input.selectedProviderStatus?.slashCommands ?? []) { if (!command.name.toLowerCase().includes(query)) continue; if (command.name === "compact" && !input.hasCompactableConversation) continue; + if ( + !input.hasThread && + input.selectedProviderStatus?.driver === "pi" && + ["copy", "export", "share"].includes(command.name) + ) + continue; // T3's own limits command is answered by the thread composer; New Task has // nowhere to show it. A provider's same-named command is left alone. if (command.name === USAGE_LIMITS_COMMAND.name && input.offersUsageLimits) { @@ -143,7 +149,7 @@ export function resolveComposerCommandSelection(input: { } else if (item.type === "slash-command") { replacement = `/${item.command} `; } else if (item.type === "provider-slash-command") { - replacement = `/${item.command.name} `; + replacement = `/${item.command.name}${item.command.argumentMode === "none" ? "" : " "}`; } return { ...replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, replacement), @@ -165,6 +171,7 @@ export function useComposerCommandMenu({ onChangeDraftMessage, onUpdateInteractionMode, onUsageLimits, + onProviderCommand, }: { readonly draftMessage: string; readonly ownerKey: string | null; @@ -180,6 +187,7 @@ export function useComposerCommandMenu({ readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; /** Picking /usage-limits is the action itself; the draft keeps nothing of it. */ readonly onUsageLimits?: () => void; + readonly onProviderCommand?: (name: string) => void; }) { const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage)); const previousOwnerKeyRef = useRef(ownerKey); @@ -433,6 +441,18 @@ export function useComposerCommandMenu({ return; } + if ( + item.type === "provider-slash-command" && + item.command.argumentMode === "none" && + onProviderCommand + ) { + const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); + setSelection({ start: cleared.cursor, end: cleared.cursor }); + onChangeDraftMessage(cleared.text); + onProviderCommand(item.command.name); + return; + } + const result = resolveComposerCommandSelection({ draftMessage, trigger, @@ -452,6 +472,7 @@ export function useComposerCommandMenu({ onChangeDraftMessage, onUpdateInteractionMode, onUsageLimits, + onProviderCommand, selectedProviderStatus?.showInteractionModeToggle, trigger, ], diff --git a/apps/mobile/src/lib/attachmentDownload.ts b/apps/mobile/src/lib/attachmentDownload.ts index 2ae0c729c190..990756c5efac 100644 --- a/apps/mobile/src/lib/attachmentDownload.ts +++ b/apps/mobile/src/lib/attachmentDownload.ts @@ -226,3 +226,18 @@ export async function shareLocalAttachment(input: { cached.preview.dispose(); } } + +/** Save generated text through the same cache and native handoff as downloaded files. */ +export async function shareTextFile(input: { + readonly name: string; + readonly mimeType: string; + readonly text: string; +}) { + const cached = await createCachedAttachmentFile(input); + try { + cached.file.write(input.text); + await cached.preview.share(new AbortController().signal); + } finally { + cached.preview.dispose(); + } +} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index b94810639a69..59c4323ae786 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Alert } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Alert, Linking } from "react-native"; import { CommandId, @@ -16,11 +16,19 @@ import { import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { parseCodexFeedbackCommand, + parsePiSessionCommand, submitCodexFeedback, type CodexFeedbackSubmission, } from "@t3tools/client-runtime/state/threads"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; +import { tryCopyTextWithHaptic } from "../lib/copyTextWithHaptic"; +import { shareTextFile } from "../lib/attachmentDownload"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { reportClientError } from "../lib/clientLogger"; import { isModelSelectionUnavailable } from "../lib/modelOptions"; @@ -109,6 +117,10 @@ export function useThreadComposerState() { const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); + const runThreadSessionCommand = useAtomCommand(threadEnvironment.sessionCommand, { + reportFailure: false, + }); + const sessionCommandsInFlight = useRef(new Set()); useEffect(() => { ensureComposerDraftsLoaded(); @@ -232,155 +244,219 @@ export function useThreadComposerState() { ); }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); - const onSendMessage = useCallback(async () => { - if (!selectedThreadShell) { - return null; - } + const onSendMessage = useCallback( + async (providerCommand?: string) => { + if (!selectedThreadShell) { + return null; + } - const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); - const draft = getComposerDraftSnapshot(threadKey); - const thread = selectedThreadDetail ?? selectedThreadShell; - const text = draft.text.trim(); - const attachments = draft.attachments; - if ( - composerAttachmentUploadBlockReason({ - environmentId: selectedThreadShell.environmentId, - attachments, - connected: selectedEnvironmentRuntime?.connectionState === "connected", - serverConfig: selectedEnvironmentRuntime?.serverConfig ?? null, - states: appAtomRegistry.get(composerAttachmentUploadsAtom), - }) !== null - ) - return null; - if (text.length === 0 && attachments.length === 0) { - return null; - } - // A send-failure restore appends with allowOverflow so it never drops the - // user's files, which can leave the draft over the cap. Sending it anyway - // would enqueue a message that outbox recovery rejects forever, so block - // here until the user removes attachments. - if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - Alert.alert( - "Too many attachments", - `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`, - ); - return null; - } + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + if (sessionCommandsInFlight.current.has(threadKey)) return null; + const draft = getComposerDraftSnapshot(threadKey); + const thread = selectedThreadDetail ?? selectedThreadShell; + const text = providerCommand === undefined ? draft.text.trim() : `/${providerCommand}`; + const attachments = draft.attachments; + if ( + composerAttachmentUploadBlockReason({ + environmentId: selectedThreadShell.environmentId, + attachments, + connected: selectedEnvironmentRuntime?.connectionState === "connected", + serverConfig: selectedEnvironmentRuntime?.serverConfig ?? null, + states: appAtomRegistry.get(composerAttachmentUploadsAtom), + }) !== null + ) + return null; + if (text.length === 0 && attachments.length === 0) { + return null; + } + // A send-failure restore appends with allowOverflow so it never drops the + // user's files, which can leave the draft over the cap. Sending it anyway + // would enqueue a message that outbox recovery rejects forever, so block + // here until the user removes attachments. + if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + Alert.alert( + "Too many attachments", + `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`, + ); + return null; + } - const modelSelection = draft.modelSelection ?? thread.modelSelection; - const serverConfig = selectedEnvironmentRuntime?.serverConfig; - if ( - selectedEnvironmentRuntime?.connectionState === "connected" && - isModelSelectionUnavailable(serverConfig, modelSelection) - ) { - Alert.alert( - "Antigravity model unavailable", - "Set up Antigravity on web or desktop, or choose another model.", - ); - return null; - } - const provider = serverConfig?.providers.find( - (entry) => entry.instanceId === modelSelection.instanceId, - ); - const feedbackCommand = - attachments.length === 0 && - (provider?.driver === "codex" || thread.session?.providerName === "codex") - ? parseCodexFeedbackCommand(text) - : null; - if (feedbackCommand) { - if (serverConfig?.environment.capabilities.providerFeedback !== true) { + const modelSelection = draft.modelSelection ?? thread.modelSelection; + const serverConfig = selectedEnvironmentRuntime?.serverConfig; + if ( + selectedEnvironmentRuntime?.connectionState === "connected" && + isModelSelectionUnavailable(serverConfig, modelSelection) + ) { Alert.alert( - "Feedback is not supported", - "This environment does not support provider feedback. Your draft has been kept.", + "Antigravity model unavailable", + "Set up Antigravity on web or desktop, or choose another model.", ); return null; } - if (thread.session === null) { - Alert.alert("Start a Codex thread first", "Send a message before you submit feedback."); + const provider = serverConfig?.providers.find( + (entry) => entry.instanceId === modelSelection.instanceId, + ); + const piSessionCommand = + provider?.driver === "pi" || thread.session?.providerName === "pi" + ? parsePiSessionCommand(text) + : null; + if (piSessionCommand) { + if ("error" in piSessionCommand || attachments.length > 0) { + Alert.alert( + "Invalid Pi command", + "error" in piSessionCommand + ? piSessionCommand.error + : "Session commands do not accept attachments or context items.", + ); + return null; + } + if (thread.session === null) { + Alert.alert("Start a Pi thread first", "Send a message before using session commands."); + return null; + } + sessionCommandsInFlight.current.add(threadKey); + try { + const response = await runThreadSessionCommand({ + environmentId: selectedThreadShell.environmentId, + input: { threadId: selectedThreadShell.id, ...piSessionCommand }, + }); + if (response._tag === "Failure") { + if (isAtomCommandInterrupted(response)) return null; + throw squashAtomCommandFailure(response); + } + const result = response.value; + if (result.command === "copy") { + if (!(await tryCopyTextWithHaptic(result.text, { target: "Pi response" }))) { + throw new Error("Could not copy the response to the clipboard."); + } + } else if (result.command === "export") { + await shareTextFile({ + name: result.fileName, + mimeType: "text/html", + text: result.html, + }); + } else { + Alert.alert("Pi session shared", result.url, [ + { text: "Done", style: "cancel" }, + { text: "Copy link", onPress: () => void tryCopyTextWithHaptic(result.url) }, + { text: "Open", onPress: () => void Linking.openURL(result.url) }, + ]); + } + if (getComposerDraftSnapshot(threadKey).text === draft.text) { + setComposerDraftText(threadKey, ""); + } + } catch (error) { + Alert.alert( + `Could not ${piSessionCommand.command} Pi session`, + error instanceof Error ? error.message : "An error occurred.", + ); + } finally { + sessionCommandsInFlight.current.delete(threadKey); + } + return null; + } + const feedbackCommand = + attachments.length === 0 && + (provider?.driver === "codex" || thread.session?.providerName === "codex") + ? parseCodexFeedbackCommand(text) + : null; + if (feedbackCommand) { + if (serverConfig?.environment.capabilities.providerFeedback !== true) { + Alert.alert( + "Feedback is not supported", + "This environment does not support provider feedback. Your draft has been kept.", + ); + return null; + } + if (thread.session === null) { + Alert.alert("Start a Codex thread first", "Send a message before you submit feedback."); + return null; + } + const metadata = makeQueuedMessageMetadata(); + await submitCodexFeedback({ + submission: { + id: MessageId.make(metadata.messageId), + command: text, + createdAt: metadata.createdAt, + }, + clearDraft: () => clearComposerDraftContent(threadKey), + onUpdate: (submission) => { + setFeedbackSubmissionsByThreadKey((current) => { + const existing = current[threadKey] ?? []; + const found = existing.some((entry) => entry.id === submission.id); + return { + ...current, + [threadKey]: found + ? existing.map((entry) => (entry.id === submission.id ? submission : entry)) + : [...existing, submission], + }; + }); + }, + upload: () => + uploadThreadFeedback({ + environmentId: selectedThreadShell.environmentId, + input: { + threadId: selectedThreadShell.id, + ...feedbackCommand, + }, + }), + }); return null; } + const metadata = makeQueuedMessageMetadata(); - await submitCodexFeedback({ - submission: { - id: MessageId.make(metadata.messageId), - command: text, - createdAt: metadata.createdAt, + const messageId = MessageId.make(metadata.messageId); + // Enqueue publishes the queued atom synchronously (the durable write + // happens behind it), so clearing the draft here gives send feedback on + // the tap frame instead of after file I/O. If the write fails the message + // is rolled out of the queue and the content is merged back into the + // draft, preserving anything typed since. + const enqueuePromise = enqueueThreadOutboxMessage({ + environmentId: selectedThreadShell.environmentId, + threadId: selectedThreadShell.id, + messageId, + commandId: CommandId.make(metadata.commandId), + text, + attachments, + modelSelection, + runtimeMode: draft.runtimeMode ?? thread.runtimeMode, + interactionMode: resolveProviderInteractionMode( + provider, + draft.interactionMode ?? thread.interactionMode, + ), + createdAt: metadata.createdAt, + }); + clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); + enqueuePromise.then( + () => { + // The queued message owns the files now; the sweep sees that and + // spares them. Deferred to here so a failed write cannot roll the + // message out of the queue mid-sweep and lose the bytes. + scheduleUnusedComposerAttachmentCleanup(attachments); }, - clearDraft: () => clearComposerDraftContent(threadKey), - onUpdate: (submission) => { - setFeedbackSubmissionsByThreadKey((current) => { - const existing = current[threadKey] ?? []; - const found = existing.some((entry) => entry.id === submission.id); - return { - ...current, - [threadKey]: found - ? existing.map((entry) => (entry.id === submission.id ? submission : entry)) - : [...existing, submission], - }; - }); + (error: unknown) => { + // Restore text via merge (idempotent) but attachments via the uncapped + // append: the merge path slots existing attachments first and truncates + // at the send limit, which would silently drop this message's images if + // the user attached new ones while the write was in flight. + void mergeComposerDraftContent(threadKey, { text, attachments: [] }); + appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true }); + setPendingConnectionError( + error instanceof Error ? error.message : "Failed to save the queued message.", + ); }, - upload: () => - uploadThreadFeedback({ - environmentId: selectedThreadShell.environmentId, - input: { - threadId: selectedThreadShell.id, - ...feedbackCommand, - }, - }), - }); - return null; - } - - const metadata = makeQueuedMessageMetadata(); - const messageId = MessageId.make(metadata.messageId); - // Enqueue publishes the queued atom synchronously (the durable write - // happens behind it), so clearing the draft here gives send feedback on - // the tap frame instead of after file I/O. If the write fails the message - // is rolled out of the queue and the content is merged back into the - // draft, preserving anything typed since. - const enqueuePromise = enqueueThreadOutboxMessage({ - environmentId: selectedThreadShell.environmentId, - threadId: selectedThreadShell.id, - messageId, - commandId: CommandId.make(metadata.commandId), - text, - attachments, - modelSelection, - runtimeMode: draft.runtimeMode ?? thread.runtimeMode, - interactionMode: resolveProviderInteractionMode( - provider, - draft.interactionMode ?? thread.interactionMode, - ), - createdAt: metadata.createdAt, - }); - clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); - enqueuePromise.then( - () => { - // The queued message owns the files now; the sweep sees that and - // spares them. Deferred to here so a failed write cannot roll the - // message out of the queue mid-sweep and lose the bytes. - scheduleUnusedComposerAttachmentCleanup(attachments); - }, - (error: unknown) => { - // Restore text via merge (idempotent) but attachments via the uncapped - // append: the merge path slots existing attachments first and truncates - // at the send limit, which would silently drop this message's images if - // the user attached new ones while the write was in flight. - void mergeComposerDraftContent(threadKey, { text, attachments: [] }); - appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true }); - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to save the queued message.", - ); - }, - ); - return messageId; - }, [ - selectedEnvironmentRuntime?.connectionState, - selectedEnvironmentRuntime?.serverConfig, - selectedThreadDetail, - selectedThreadShell, - uploadThreadFeedback, - ]); + ); + return messageId; + }, + [ + selectedEnvironmentRuntime?.connectionState, + selectedEnvironmentRuntime?.serverConfig, + selectedThreadDetail, + selectedThreadShell, + uploadThreadFeedback, + runThreadSessionCommand, + ], + ); const onChangeDraftMessage = useCallback( (value: string) => { diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index e1738a6c23fe..e8fc8ada4588 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -26,6 +26,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, [WS_METHODS.serverListProviderCommands]: AuthOrchestrationReadScope, [WS_METHODS.serverPrewarmProviderSession]: AuthOrchestrationOperateScope, + [WS_METHODS.providerSessionCommand]: AuthOrchestrationOperateScope, [WS_METHODS.serverListSkills]: AuthOrchestrationReadScope, [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope, [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/git/Layers/PiTextGeneration.test.ts b/apps/server/src/git/Layers/PiTextGeneration.test.ts new file mode 100644 index 000000000000..845e059604e2 --- /dev/null +++ b/apps/server/src/git/Layers/PiTextGeneration.test.ts @@ -0,0 +1,124 @@ +import { assert, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { makePiTextGeneration } from "./PiTextGeneration.ts"; + +const decodeRecordLine = Schema.decodeSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); +const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +/** Deliberately outside the valid pid range so a group-kill can never land. */ +const FAKE_PID = 999_999_999; + +/** + * Fake `pi --mode rpc` for one ephemeral generation: records the request + * types it receives, settles right after the prompt, and replies with + * `assistantText`. + */ +function fakePiSpawner(assistantText: string, requests: Array>) { + return ChildProcessSpawner.make(() => + Effect.gen(function* () { + const stdout = yield* Queue.unbounded(); + const emit = (record: Record) => + Queue.offer(stdout, new TextEncoder().encode(`${encodeJsonLine(record)}\n`)); + let buffer = ""; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(FAKE_PID), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.forEach((chunk: Uint8Array) => + Effect.gen(function* () { + buffer += new TextDecoder().decode(chunk); + let newline = buffer.indexOf("\n"); + while (newline !== -1) { + const record = decodeRecordLine(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + requests.push(record); + const data = + record["type"] === "get_last_assistant_text" ? { text: assistantText } : undefined; + yield* emit({ type: "response", id: record["id"], success: true, data }); + if (record["type"] === "prompt") yield* emit({ type: "agent_settled" }); + newline = buffer.indexOf("\n"); + } + }), + ), + stdout: Stream.fromQueue(stdout), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); +} + +it.effect("applies the selected thinking level for titles", () => + Effect.gen(function* () { + const requests: Array> = []; + const textGeneration = yield* makePiTextGeneration( + { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] }, + {}, + ).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + fakePiSpawner('{"title":"Fix this"}', requests), + ), + ); + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "fix this", + modelSelection: { + instanceId: ProviderInstanceId.make("pi"), + model: "anthropic/claude-sonnet-5", + options: [{ id: "thinking", value: "high" }], + }, + }); + + assert.deepStrictEqual(generated, { title: "Fix this" }); + assert.deepStrictEqual( + requests.map((record) => record["type"]), + ["set_model", "set_thinking_level", "prompt", "get_last_assistant_text"], + ); + assert.strictEqual(requests[1]?.["level"], "high"); + }), +); + +it.effect("defers to Pi default model without set_model", () => + Effect.gen(function* () { + const requests: Array> = []; + const textGeneration = yield* makePiTextGeneration( + { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] }, + {}, + ).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + fakePiSpawner('{"subject":"Fix bug","body":"Details"}', requests), + ), + ); + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "main", + stagedSummary: "one file", + stagedPatch: "diff", + modelSelection: { + instanceId: ProviderInstanceId.make("pi"), + model: "default", + }, + }); + + assert.strictEqual(generated.subject, "Fix bug"); + assert.isFalse(requests.some((record) => record["type"] === "set_model")); + }), +); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index d924d996e773..8e2f505059f3 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -146,6 +146,7 @@ function createProviderServiceHarness( respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), stopSession: () => unsupported(), + sessionCommand: () => unsupported(), listSessions, prewarmSession: () => Effect.void, getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a0310ec0f87d..5dd5502f033c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -373,6 +373,7 @@ describe("ProviderCommandReactor", () => { respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], stopSession: stopSession as ProviderServiceShape["stopSession"], + sessionCommand: unsupported as ProviderServiceShape["sessionCommand"], listSessions: () => Effect.succeed(runtimeSessions), prewarmSession: () => Effect.void, getCapabilities: (_provider) => diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index d4cbc343a0d2..56d023355a75 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -138,6 +138,7 @@ function createProviderServiceHarness() { steerTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), + sessionCommand: () => unsupported(), stopSession: () => unsupported(), listSessions: () => Effect.succeed([...runtimeSessions]), prewarmSession: () => Effect.void, diff --git a/apps/server/src/provider/Layers/PiAdapter.live.test.ts b/apps/server/src/provider/Layers/PiAdapter.live.test.ts new file mode 100644 index 000000000000..ad8e7cdb8531 --- /dev/null +++ b/apps/server/src/provider/Layers/PiAdapter.live.test.ts @@ -0,0 +1,147 @@ +/** + * Opt-in check of the Pi adapter against a real `pi` install. The fake Pi in + * PiAdapter.test.ts cannot catch RPC drift, so run this after a Pi upgrade: + * + * T3_PI_LIVE_MODEL= vp test run PiAdapter.live + * + * It uses the user's Pi models and credentials. Sessions go to a temp + * `--session-dir`, so the user's Pi session history stays untouched. + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + ApprovalRequestId, + type ModelSelection, + ProviderInstanceId, + type ProviderRuntimeEvent, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; + +import { ServerConfig } from "../../config.ts"; +import { piRecordString } from "../PiRpc.ts"; +import { makePiAdapter } from "./PiAdapter.ts"; +import { checkPiProviderStatus } from "./PiProvider.ts"; + +const MODEL = process.env.T3_PI_LIVE_MODEL ?? ""; +const PI = ProviderInstanceId.make("pi"); +const THREAD_ID = ThreadId.make("thread-pi-live"); +const selection: ModelSelection = { instanceId: PI, model: MODEL }; + +const testLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-pi-live-" }).pipe( + Layer.provideMerge(NodeServices.layer), +); + +const makeLiveHarness = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-live-workspace-" }); + const sessionDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-live-sessions-" }); + const settings = { + enabled: true, + binaryPath: "pi", + launchArgs: `--session-dir ${sessionDir}`, + customModels: [], + }; + const adapter = yield* makePiAdapter(settings, { instanceId: PI }); + const events = yield* Queue.unbounded(); + yield* Stream.runForEach(adapter.streamEvents, (event) => Queue.offer(events, event)).pipe( + Effect.forkScoped, + ); + yield* Effect.yieldNow; + + /** Sends a turn, accepts every approval, and returns the turn's events and text. */ + const runTurn = (input: string) => + Effect.gen(function* () { + const turn = yield* adapter.sendTurn({ + threadId: THREAD_ID, + input, + modelSelection: selection, + }); + const seen: Array = []; + while (true) { + const event = yield* Queue.take(events); + seen.push(event); + if (event.type === "request.opened" && event.requestId !== undefined) { + yield* adapter.respondToRequest( + THREAD_ID, + ApprovalRequestId.make(event.requestId), + "accept", + ); + } + if (event.type === "turn.completed" && event.turnId === turn.turnId) break; + } + const text = seen + .flatMap((event) => + event.type === "content.delta" && event.payload.streamKind === "assistant_text" + ? [event.payload.delta] + : [], + ) + .join(""); + return { events: seen, text, completed: seen.at(-1) }; + }).pipe(Effect.timeout("3 minutes")); + + const cursor = adapter + .listSessions() + .pipe(Effect.map((sessions) => sessions.find((s) => s.threadId === THREAD_ID)?.resumeCursor)); + + return { adapter, cwd, settings, runTurn, cursor }; +}); + +describe.runIf(MODEL.length > 0)("PiAdapter against a real Pi", () => { + it.live( + "lists the model with its thinking levels", + () => + Effect.gen(function* () { + const { settings, cwd } = yield* makeLiveHarness; + const snapshot = yield* checkPiProviderStatus(settings, process.env, cwd); + assert.equal(snapshot.status, "ready"); + assert.isDefined(snapshot.models.find((model) => model.slug === MODEL)); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + 60_000, + ); + + it.live( + "runs a supervised tool turn, resumes the session, and rolls back a turn", + () => + Effect.gen(function* () { + const { adapter, cwd, runTurn, cursor } = yield* makeLiveHarness; + yield* adapter.startSession({ threadId: THREAD_ID, cwd, runtimeMode: "approval-required" }); + + const tool = yield* runTurn("Run `echo t3-live-ok` with bash, then reply with its output."); + assert.deepInclude(tool.completed?.payload, { state: "completed" }); + assert.isTrue(tool.events.some((event) => event.type === "request.opened")); + const bash = tool.events.find( + (event) => + event.type === "item.completed" && event.payload.itemType === "command_execution", + ); + const bashData = bash?.type === "item.completed" ? bash.payload.data : undefined; + assert.include(piRecordString(bashData, "result") ?? "", "t3-live-ok"); + + yield* runTurn("Remember the code word PINEAPPLE-42. Reply with exactly: OK"); + const saved = yield* cursor; + yield* adapter.stopSession(THREAD_ID); + yield* adapter.startSession({ + threadId: THREAD_ID, + cwd, + runtimeMode: "approval-required", + resumeCursor: saved, + }); + const resumed = yield* runTurn("What code word did I give you? Reply with only the word."); + assert.include(resumed.text, "PINEAPPLE-42"); + + yield* runTurn("A second code word is MANGO-7. Reply with exactly: OK"); + yield* adapter.rollbackThread(THREAD_ID, 1); + const afterRollback = yield* runTurn( + "List every code word I gave you, comma separated, and nothing else.", + ); + assert.include(afterRollback.text, "PINEAPPLE-42"); + assert.notInclude(afterRollback.text, "MANGO-7"); + yield* adapter.stopSession(THREAD_ID); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + 600_000, + ); +}); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 06eff3b6b861..0b1accff9713 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -8,6 +8,8 @@ import type { ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, + ProviderSessionCommandInput, + ProviderSessionCommandResult, ProviderSessionForkInput, ProviderTurnStartResult, } from "@t3tools/contracts"; @@ -303,6 +305,14 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { sessions.clear(); }), ); + + const sessionCommand = vi.fn( + ( + input: ProviderSessionCommandInput, + ): Effect.Effect => + Effect.succeed({ command: "copy", text: `response-${input.threadId}` }), + ); + const forkSession = vi.fn( (input: ProviderSessionForkInput): Effect.Effect => startSession({ @@ -327,6 +337,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { hasSession, readThread, rollbackThread, + sessionCommand, stopAll, get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -363,6 +374,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { hasSession, readThread, rollbackThread, + sessionCommand, stopAll, }; } @@ -1088,6 +1100,68 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect( + "routes a session command after recovering a stopped session without sending a turn", + () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = asThreadId("thread-session-command-recover"); + yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/session-command-project", + runtimeMode: "full-access", + }); + yield* routing.codex.stopSession(threadId); + routing.codex.startSession.mockClear(); + routing.codex.sendTurn.mockClear(); + routing.codex.sessionCommand.mockClear(); + const result = yield* provider.sessionCommand({ threadId, command: "copy" }); + assert.deepStrictEqual(result, { command: "copy", text: `response-${threadId}` }); + assert.strictEqual(routing.codex.startSession.mock.calls.length, 1); + assert.deepStrictEqual(routing.codex.sessionCommand.mock.calls, [ + [{ threadId, command: "copy" }], + ]); + assert.strictEqual(routing.codex.sendTurn.mock.calls.length, 0); + }), + ); + + it.effect("rejects a session command for an unsupported provider without resuming it", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = asThreadId("thread-session-command-unsupported"); + yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + threadId, + runtimeMode: "full-access", + }); + yield* routing.claude.stopSession(threadId); + routing.claude.startSession.mockClear(); + // The shared fake supports session commands; simulate a provider that + // does not by hiding the optional adapter method for this test. + const claudeAdapter = routing.claude.adapter as unknown as { + sessionCommand?: unknown; + }; + const saved = claudeAdapter.sessionCommand; + claudeAdapter.sessionCommand = undefined; + try { + const error = yield* provider + .sessionCommand({ threadId, command: "copy" }) + .pipe(Effect.flip); + assert.strictEqual( + error._tag, + "ProviderValidationError", + `expected validation error, got ${String(error)}`, + ); + assert.strictEqual(routing.claude.startSession.mock.calls.length, 0); + } finally { + claudeAdapter.sessionCommand = saved; + } + }), + ); + it.effect("preserves the persisted binding when stopping a session", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 95e06bbbf832..48161a3f6a01 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -18,6 +18,7 @@ import { ProviderRespondToRequestInput, ProviderRespondToUserInputInput, ProviderSendTurnInput, + ProviderSessionCommandInput, ProviderSessionForkInput, ProviderSessionStartInput, ProviderSteerTurnInput, @@ -1122,6 +1123,39 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); + const sessionCommand: ProviderServiceShape["sessionCommand"] = Effect.fn("sessionCommand")( + function* (rawInput) { + const operation = "ProviderService.sessionCommand"; + const input = yield* decodeInputOrValidationError({ + operation, + schema: ProviderSessionCommandInput, + payload: rawInput, + }); + let routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation, + allowRecovery: false, + }); + if (routed.adapter.sessionCommand === undefined) { + return yield* toValidationError( + operation, + `Provider '${routed.adapter.provider}' does not support session commands.`, + ); + } + if (!routed.isActive) { + routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation, + allowRecovery: true, + }); + } + if (routed.adapter.sessionCommand === undefined) { + return yield* toValidationError(operation, "Provider does not support session commands."); + } + return yield* routed.adapter.sessionCommand(input); + }, + ); + const listSessions: ProviderServiceShape["listSessions"] = Effect.fn("listSessions")( function* () { const currentAdapters = yield* getAdapterEntries; @@ -1312,6 +1346,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( respondToRequest, respondToUserInput, stopSession, + sessionCommand, listSessions, prewarmSession, getCapabilities, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 30e0a165e6cc..dc939f7e13ff 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -172,6 +172,7 @@ describe("ProviderSessionReaper", () => { steerTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), + sessionCommand: () => unsupported(), stopSession, listSessions: input.listSessionsImplementation ?? (() => Effect.succeed(input.activeSessions ?? [])), diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index f624b7af03a6..3de97cb5c774 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -20,6 +20,8 @@ import type { ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, + ProviderSessionCommandInput, + ProviderSessionCommandResult, ProviderSessionForkInput, ProviderSessionStartInput, ProviderSteerTurnInput, @@ -140,6 +142,15 @@ export interface ProviderServiceShape { readonly numTurns: number; }) => Effect.Effect; + /** + * Run a client-requested session utility (copy/export/share) without + * creating an agent turn. Fails when the bound provider has no + * `sessionCommand` adapter support. + */ + readonly sessionCommand: ( + input: ProviderSessionCommandInput, + ) => Effect.Effect; + /** * Canonical provider runtime event stream. * diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b8377827c74f..83ac185db77e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -8,6 +8,7 @@ import { type DpopPublicJwk, } from "@t3tools/shared/dpop"; import { ServerAuth, type ServerAuthShape } from "./auth/Services/ServerAuth.ts"; +import { ProviderAdapterRequestError } from "./provider/Errors.ts"; import * as Context from "effect/Context"; import { @@ -876,6 +877,7 @@ const buildAppUnderTest = (options?: { getCapabilities: () => Effect.die("Not implemented in server test."), getInstanceInfo: () => Effect.die("Not implemented in server test."), rollbackConversation: () => Effect.die("Not implemented in server test."), + sessionCommand: () => Effect.die("Not implemented in server test."), streamEvents: Stream.empty, ...options.layers.providerService, }), @@ -2323,6 +2325,67 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("returns session command results over websocket rpc without dispatching a turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-session-command"); + yield* buildAppUnderTest({ + layers: { + providerService: { + sessionCommand: (input) => + Effect.succeed({ command: "copy", text: `response-${input.threadId}` }), + }, + }, + }); + + const { cookie } = yield* bootstrapBrowserSession(); + const wsUrl = appendSessionCookieToWsUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + cookie?.split(";")[0] ?? "", + ); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.providerSessionCommand]({ threadId, command: "copy" }), + ), + ); + assert.deepStrictEqual(result, { command: "copy", text: `response-${threadId}` }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("keeps session command failures actionable across websocket rpc", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-session-command-error"); + yield* buildAppUnderTest({ + layers: { + providerService: { + sessionCommand: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "pi", + method: "share", + detail: "Run gh auth login on the server.", + }), + ), + }, + }, + }); + + const { cookie } = yield* bootstrapBrowserSession(); + const wsUrl = appendSessionCookieToWsUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + cookie?.split(";")[0] ?? "", + ); + const error = yield* Effect.flip( + Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.providerSessionCommand]({ threadId, command: "share" }), + ), + ), + ); + assert.strictEqual(error._tag, "ProviderSessionCommandError"); + assert.include(error.message, "gh auth login on the server"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes acceptance pause through the authenticated websocket authority tuple", () => Effect.gen(function* () { const authority = { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1916cd448916..5b83579169a2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -56,6 +56,7 @@ import { FilesystemBrowseError, MessageId, ProjectId, + ProviderSessionCommandError, ServerChatArchiveError, ServerProviderListCommandsError, ServerExportThreadMarkdownError, @@ -1643,6 +1644,30 @@ const makeWsRpcLayer = ( }).pipe(Effect.as({})), { "rpc.aggregate": "server" }, ), + [WS_METHODS.providerSessionCommand]: (input) => + observeRpcEffect( + WS_METHODS.providerSessionCommand, + Option.match(providerService, { + onNone: () => + Effect.fail( + new ProviderSessionCommandError({ + threadId: input.threadId, + detail: "Provider runtime is unavailable in this environment.", + }), + ), + onSome: (service) => + service.sessionCommand(input).pipe( + Effect.mapError( + (cause) => + new ProviderSessionCommandError({ + threadId: input.threadId, + detail: cause.message, + }), + ), + ), + }), + { "rpc.aggregate": "provider" }, + ), [WS_METHODS.serverListSkills]: (_input) => observeRpcEffect( WS_METHODS.serverListSkills, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 115c40ae133a..543d8f36c189 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -283,6 +283,9 @@ function createMockEnvironmentApi(input: { }) as EnvironmentApi["server"]["exportThreadMarkdown"], listProviderCommands: async () => ({ commands: [] }), prewarmProviderSession: async () => ({}), + sessionCommand: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["server"]["sessionCommand"], }, orchestration: { dispatchCommand: input.dispatchCommand, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 03776115f6fb..b29e4de8f565 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -32,6 +32,7 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; +import { parsePiSessionCommand } from "@t3tools/client-runtime/state/threads"; import { applyClaudePromptEffortPrefix, createModelSelection, @@ -3294,7 +3295,7 @@ function ChatViewBody( ], ); - const onSend = async (e?: { preventDefault: () => void }) => { + const onSend = async (e?: { preventDefault: () => void }, providerCommand?: string) => { e?.preventDefault(); const api = readEnvironmentApi(environmentId); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; @@ -3318,10 +3319,13 @@ function ChatViewBody( selectedModelSelection: ctxSelectedModelSelection, } = sendCtx; const draftPromptForSend = promptRef.current; - const promptForSend = composerPreviewAnnotations.reduce( - (prompt, annotation) => appendPreviewAnnotationPrompt(prompt, annotation), - draftPromptForSend, - ); + const promptForSend = + providerCommand !== undefined + ? `/${providerCommand}` + : composerPreviewAnnotations.reduce( + (prompt, annotation) => appendPreviewAnnotationPrompt(prompt, annotation), + draftPromptForSend, + ); const { trimmedPrompt: trimmed, sendableTerminalContexts: sendableComposerTerminalContexts, @@ -3332,6 +3336,129 @@ function ChatViewBody( imageCount: composerImages.length, terminalContexts: composerTerminalContexts, }); + const piSessionCommand = ctxSelectedProvider === "pi" ? parsePiSessionCommand(trimmed) : null; + if (piSessionCommand) { + const composerHasNonPromptContent = + composerImages.length > 0 || + composerTerminalContexts.length > 0 || + composerPreviewAnnotations.length > 0; + if ("error" in piSessionCommand || composerHasNonPromptContent) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Invalid Pi command", + description: + "error" in piSessionCommand + ? piSessionCommand.error + : "Session commands do not accept attachments or context items.", + }), + ); + return; + } + const hasMessages = isServerThread + ? serverMessageIds.length > 0 + : activeThread.messages.length > 0; + if (!isServerThread || !hasMessages) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Start a Pi thread first", + description: "Send a message before using session commands.", + }), + ); + return; + } + sendInFlightRef.current = true; + const notice = toastManager.add( + stackedThreadToast({ + type: "info", + title: + piSessionCommand.command === "share" ? "Sharing Pi session…" : "Running Pi command…", + timeout: 0, + }), + ); + try { + const result = await api.server.sessionCommand({ + threadId: activeThread.id, + ...piSessionCommand, + }); + if (result.command === "copy") { + try { + if (typeof navigator.clipboard?.writeText !== "function") { + throw new Error("Clipboard is unavailable."); + } + await navigator.clipboard.writeText(result.text); + } catch { + toastManager.update(notice, { + type: "error", + title: "Could not copy the Pi response", + description: + "The response was retrieved. Clipboard access was denied by the browser.", + timeout: 0, + }); + return; + } + toastManager.update(notice, { + type: "success", + title: "Response copied", + timeout: 5_000, + }); + } else if (result.command === "export") { + const url = URL.createObjectURL( + new Blob([result.html], { type: "text/html;charset=utf-8" }), + ); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = result.fileName; + anchor.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + toastManager.update(notice, { + type: "success", + title: "Pi session exported", + description: result.outputPath, + timeout: 5_000, + }); + } else { + toastManager.update(notice, { + type: "success", + title: "Pi session shared", + description: ( + + {result.url} + + ), + actionProps: { + children: "Copy link", + onClick: () => + void navigator.clipboard?.writeText(result.url).catch(() => + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy the share link", + }), + ), + ), + }, + timeout: 0, + }); + } + if (promptRef.current === promptForSend) { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + } + } catch (error) { + toastManager.update(notice, { + type: "error", + title: `Could not ${piSessionCommand.command} Pi session`, + description: error instanceof Error ? error.message : "An error occurred.", + timeout: 0, + }); + } finally { + sendInFlightRef.current = false; + } + return; + } if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, @@ -5304,6 +5431,7 @@ function ChatViewBody( shouldAutoScrollRef={isAtEndRef} scheduleStickToBottom={scrollToEnd} onSend={onSend} + onProviderCommand={(name) => void onSend(undefined, name)} onComposerIntent={prewarmComposerProviderSession} onInterrupt={onInterrupt} onSteer={onSteer} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 86720281dfbf..fe905c7f3475 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -500,13 +500,16 @@ export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( ); export const PiAgentIcon: Icon = ({ className, ...props }) => ( - - + - + ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9710ef44d611..12d12502ec2b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -439,6 +439,7 @@ export interface ChatComposerProps { // Callbacks onSend: (e?: { preventDefault: () => void }) => void; + onProviderCommand?: ((name: string) => void) | undefined; onComposerIntent: () => void; onInterrupt: () => void; onSteer: () => void; @@ -1693,7 +1694,17 @@ export const ChatComposer = memo( return; } if (item.type === "provider-slash-command") { - const replacement = `/${item.command.name} `; + if (item.command.argumentMode === "none" && props.onProviderCommand) { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + props.onProviderCommand(item.command.name); + } + return; + } + const replacement = `/${item.command.name}${item.command.argumentMode === "none" ? "" : " "}`; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, trigger.rangeEnd, @@ -1729,7 +1740,7 @@ export const ChatComposer = memo( return; } }, - [applyPromptReplacement, resolveActiveComposerTrigger], + [applyPromptReplacement, props.onProviderCommand, resolveActiveComposerTrigger], ); const onComposerMenuItemHighlighted = useCallback( diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 88b56295f360..6e3ecdc75183 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon, PiAgentIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, + [ProviderDriverKind.make("pi")]: PiAgentIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index b8bce8f0cd98..c8451830757e 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -13,7 +13,7 @@ import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; -import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon } from "../Icons"; +import { ACPRegistryIcon, Gemini, GithubCopilotIcon } from "../Icons"; import { Dialog, DialogDescription, @@ -80,12 +80,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly DriverOption[] = [ icon: ACPRegistryIcon, fields: [], }, - { - value: ProviderDriverKind.make("piAgent"), - label: "Pi Agent", - icon: PiAgentIcon, - fields: [], - }, ]; /** diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index f05fde5d18d9..f30b8389d7a0 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -35,6 +35,7 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial> = diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index f08357be9693..13ed73971488 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -107,6 +107,7 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { refreshProviders: rpcClient.server.refreshProviders, listProviderCommands: rpcClient.server.listProviderCommands, prewarmProviderSession: rpcClient.server.prewarmProviderSession, + sessionCommand: rpcClient.server.sessionCommand, exportThreadMarkdown: rpcClient.server.exportThreadMarkdown, }, orchestration: { diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index eb4c6e5cf179..383a74295c2f 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -135,6 +135,7 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { refreshProviders: rpcClient.server.refreshProviders, listProviderCommands: rpcClient.server.listProviderCommands, prewarmProviderSession: rpcClient.server.prewarmProviderSession, + sessionCommand: rpcClient.server.sessionCommand, listSkills: rpcClient.server.listSkills, upsertKeybinding: rpcClient.server.upsertKeybinding, getSettings: rpcClient.server.getSettings, diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 68df87980f2c..c434ab9b3b57 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -210,6 +210,7 @@ export interface WsRpcClient { ) => ReturnType>; readonly listProviderCommands: RpcUnaryMethod; readonly prewarmProviderSession: RpcUnaryMethod; + readonly sessionCommand: RpcUnaryMethod; readonly listSkills: RpcUnaryNoArgMethod; readonly upsertKeybinding: RpcUnaryMethod; readonly getSettings: RpcUnaryNoArgMethod; @@ -512,6 +513,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { transport.request((client) => client[WS_METHODS.serverListProviderCommands](input)), prewarmProviderSession: (input) => transport.request((client) => client[WS_METHODS.serverPrewarmProviderSession](input)), + sessionCommand: (input) => + transport.request((client) => client[WS_METHODS.providerSessionCommand](input)), listSkills: () => transport.request((client) => client[WS_METHODS.serverListSkills]({})), upsertKeybinding: (input) => transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e93289f3be11..2ce6b4c10d27 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -62,6 +62,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("pi"), + label: "Pi", + available: true, + pickerSidebarBadge: "new", + }, ]; export interface WorkLogEntry { diff --git a/docs/providers/pi.md b/docs/providers/pi.md new file mode 100644 index 000000000000..bebc114b3803 --- /dev/null +++ b/docs/providers/pi.md @@ -0,0 +1,66 @@ +# Pi + +T3 Code can use your existing Pi coding agent installation while keeping Pi's models, auth, +extensions, skills, context files, and native session history. + +## Set Up Pi + +1. Install Pi 0.80.5 or newer on the machine running the T3 Code server. +2. Run Pi once in a terminal and finish the provider login or API-key setup you normally use. +3. Open T3 Code Settings, enable Pi, and refresh the provider. + +If `pi` is not on the server's `PATH`, set Pi's binary path to the executable. Provider environment +variables and launch arguments are also available for installations that need a custom agent +directory, endpoint, or model configuration. T3 Code rejects launch arguments that change Pi's +execution mode or select a session because T3 owns those parts of the process lifecycle. + +## What Carries Over + +T3 Code discovers the models reported by Pi and exposes their supported thinking levels. The +thinking picker marks Pi's current configured level as the default without overriding it. The +**Pi default** model keeps the model from Pi's own settings. Threads use Pi's native session files +for resume and rollback, so a thread started in T3 Code also appears in Pi's `/resume` list. If a +thread's session file is deleted, the thread continues in a new Pi session without the earlier +context. Extension dialogs appear in the T3 Code composer, and the context meter follows Pi's +own usage reporting after each response and when a turn settles. + +Pi skills appear in the composer's `$` menu. This includes user skills and project skills that Pi +loads for the current workspace; selecting one uses Pi's native skill expansion. `/compact` runs +Pi's own context compaction. + +In an existing Pi thread, `/copy` copies the last assistant response. `/export` saves an HTML +session export to your device; an optional path also keeps a copy on the server. On mobile, +choose where to save it through the share sheet. `/share` uploads the session to an unlisted +GitHub gist and returns a Pi viewer link. Sharing requires `gh auth login` on the server machine; +anyone with the link can read the exported session. Selecting `/copy` or `/share` runs it +immediately; these commands do not accept arguments. + +Pi loads its normal user and project extensions. Blocking `select`, `confirm`, `input`, and `editor` +dialogs work in T3 Code. Notifications appear in the work log. Pi terminal decoration such as +titles, status lines, and widgets does not have a T3 Code equivalent. + +## Permission Modes + +T3 Code applies the composer permission mode through Pi's blocking tool hook: + +- **Supervised** asks before commands, file changes, and extension tools. Read-only tools continue. +- **Auto-accept edits** allows Pi's edit and write tools, but asks before commands and extension + tools. +- **Auto** has the same rules as **Supervised** because Pi has no AI approval reviewer. +- **Full access** allows tools without T3 Code approval prompts. + +Changing the mode restarts the Pi provider session and resumes the same native conversation. The +policy covers Pi tool calls; it is not an operating-system sandbox, and code that a trusted Pi +extension runs outside a tool call remains governed by Pi's own extension trust model. + +## Troubleshooting + +- If Pi is unavailable, confirm that the configured binary runs on the server machine, then refresh + the provider in Settings. +- If no models appear, open Pi directly and confirm its authentication and model configuration. +- If discovery cannot complete, T3 Code keeps Pi available with the `Pi default` model. Start a + thread to let the interactive Pi session handle any startup prompt. +- If a project extension is missing, approve the project in Pi, then start a fresh provider session. +- If a project skill is missing from the `$` menu, approve the project in Pi and refresh the provider. +- The context meter appears once Pi reports usage for the thread. Some model providers only + report usage when a response completes, so the meter can wait for the first reply. diff --git a/packages/client-runtime/src/state/piSessionCommands.test.ts b/packages/client-runtime/src/state/piSessionCommands.test.ts new file mode 100644 index 000000000000..5698de3a1c59 --- /dev/null +++ b/packages/client-runtime/src/state/piSessionCommands.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vite-plus/test"; +import { parsePiSessionCommand } from "./piSessionCommands.ts"; + +describe("Pi session command parsing", () => { + it("recognizes standalone utilities and an export path with spaces", () => { + expect(parsePiSessionCommand(" /copy ")).toEqual({ command: "copy" }); + expect(parsePiSessionCommand("/share")).toEqual({ command: "share" }); + expect(parsePiSessionCommand("/export")).toEqual({ command: "export" }); + expect(parsePiSessionCommand("/export reports/my session.html")).toEqual({ + command: "export", + outputPath: "reports/my session.html", + }); + }); + + it("rejects arguments to no-argument commands instead of treating them as prompts", () => { + expect(parsePiSessionCommand("/copy this")).toEqual({ + error: "/copy does not accept arguments.", + }); + expect(parsePiSessionCommand("/share this")).toEqual({ + error: "/share does not accept arguments.", + }); + expect(parsePiSessionCommand("/copy\nExplain this")).toEqual({ + error: "/copy does not accept arguments.", + }); + expect(parsePiSessionCommand("/export\nthen explain")).toEqual({ + error: "/export accepts a path on a single line.", + }); + }); + + it("leaves prose and unrecognized commands as prompts", () => { + for (const prompt of ["please /copy", "/exporter", "/copying"]) { + expect(parsePiSessionCommand(prompt)).toBeNull(); + } + }); +}); diff --git a/packages/client-runtime/src/state/piSessionCommands.ts b/packages/client-runtime/src/state/piSessionCommands.ts new file mode 100644 index 000000000000..6bf3a353fdd8 --- /dev/null +++ b/packages/client-runtime/src/state/piSessionCommands.ts @@ -0,0 +1,25 @@ +import { + providerSlashCommandArgumentError, + type ProviderSessionCommandInput, +} from "@t3tools/contracts"; + +/** Recognized utilities never fall through to an agent prompt when arguments are invalid. */ +export function parsePiSessionCommand( + text: string, +): Omit | { readonly error: string } | null { + const trimmed = text.trim(); + const noArgs = /^\/(copy|share)(?:\s+([\s\S]*))?$/.exec(trimmed); + if (noArgs) { + const command = noArgs[1] === "copy" ? "copy" : "share"; + const error = providerSlashCommandArgumentError( + { name: command, argumentMode: "none" }, + noArgs[2] ?? "", + ); + return error ? { error } : { command }; + } + const match = /^\/export(?:\s+([\s\S]*))?$/.exec(trimmed); + if (!match) return null; + if (/[\r\n]/.test(trimmed)) return { error: "/export accepts a path on a single line." }; + const outputPath = match[1]?.trim(); + return { command: "export", ...(outputPath ? { outputPath } : {}) }; +} diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 1de120539d09..93d0d387f90c 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -228,5 +228,11 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + sessionCommand: createEnvironmentRpcCommand(runtime, { + label: "environment-data:commands:thread:session-command", + tag: WS_METHODS.providerSessionCommand, + scheduler, + concurrency, + }), }; } diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 764ef2b11e35..91614c8567a7 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -861,6 +861,7 @@ export * from "./threadSnapshotHttp.ts"; export * from "./composerPathSearch.ts"; export * from "./threadCommands.ts"; export * from "./threadFeedback.ts"; +export { parsePiSessionCommand } from "./piSessionCommands.ts"; export * from "./threadDetail.ts"; export * from "./threadReducer.ts"; export * from "./threadShell.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 5e5fa068119a..9d0d51f8a669 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -79,6 +79,7 @@ import type { ServerProviderUpdatedPayload, ServerUpsertKeybindingResult, } from "./server.ts"; +import type { ProviderSessionCommandInput, ProviderSessionCommandResult } from "./provider.ts"; import type { TerminalClearInput, TerminalCloseInput, @@ -1087,6 +1088,7 @@ export interface LocalApi { input: ServerProviderListCommandsInput, ) => Promise; prewarmProviderSession: (input: ServerProviderPrewarmSessionInput) => Promise; + sessionCommand: (input: ProviderSessionCommandInput) => Promise; listSkills: () => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; getSettings: () => Promise; @@ -1263,6 +1265,7 @@ export interface EnvironmentApi { input: ServerProviderListCommandsInput, ) => Promise; prewarmProviderSession: (input: ServerProviderPrewarmSessionInput) => Promise; + sessionCommand: (input: ProviderSessionCommandInput) => Promise; }; workflow: { run: (input: WorkflowRunInput) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 1f407d9403c7..f36e7b5eda94 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -32,6 +32,9 @@ import { DelegationAuditPageInput, } from "./delegationAudit.ts"; import { + ProviderSessionCommandError, + ProviderSessionCommandInput, + ProviderSessionCommandResult, ProviderUploadFeedbackInput, ProviderUploadFeedbackResult, ProviderUploadFeedbackError, @@ -313,6 +316,7 @@ import { WorkflowRunInput } from "./workflowRuntime.ts"; export const WS_METHODS = { providerConsumeResetCredit: "provider.consumeResetCredit", providerUploadFeedback: "provider.uploadFeedback", + providerSessionCommand: "provider.sessionCommand", serverGetUsageSummary: "server.getUsageSummary", serverRefreshUsageRates: "server.refreshUsageRates", attachmentsCreateUploadUrl: "attachments.createUploadUrl", @@ -1476,6 +1480,12 @@ export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( }, ); +export const WsProviderSessionCommandRpc = Rpc.make(WS_METHODS.providerSessionCommand, { + payload: ProviderSessionCommandInput, + success: ProviderSessionCommandResult, + error: Schema.Union([ProviderSessionCommandError, EnvironmentAuthorizationError]), +}); + export const WsRpcGroup = RpcGroup.make( Rpc.make(ORCHESTRATION_WS_METHODS.readThread, { payload: OrchestrationRpcSchemas.readThread.input, @@ -1490,6 +1500,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerRefreshProvidersRpc, WsServerListProviderCommandsRpc, WsServerPrewarmProviderSessionRpc, + WsProviderSessionCommandRpc, WsServerListSkillsRpc, WsServerUpsertKeybindingRpc, WsServerGetSettingsRpc, diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index aecc7def5a62..9d87a9f18ca9 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -2,12 +2,34 @@ import { Schema } from "effect"; import { describe, expect, it } from "vitest"; import { ServerProvider, ServerProviderListCommandsInput } from "./server.ts"; +import { providerSlashCommandArgumentError } from "./server.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); const decodeServerProviderListCommandsInput = Schema.decodeUnknownSync( ServerProviderListCommandsInput, ); +describe("provider slash command arguments", () => { + it("enforces declared rules while keeping legacy commands unrestricted", () => { + expect(providerSlashCommandArgumentError({ name: "copy", argumentMode: "none" }, "extra")).toBe( + "/copy does not accept arguments.", + ); + expect( + providerSlashCommandArgumentError({ name: "copy", argumentMode: "none" }, " \n"), + ).toBeNull(); + expect(providerSlashCommandArgumentError({ name: "run", argumentMode: "required" }, " ")).toBe( + "/run requires arguments.", + ); + expect( + providerSlashCommandArgumentError({ name: "run", argumentMode: "required" }, "task"), + ).toBeNull(); + expect( + providerSlashCommandArgumentError({ name: "export", argumentMode: "optional" }, ""), + ).toBeNull(); + expect(providerSlashCommandArgumentError({ name: "legacy" }, "extra")).toBeNull(); + }); +}); + describe("ServerProvider", () => { it("defaults capability arrays when decoding provider snapshots", () => { const parsed = decodeServerProvider({