From ea76d5bdcc625077382ebfc5ff25107d3b7e3e58 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sat, 3 Oct 2026 09:04:48 +0000 Subject: [PATCH 01/79] fix(stt): run transcription as a server-side job any browser on the session can follow --- AGENTS.md | 33 ++ CHANGELOG.md | 1 + app/api/stt/[jobId]/audio/route.ts | 23 ++ app/api/stt/[jobId]/route.ts | 52 ++++ app/api/stt/route.ts | 86 +++--- components/ChatInput.tsx | 75 ++--- components/RecordingDeck.tsx | 14 + hooks/useDictation.behavior.test.mjs | 439 ++++++++++++++++++++++++++- hooks/useDictation.test.mjs | 65 ---- hooks/useDictation.ts | 390 +++++++++++++++++++----- lib/i18n/locales/en.json | 1 + lib/i18n/locales/ja.json | 1 + lib/i18n/locales/zh-CN.json | 1 + lib/stt-jobs.ts | 262 ++++++++++++++++ lib/stt-route.test.mjs | 382 ++++++++++++++++++++++- lib/stt.ts | 34 +++ 16 files changed, 1610 insertions(+), 249 deletions(-) create mode 100644 app/api/stt/[jobId]/audio/route.ts create mode 100644 app/api/stt/[jobId]/route.ts create mode 100644 lib/stt-jobs.ts diff --git a/AGENTS.md b/AGENTS.md index 700a14eb8..8610b319c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,9 @@ app/api/ skills/route.ts GET/PATCH loaded skills and disable-model-invocation skills/install/route.ts POST install skills through npx skills add skills/search/route.ts GET/POST skills.sh search + stt/route.ts POST audio (+scope) → 202 { jobId } | GET ?scope= live jobs (lib/stt-jobs.ts) + stt/[jobId]/route.ts GET job state | POST retry with kept audio | DELETE ?claim= claim/discard + stt/[jobId]/audio/route.ts GET the kept recording (audio/* only) for playback worktrees/route.ts GET/POST/DELETE git worktrees lib/ @@ -480,6 +483,36 @@ so omp requeues it on abort and runs it next; omp-web cannot prevent that. `GhostMirror`), not ChatInput state: re-rendering the composer per ghost change was the dominant per-keystroke cost. +### Voice transcription jobs (`lib/stt-jobs.ts`, `/api/stt`, `hooks/useDictation.ts`) +- The browser never waits on the STT endpoint: `POST /api/stt` keeps the + recording in memory and starts a job; the hook polls `GET /api/stt/[jobId]` + (proxies with ~30s timeouts would otherwise return HTML 504s). Job failures + are 200 payloads for the same reason. +- Jobs carry the composer scope (`draftKey`: session id or `new:`). The + hook adopts the newest job for its scope on mount, focus, visibility and + every 4s while visible and idle, so another browser can play (`/audio`), + retry (`POST`) or discard it. Leaving the scope stops following without + discarding. +- A finished job is delivered only by claim: + `DELETE ?claim=&owner=` returns the text to the + first claim token (repeatable with the same token); polls never carry text, + and other composers see `gone` and stand down silently. A claim on an + unfinished job is a no-op; a `DELETE` without `claim` discards and aborts + the upstream request. +- Two tokens, never merged. The tab token (sessionStorage) identifies the + job owner: it survives the composer remounting (`AppShell` keys + `ChatWindow` by session), gets the 15s first claim, and alone gets the + send/queue choice back. A duplicated tab copies it, so exclusivity comes + from the claim token, which is per hook instance. +- The send/queue choice (`after`: send | steer | followup) is uploaded with + the recording, kept on the job, and returned with the owner's claim. Never + keep it only in component state: a session switch remounts the composer and + loses it. A non-owner claim only inserts, since that composer holds its own + draft and attachments. +- Store is per process (`globalThis` map) with caps (4 pending, 20 live) and + TTLs; a server restart loses jobs, and the hook then re-uploads its local + copy if it has one. + ### Completion sound - `hooks/useAudio.ts` stores the toggle in `localStorage` and reuses one `AudioContext`. - Browser autoplay policy means sound must be unlocked from a user gesture; `ChatInput` calls the unlock hook from interactive controls, and `ChatWindow` plays the tone from `onAgentEnd`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 728389cc5..c02b7e7a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - The **Agent environment variables** editor in Settings → System & Updates now sits below its title and description and follows the page width, instead of sitting beside them and extending past the right edge. The text box also grows with its contents. - Show queued steers and follow-ups on every device viewing a session. The queue panel now shows omp's own queue (omp 18.4.4 or later) instead of a copy kept by the tab that sent them, so it also stays correct across reloads. - **Stop** no longer loses a queued steer or lets the agent run it anyway. The text of messages still waiting in the queue moves back into the composer (images attached to them are dropped). Requires omp 18.4.4 or later. A steer the model already picked up through live steering (for example on OpenAI models with `providers.openaiLiveSteering`) cannot be taken back: omp requeues it when the run stops and runs it next. +- Long voice transcriptions no longer fail with `Unexpected token '<', " }) { + const audio = getSttJobAudio((await params).jobId); + if (!audio) { + return NextResponse.json({ error: "Transcription job not found", code: "stt_job_not_found" }, { status: 404 }); + } + // The type came from the uploading browser: serve only a bare audio/* type + // so a crafted upload cannot render as HTML on this origin. + const type = /^audio\/[a-z0-9.+-]+/i.exec(audio.type)?.[0] ?? "application/octet-stream"; + return new Response(audio, { + headers: { + "Content-Type": type, + "Content-Length": String(audio.size), + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + }, + }); +} diff --git a/app/api/stt/[jobId]/route.ts b/app/api/stt/[jobId]/route.ts new file mode 100644 index 000000000..3498d7669 --- /dev/null +++ b/app/api/stt/[jobId]/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { readSttConfig } from "@/lib/stt"; +import { closeSttJob, getSttJob, retrySttJob } from "@/lib/stt-jobs"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ jobId: string }> }; + +const notFound = () => + NextResponse.json({ error: "Transcription job not found", code: "stt_job_not_found" }, { status: 404 }); + +/** + * GET /api/stt/[jobId] — { id, status: "pending" | "done" | "error" | "gone", error? }, + * or 404 for an unknown/expired job. The transcript is never here: claim it + * with DELETE ?claim=. "gone" means another browser claimed or + * discarded it. Job failures are 200 payloads, not 5xx, so an intermediate + * proxy cannot swap them for its own error page. + */ +export async function GET(_req: Request, { params }: Params) { + const job = getSttJob((await params).jobId); + return job ? NextResponse.json(job) : notFound(); +} + +/** POST /api/stt/[jobId]?owner= — retry a failed job with the audio the server kept. */ +export async function POST(req: Request, { params }: Params) { + const config = readSttConfig(); + if (!config) { + return NextResponse.json({ error: "STT not configured. Set OMP_WEB_STT_ENDPOINT." }, { status: 501 }); + } + const owner = new URL(req.url).searchParams.get("owner")?.slice(0, 128) || undefined; + const job = retrySttJob(config, (await params).jobId, owner); + if (job === "busy") { + return NextResponse.json({ error: "Too many transcriptions in progress", code: "stt_busy" }, { status: 429 }); + } + return job ? NextResponse.json(job) : notFound(); +} + +/** + * DELETE /api/stt/[jobId]?claim=&owner= — claim a + * finished transcript, or (without `claim`) discard the job. Returns the job + * as it was before closing: only the first claimer of a "done" job sees + * status "done" with its text (repeatable with the same claim token), and + * only the job's owner also gets its send/queue `after`; everyone else sees + * "gone". + */ +export async function DELETE(req: Request, { params }: Params) { + const query = new URL(req.url).searchParams; + const claim = query.get("claim")?.slice(0, 128) || undefined; + const owner = query.get("owner")?.slice(0, 128) || undefined; + const job = closeSttJob((await params).jobId, claim, owner); + return job ? NextResponse.json(job) : notFound(); +} diff --git a/app/api/stt/route.ts b/app/api/stt/route.ts index 0afc92818..2c31506a3 100644 --- a/app/api/stt/route.ts +++ b/app/api/stt/route.ts @@ -1,40 +1,36 @@ import { NextResponse } from "next/server"; import { parseFormDataWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data"; -import { MAX_STT_AUDIO_BYTES, MAX_STT_REQUEST_BYTES } from "@/lib/stt"; +import { isSttAfter, MAX_STT_AUDIO_BYTES, MAX_STT_REQUEST_BYTES, readSttConfig } from "@/lib/stt"; +import { listSttJobs, startSttJob } from "@/lib/stt-jobs"; export const dynamic = "force-dynamic"; -function extractUpstreamErrorMessage(data: unknown, rawText: string, status: number): string { - if (data && typeof data === "object" && "error" in data) { - const error: unknown = data.error; - if (typeof error === "string" && error.trim()) return error; - if (error && typeof error === "object" && "message" in error) { - const message: unknown = error.message; - if (typeof message === "string" && message.trim()) return message; - } - } - if (rawText.trim()) return rawText.trim().slice(0, 500); - return `Transcription failed (upstream ${status})`; -} +const MAX_SCOPE_LENGTH = 1024; -function cleanEnvVar(val?: string): string | undefined { - const cleaned = val?.replace(/\\n|[\r\n]/g, "").trim(); - return cleaned || undefined; +/** GET /api/stt?scope= — live jobs for that composer, newest first. */ +export async function GET(request: Request) { + const scope = new URL(request.url).searchParams.get("scope"); + if (!scope || scope.length > MAX_SCOPE_LENGTH) { + return NextResponse.json({ error: "scope is required", code: "missing_scope" }, { status: 400 }); + } + return NextResponse.json({ jobs: listSttJobs(scope) }); } +/** + * POST /api/stt (multipart: file, optional scope) — keeps the audio and starts + * a server-side transcription job. Returns 202 { jobId }; poll + * GET /api/stt/[jobId] for the result. + */ export async function POST(request: Request) { - let apiKey: string | undefined; try { - const endpoint = cleanEnvVar(process.env.OMP_WEB_STT_ENDPOINT); - if (!endpoint) { + const config = readSttConfig(); + if (!config) { return NextResponse.json( { error: "STT not configured. Set OMP_WEB_STT_ENDPOINT." }, { status: 501 } ); } - apiKey = cleanEnvVar(process.env.OMP_WEB_STT_KEY); - const model = cleanEnvVar(process.env.OMP_WEB_STT_MODEL); const formData = await parseFormDataWithinLimit(request, MAX_STT_REQUEST_BYTES); const file = formData.get("file"); if (!file || typeof file === "string" || file.size === 0) { @@ -49,37 +45,31 @@ export async function POST(request: Request) { { status: 413 } ); } - - if (model && !formData.has("model")) { - formData.append("model", model); + const scope = formData.get("scope"); + if (scope !== null && (typeof scope !== "string" || scope.length > MAX_SCOPE_LENGTH)) { + return NextResponse.json({ error: "Invalid scope", code: "invalid_scope" }, { status: 400 }); } - - const res = await fetch(endpoint, { - method: "POST", - headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, - body: formData, - signal: AbortSignal.timeout(60000), - }); - - const rawText = await res.text(); - let data: unknown = null; - try { - data = rawText ? JSON.parse(rawText) : {}; - } catch { - data = null; + // The uploader's claim token: it gets first claim on the transcript. + const owner = formData.get("owner"); + // What to do with the transcript (send/steer/followup); absent = insert. + const after = formData.get("after"); + if (after !== null && !isSttAfter(after)) { + return NextResponse.json({ error: "Invalid after", code: "invalid_after" }, { status: 400 }); } - if (!res.ok) { + const jobId = startSttJob(config, { + audio: file, + scope: scope || null, + owner: typeof owner === "string" && owner ? owner.slice(0, 128) : undefined, + after: after ?? undefined, + }); + if (!jobId) { return NextResponse.json( - { error: extractUpstreamErrorMessage(data, rawText, res.status) }, - { status: res.status } + { error: "Too many transcriptions in progress", code: "stt_busy" }, + { status: 429 } ); } - - if (data !== null) { - return NextResponse.json(data, { status: res.status }); - } - return NextResponse.json({ text: rawText }, { status: res.status }); + return NextResponse.json({ jobId }, { status: 202 }); } catch (error) { if (error instanceof RequestBodyTooLargeError) { return NextResponse.json( @@ -87,8 +77,6 @@ export async function POST(request: Request) { { status: 413 } ); } - const rawMsg = error instanceof Error ? error.message : String(error); - const safeMsg = apiKey ? rawMsg.replaceAll(apiKey, "[REDACTED]") : rawMsg; - return NextResponse.json({ error: safeMsg }, { status: 500 }); + return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); } } diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index de70c4399..6b06026a3 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -10,6 +10,7 @@ import { ConfirmDialog } from "@/components/ui/field"; import { useDictation } from "@/hooks/useDictation"; import { toastBtwError } from "@/hooks/useBtw"; import type { AnthropicSlowModeState, GenerationSpeedInfo, SessionStatsInfo } from "@/lib/pi-types"; +import type { SttAfter } from "@/lib/stt"; import { formatCompactNumber, formatPercent } from "@/lib/format"; import { ContextDetailPanel } from "./ComposerPanels"; import { RecordingDeck } from "./RecordingDeck"; @@ -856,11 +857,6 @@ export const ChatInput = memo(forwardRef(function ChatIn onSend(composedMessage, attachedImagesRef.current.length ? attachedImagesRef.current : undefined); clearInput(); }, [value, isStreaming, onBuiltinCommand, onSend, clearInput, onAudioUnlock, rejectsOversizedPrompt, runBuiltinCommand, sendSideQuestion]); - /** What happens to the composer after the transcript lands: null inserts it - * for editing; "send" dispatches immediately; "steer"/"followup" queue it - * into the running agent. */ - type DictationAfterMode = "send" | "steer" | "followup"; - const dictationAfterRef = useRef(null); const { isRecording, isPaused, @@ -881,17 +877,26 @@ export const ChatInput = memo(forwardRef(function ChatIn seekPreview: seekPreviewDictation, confirmTranscribe: confirmTranscribeDictation, } = useDictation({ - onTranscript: (text) => { - const after = dictationAfterRef.current; - dictationAfterRef.current = null; + scope: draftKey, + // `after` comes back with the transcript from the server job, so a send or + // queue chosen before a session switch (which remounts this composer) holds. + // The run may have started or ended since: queue while the agent runs + // (Send becomes a follow-up), send when idle (nothing left to queue behind). + onTranscript: (text, after) => { const base = valueRef.current; const sep = base.length > 0 && !base.endsWith(" ") ? " " : ""; const finalText = base + sep + text; insertTextAtCursor(text); - if (after === "send") { + if (after && onFollowUp) { + // Queued messages are text-only: with attachments in the composer the + // queue would refuse it, so keep it here and say why. + if (attachedImagesRef.current.length || attachedTextFilesRef.current.length) { + toast.info(t("chatInput.dictationKeptWithAttachments")); + } else { + sendQueued(after === "send" ? "followup" : after, finalText); + } + } else if (after && !isStreaming) { void handleSend(finalText); - } else if (after === "steer" || after === "followup") { - sendQueued(after, finalText); } else { toast.success(t("chatInput.dictationSuccess")); } @@ -912,26 +917,12 @@ export const ChatInput = memo(forwardRef(function ChatIn toast.error(msg); }, }); - const stopAndInsertDictation = useCallback(() => { - dictationAfterRef.current = null; - stopDictation(); - }, [stopDictation]); - const stopAndSendDictation = useCallback(() => { - dictationAfterRef.current = "send"; - stopDictation({ immediateSend: true }); - }, [stopDictation]); - const stopAndQueueDictation = useCallback((mode: "steer" | "followup") => { - dictationAfterRef.current = mode; - stopDictation({ immediateSend: true }); - }, [stopDictation]); - const cancelDictationAndReset = useCallback(() => { - dictationAfterRef.current = null; - cancelDictation(); - }, [cancelDictation]); - const startFreshDictation = useCallback(() => { - dictationAfterRef.current = null; - toggleDictation(); - }, [toggleDictation]); + // From the review deck the recorder has already stopped, so finishing with a + // send/queue choice transcribes the reviewed recording instead. + const stopAndFinishDictation = useCallback((after: SttAfter) => { + if (isReviewing) confirmTranscribeDictation({ after }); + else stopDictation({ after }); + }, [isReviewing, confirmTranscribeDictation, stopDictation]); // While the recording deck replaces the textarea there is no focused input, // so Escape/Enter are handled at window level: Escape cancels/discard, Enter // retries after an error or converts the recording to text. @@ -940,7 +931,7 @@ export const ChatInput = memo(forwardRef(function ChatIn const onKeyDown = (e: globalThis.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); - cancelDictationAndReset(); + cancelDictation(); } else if (e.key === "Enter" && !e.shiftKey && !isTranscribing) { // Let focused deck/toolbar controls keep their own activation; // only hijack Enter from the non-interactive page context. @@ -949,12 +940,12 @@ export const ChatInput = memo(forwardRef(function ChatIn e.preventDefault(); if (transcribeError) retryDictation(); else if (isReviewing) confirmTranscribeDictation(); - else stopAndInsertDictation(); + else stopDictation(); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [isRecording, isPaused, isReviewing, isTranscribing, transcribeError, cancelDictationAndReset, retryDictation, confirmTranscribeDictation, stopAndInsertDictation]); + }, [isRecording, isPaused, isReviewing, isTranscribing, transcribeError, cancelDictation, retryDictation, confirmTranscribeDictation, stopDictation]); const slashQuery = value.startsWith("/") && !/\s/.test(value.slice(1)) ? value.slice(1).toLowerCase() @@ -1433,7 +1424,7 @@ export const ChatInput = memo(forwardRef(function ChatIn } if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "m") { e.preventDefault(); - startFreshDictation(); + toggleDictation(); return; } @@ -1580,7 +1571,7 @@ export const ChatInput = memo(forwardRef(function ChatIn } } }, - [isMobile, isStreaming, onSteer, onFollowUp, onAbort, onMinimize, slashMenuOpen, slashQuery, filteredSlashCommands, slashActiveIndex, applySlashCommand, sendQueued, handleSend, getNextSlashIndex, atMenuOpen, atQuery, atMatches, atActiveIndex, applyAtCompletion, historyMenuOpen, inputHistory, historyActiveIndex, applyHistoryInput, value, startFreshDictation, wordPrediction] + [isMobile, isStreaming, onSteer, onFollowUp, onAbort, onMinimize, slashMenuOpen, slashQuery, filteredSlashCommands, slashActiveIndex, applySlashCommand, sendQueued, handleSend, getNextSlashIndex, atMenuOpen, atQuery, atMatches, atActiveIndex, applyAtCompletion, historyMenuOpen, inputHistory, historyActiveIndex, applyHistoryInput, value, toggleDictation, wordPrediction] ); @@ -2603,12 +2594,12 @@ export const ChatInput = memo(forwardRef(function ChatIn previewDuration={previewDuration} transcribeError={transcribeError} onPauseResume={togglePauseDictation} - onConvert={stopAndInsertDictation} + onConvert={stopDictation} onRetry={retryDictation} onPlayPreview={isPlayingPreview ? pausePreviewDictation : playPreviewDictation} onSeekPreview={seekPreviewDictation} onConfirmTranscribe={confirmTranscribeDictation} - onDiscard={cancelDictationAndReset} + onDiscard={cancelDictation} /> ) : (
@@ -3228,7 +3219,7 @@ export const ChatInput = memo(forwardRef(function ChatIn {isRecording || isPaused || isReviewing || isTranscribing || transcribeError ? ( )} - {/* Claude usage-limit stage (wrap-up allowance or /slow low + {/* Slow toggle — only when omp reports /slow applies to the + active model. A `global` scope is a persisted omp setting + shared by every session; `session` is this session's flex + tier — the tooltip says which. */} + {slowModeSupported && onSlowModeChange && ( + + )} + + {/* Provider usage-limit stage (wrap-up allowance or /slow low priority), warning-colored like the TUI status line so past-the-limit service is never mistaken for normal. Layout lives in globals.css (own row on mobile). */} - {anthropicSlowModeLabel && ( -
- {anthropicSlowModeLabel} + {usageLimitLabel && ( +
+ {usageLimitLabel}
)} diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 298642f18..3437b763e 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -524,7 +524,7 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa const { loading, error, messages, entryIds, showPreCompactionHistory, streamState, - agentRunning, bashRunning, pendingBash, modelNames, modelList, modelsLoading, modelError, modelThinkingLevels, modelThinkingLevelMaps, thinkingLevel, fastModeEnabled, fastModeActive, anthropicSlowMode, + agentRunning, bashRunning, pendingBash, modelNames, modelList, modelsLoading, modelError, modelThinkingLevels, modelThinkingLevelMaps, thinkingLevel, fastModeEnabled, fastModeActive, slowModeSupported, slowModeEnabled, slowModeScope, usageLimit, externalRunActive, toolPreset, liveModelMeta, @@ -542,7 +542,7 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction, handleCompact, removeQueuedMessage, promoteQueuedToSteer, handleBuiltinSlashCommand, togglePreCompactionHistory, - handleThinkingLevelChange, handleFastModeChange, handleCycleModel, handleCycleThinkingLevel, handleAbortRetry, loadSlashCommands, + handleThinkingLevelChange, handleFastModeChange, handleSlowModeChange, handleCycleModel, handleCycleThinkingLevel, handleAbortRetry, loadSlashCommands, handleToolPresetChange, btw, askBtw, } = useAgentSession({ @@ -1064,9 +1064,13 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa onToolPresetChange={handleToolPresetChange} fastModeEnabled={fastModeEnabled} fastModeActive={fastModeActive} - anthropicSlowMode={anthropicSlowMode} + usageLimit={usageLimit} fastModeSupported={Boolean(displayModelValue && modelList.some((entry) => entry.provider === displayModelValue.provider && entry.id === displayModelValue.modelId && entry.supportsFastMode))} onFastModeChange={session || isNew ? handleFastModeChange : undefined} + slowModeSupported={slowModeSupported} + slowModeEnabled={slowModeEnabled} + slowModeScope={slowModeScope} + onSlowModeChange={session ? handleSlowModeChange : undefined} onAbortRetry={session ? handleAbortRetry : undefined} availableThinkingLevels={availableThinkingLevels} thinkingLevelMap={currentThinkingLevelMap} diff --git a/hooks/useAgentSession-stream.ts b/hooks/useAgentSession-stream.ts index 62f280054..3f41c3b02 100644 --- a/hooks/useAgentSession-stream.ts +++ b/hooks/useAgentSession-stream.ts @@ -10,7 +10,7 @@ import type { SessionTreeNode, } from "@/lib/types"; import type { ThinkingModelMeta } from "@/lib/thinking-levels"; -import type { AnthropicSlowModeState, QueuedMessages, RpcAvailableSlashCommand, TodoPhase } from "@/lib/pi-types"; +import type { QueuedMessages, RpcAvailableSlashCommand, SlowModeScope, TodoPhase, UsageLimitState } from "@/lib/pi-types"; import type { SubagentHistoryEntry, SubagentInfo, @@ -157,7 +157,10 @@ export type AgentStateResponse = { thinkingLevel?: string; fastModeEnabled?: boolean; fastModeActive?: boolean; - anthropicSlowMode?: AnthropicSlowModeState; + slowModeSupported?: boolean; + slowModeEnabled?: boolean; + slowModeScope?: SlowModeScope; + usageLimit?: UsageLimitState; autoRetryEnabled?: boolean; interruptMode?: "immediate" | "wait"; autoCompactionEnabled?: boolean; diff --git a/hooks/useAgentSession.rpc.test.mjs b/hooks/useAgentSession.rpc.test.mjs index a7b2eda3b..7ae8b0303 100644 --- a/hooks/useAgentSession.rpc.test.mjs +++ b/hooks/useAgentSession.rpc.test.mjs @@ -2702,30 +2702,103 @@ const SLOW_STATE = { stage: "low_priority", resetsAtSec: 1770000000, allowanceLe test("opening a session past its Claude usage limit shows the badge, and an idle /slow off clears it", async () => { resetWorld(); primeSession("s1", [userMsg("u0", "q")]); - world.agents.set("s1", { running: true, state: { model: SLOW_MODEL, anthropicSlowMode: SLOW_STATE } }); + world.agents.set("s1", { running: true, state: { model: SLOW_MODEL, usageLimit: SLOW_STATE } }); const w = await mountSession("s1"); - assert.deepEqual(w.latest.anthropicSlowMode, SLOW_STATE); + assert.deepEqual(w.latest.usageLimit, SLOW_STATE); world.agents.set("s1", { running: true, state: { model: SLOW_MODEL } }); await act(async () => { lastEs().emit({ type: "prompt_result", agentInvoked: false }); }); await settle(); - assert.equal(w.latest.anthropicSlowMode, undefined); + assert.equal(w.latest.usageLimit, undefined); }); test("the usage-limit badge appears mid-run and clears when the run ends without it", async () => { resetWorld(); primeSession("s1", [userMsg("u0", "q")]); const { w, es } = await startStreamingRun("s1"); - world.agents.set("s1", { running: true, state: { isStreaming: true, model: SLOW_MODEL, anthropicSlowMode: SLOW_STATE } }); + world.agents.set("s1", { running: true, state: { isStreaming: true, model: SLOW_MODEL, usageLimit: SLOW_STATE } }); // The in-run sample ticks every 2s; wait for it rather than a fixed sleep. - for (let waited = 0; w.latest.anthropicSlowMode !== SLOW_STATE && waited < 5000; waited += 250) await settle(250); - assert.deepEqual(w.latest.anthropicSlowMode, SLOW_STATE); + for (let waited = 0; w.latest.usageLimit !== SLOW_STATE && waited < 5000; waited += 250) await settle(250); + assert.deepEqual(w.latest.usageLimit, SLOW_STATE); saveSession("s1", [userMsg("u0", "q"), assistantMsg("a1", "done")]); world.agents.set("s1", { running: false, state: { model: SLOW_MODEL } }); await act(async () => { es.emit({ type: "agent_end", isTerminal: true }); }); await settle(); - assert.equal(w.latest.anthropicSlowMode, undefined); + assert.equal(w.latest.usageLimit, undefined); +}); + +test("the Slow toggle follows omp's per-model state and is cleared by a switch to an unsupported model", async () => { + resetWorld(); + primeSession("s1", [userMsg("u0", "q")]); + world.agents.set("s1", { running: true, state: { model: SLOW_MODEL, slowModeSupported: true, slowModeEnabled: true } }); + const w = await mountSession("s1"); + assert.equal(w.latest.slowModeSupported, true); + assert.equal(w.latest.slowModeEnabled, true); + + // A model without /slow reports supported:false and omits enabled; the + // persisted Claude setting may still be on, but it must not show as pressed. + world.agents.set("s1", { running: true, state: { model: { provider: "openrouter", id: "other" }, slowModeSupported: false } }); + await act(async () => { lastEs().emit({ type: "model_changed" }); }); + await settle(); + assert.equal(w.latest.slowModeSupported, false); + assert.equal(w.latest.slowModeEnabled, false); +}); + +/** Mounts s1 with Slow at `enabled`, answers the next set_slow_mode with `answer`, and parks the follow-up state refresh so only the command's answer can move the toggle. */ +async function mountSlowToggle(enabled, answer) { + resetWorld(); + primeSession("s1", [userMsg("u0", "q")]); + world.agents.set("s1", { running: true, state: { model: SLOW_MODEL, slowModeSupported: true, slowModeEnabled: enabled, slowModeScope: "global" } }); + const w = await mountSession("s1"); + assert.equal(w.latest.slowModeEnabled, enabled); + world.holds.push({ + match: (method, _url, body) => method === "POST" && body?.type === "set_slow_mode", + produce: async () => answer, + }); + world.holds.push({ + match: (method, url) => method === "GET" && url === "/api/sessions/s1/state", + produce: () => new Promise(() => {}), + }); + return w; +} + +for (const enabled of [false, true]) { + test(`turning Slow ${enabled ? "on" : "off"} sends set_slow_mode and applies omp's answer`, async () => { + const w = await mountSlowToggle(!enabled, { value: { success: true, data: { enabled } } }); + const posted = world.calls.length; + await act(async () => { await w.latest.handleSlowModeChange(enabled); }); + const command = world.calls.slice(posted).find((call) => call.body?.type === "set_slow_mode"); + assert.deepEqual(command?.body, { type: "set_slow_mode", enabled }); + assert.equal(w.latest.slowModeEnabled, enabled); + assert.deepEqual(w.latest.notices, []); + }); +} + +test("a refused set_slow_mode leaves the toggle as it was and shows omp's error", async () => { + const refusal = "Slow mode is unavailable for the current model."; + const w = await mountSlowToggle(false, { status: 400, value: { error: refusal } }); + await act(async () => { await w.latest.handleSlowModeChange(true); }); + assert.equal(w.latest.slowModeEnabled, false); + assert.deepEqual(w.latest.notices.map((n) => [n.type, n.message]), [["error", refusal]]); +}); + +test("the Slow scope follows omp's state and clears with support", async () => { + resetWorld(); + primeSession("s1", [userMsg("u0", "q")]); + world.agents.set("s1", { running: true, state: { model: SLOW_MODEL, slowModeSupported: true, slowModeEnabled: false, slowModeScope: "global" } }); + const w = await mountSession("s1"); + assert.equal(w.latest.slowModeScope, "global"); + + world.agents.set("s1", { running: true, state: { model: { provider: "openai", id: "gpt-test" }, slowModeSupported: true, slowModeEnabled: false, slowModeScope: "session" } }); + await act(async () => { lastEs().emit({ type: "model_changed" }); }); + await settle(); + assert.equal(w.latest.slowModeScope, "session"); + + world.agents.set("s1", { running: true, state: { model: { provider: "openrouter", id: "other" }, slowModeSupported: false } }); + await act(async () => { lastEs().emit({ type: "model_changed" }); }); + await settle(); + assert.equal(w.latest.slowModeScope, undefined); }); test("HTTP discovery of a new wrapper replaces an old still-open stream before hydrating it", async () => { diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 00b1c7724..93588d58d 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -29,7 +29,7 @@ import { toast } from "@/components/ui/toast"; import { expandWebSlashCommand } from "@/lib/web-slash-commands"; import { validateOutgoingPrompt } from "@/lib/image-attachments"; import { createActiveGoal, parseActiveGoal, type ActiveGoal, type ActivePlan } from "@/lib/web-mode-state"; -import type { AnthropicSlowModeState, HostToolDefinition, HostUriSchemeDefinition, RpcAskDialogAnswer, RpcAvailableSlashCommand, SessionStatsInfo, TodoPhase } from "@/lib/pi-types"; +import type { HostToolDefinition, HostUriSchemeDefinition, RpcAskDialogAnswer, RpcAvailableSlashCommand, SessionStatsInfo, SlowModeScope, TodoPhase, UsageLimitState } from "@/lib/pi-types"; import { isRecord } from "@/lib/type-guards"; import { subscribeSessionsChanged } from "@/lib/session-change-bus"; import { createSessionCatchUp, type SessionCatchUp, type SessionLiveFields } from "./useAgentSession-sync"; @@ -231,8 +231,13 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const [thinkingLevel, setThinkingLevel] = useState("auto"); const [fastModeEnabled, setFastModeEnabled] = useState(false); const [fastModeActive, setFastModeActive] = useState(undefined); - // omp's Claude usage-limit stage (wrap-up allowance / /slow low priority). - const [anthropicSlowMode, setAnthropicSlowMode] = useState(undefined); + // `/slow` for the active model, as omp reports it: false whenever the model + // cannot use it, so it is re-read from every state, never carried over. + const [slowModeSupported, setSlowModeSupported] = useState(false); + const [slowModeEnabled, setSlowModeEnabled] = useState(false); + const [slowModeScope, setSlowModeScope] = useState(undefined); + // omp's provider usage-limit stage (wrap-up allowance / /slow low priority). + const [usageLimit, setUsageLimit] = useState(undefined); // Runtime session modes returned by get_state and changed via RPC // (set_interrupt_mode / set_auto_compaction). const [interruptMode, setInterruptMode] = useState<"immediate" | "wait">("immediate"); @@ -737,7 +742,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) { setFastModeEnabled(agentState.state.fastModeEnabled); } setFastModeActive(agentState.state?.fastModeActive); - setAnthropicSlowMode(agentState.state?.anthropicSlowMode); + setSlowModeSupported(agentState.state?.slowModeSupported ?? false); + setSlowModeEnabled(agentState.state?.slowModeEnabled ?? false); + setSlowModeScope(agentState.state?.slowModeScope); + setUsageLimit(agentState.state?.usageLimit); if (agentState.state?.autoRetryEnabled !== undefined) setAutoRetryEnabled(agentState.state.autoRetryEnabled); if (agentState.state?.interruptMode !== undefined) setInterruptMode(agentState.state.interruptMode); if (agentState.state?.autoCompactionEnabled !== undefined) setAutoCompactionEnabled(agentState.state.autoCompactionEnabled); @@ -834,7 +842,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (modelApplied && liveState.thinkingLevel !== undefined) setThinkingLevel(normalizeThinkingLevel(liveState.thinkingLevel)); if (liveState.fastModeEnabled !== undefined) setFastModeEnabled(liveState.fastModeEnabled); setFastModeActive(liveState.fastModeActive); - setAnthropicSlowMode(liveState.anthropicSlowMode); + setSlowModeSupported(liveState.slowModeSupported ?? false); + setSlowModeEnabled(liveState.slowModeEnabled ?? false); + setSlowModeScope(liveState.slowModeScope); + setUsageLimit(liveState.usageLimit); if (liveState.autoRetryEnabled !== undefined) setAutoRetryEnabled(liveState.autoRetryEnabled); if (liveState.interruptMode !== undefined) setInterruptMode(liveState.interruptMode); if (liveState.autoCompactionEnabled !== undefined) setAutoCompactionEnabled(liveState.autoCompactionEnabled); @@ -1766,7 +1777,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (cancelled) return; const tps = data?.state?.tokensPerSecond; setTokensPerSecond(typeof tps === "number" && Number.isFinite(tps) && tps > 0 ? tps : null); - if (data?.state) setAnthropicSlowMode(data.state.anthropicSlowMode); + if (data?.state) setUsageLimit(data.state.usageLimit); }) .catch(() => {}); return () => { cancelled = true; }; @@ -1782,8 +1793,8 @@ export function useAgentSession(opts: UseAgentSessionOptions) { // A sample in flight when the run ends must not overwrite the end-of-run state. if (cancelled) return; const tps = data?.state?.tokensPerSecond; - // The usage-limit badge changes per Anthropic response (lane entry, % left). - if (data?.state) setAnthropicSlowMode(data.state.anthropicSlowMode); + // The usage-limit badge changes per provider response (lane entry, % left). + if (data?.state) setUsageLimit(data.state.usageLimit); setTokensPerSecond(typeof tps === "number" && Number.isFinite(tps) && tps > 0 ? tps : null); }) .catch(() => {}); @@ -1979,7 +1990,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) { // composer toggle stuck on a stale value. if (d.state?.fastModeEnabled !== undefined) setFastModeEnabled(d.state.fastModeEnabled); setFastModeActive(d.state?.fastModeActive); - setAnthropicSlowMode(d.state?.anthropicSlowMode); + setSlowModeSupported(d.state?.slowModeSupported ?? false); + setSlowModeEnabled(d.state?.slowModeEnabled ?? false); + setSlowModeScope(d.state?.slowModeScope); + setUsageLimit(d.state?.usageLimit); if (d.state?.extensionStatuses !== undefined) setExtensionStatuses(d.state.extensionStatuses ?? []); if (d.state?.extensionWidgets !== undefined) setExtensionWidgets(d.state.extensionWidgets ?? []); if (d.state?.todoPhases !== undefined) setTodoPhases(d.state.todoPhases ?? []); @@ -2089,7 +2103,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (d.state.thinkingLevel !== undefined) setThinkingLevel(normalizeThinkingLevel(d.state.thinkingLevel)); if (d.state.fastModeEnabled !== undefined) setFastModeEnabled(d.state.fastModeEnabled); setFastModeActive(d.state.fastModeActive); - setAnthropicSlowMode(d.state.anthropicSlowMode); + setSlowModeSupported(d.state.slowModeSupported ?? false); + setSlowModeEnabled(d.state.slowModeEnabled ?? false); + setSlowModeScope(d.state.slowModeScope); + setUsageLimit(d.state.usageLimit); if (d.state.autoRetryEnabled !== undefined) setAutoRetryEnabled(d.state.autoRetryEnabled); if (d.state.interruptMode !== undefined) setInterruptMode(d.state.interruptMode); if (d.state.autoCompactionEnabled !== undefined) setAutoCompactionEnabled(d.state.autoCompactionEnabled); @@ -2987,6 +3004,20 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } }, [addNotice, ensureNewSession, refreshLiveModelState]); + const handleSlowModeChange = useCallback(async (enabled: boolean) => { + // The toggle only renders from live get_state, so a session always exists. + const sid = sessionIdRef.current; + if (!sid) return; + try { + const result = await sendAgentCommand<{ enabled?: boolean }>(sid, { type: "set_slow_mode", enabled }); + setSlowModeEnabled(result?.enabled ?? enabled); + void refreshLiveModelState(sid); + } catch (error) { + console.error("Failed to change Slow mode:", error); + addNotice({ type: "error", message: error instanceof Error ? error.message : String(error) }); + } + }, [addNotice, refreshLiveModelState]); + /** Toggle automatic retry for transient model failures. */ const handleAutoRetryChange = useCallback(async (enabled: boolean) => { const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current; @@ -3723,7 +3754,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { return { // State data, loading, error, activeLeafId, messages, entryIds, showPreCompactionHistory, streamState, - agentRunning, modelNames, modelList, modelsLoading, modelError, modelThinkingLevels, modelThinkingLevelMaps, newSessionModel, toolPreset, thinkingLevel, fastModeEnabled, fastModeActive, anthropicSlowMode, autoRetryEnabled, interruptMode, autoCompactionEnabled, steeringMode, followUpMode, + agentRunning, modelNames, modelList, modelsLoading, modelError, modelThinkingLevels, modelThinkingLevelMaps, newSessionModel, toolPreset, thinkingLevel, fastModeEnabled, fastModeActive, slowModeSupported, slowModeEnabled, slowModeScope, usageLimit, autoRetryEnabled, interruptMode, autoCompactionEnabled, steeringMode, followUpMode, liveModelMeta, retryInfo, contextUsage, systemPrompt, forkingEntryId, isCompacting, compactError, compactResult, tokensPerSecond, currentModel, displayModel, isAutoModelSelection: !displayModel, sessionStats, agentPhase, @@ -3737,7 +3768,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { sessionIdRef, messagesEndRef, scrollContainerRef, pendingScrollToUserRef, initialScrollDoneRef, // Actions - handleSend, handleAbort, handleFork, handleNavigate, handleModelChange, handleFastModeChange, handleAutoRetryChange, handleInterruptModeChange, handleAutoCompactionChange, handleSteeringModeChange, handleFollowUpModeChange, handleCycleModel, handleCycleThinkingLevel, handleAbortRetry, handleInterruptAndReply, + handleSend, handleAbort, handleFork, handleNavigate, handleModelChange, handleFastModeChange, handleSlowModeChange, handleAutoRetryChange, handleInterruptModeChange, handleAutoCompactionChange, handleSteeringModeChange, handleFollowUpModeChange, handleCycleModel, handleCycleThinkingLevel, handleAbortRetry, handleInterruptAndReply, retrySession: () => { const sid = sessionIdRef.current; if (sid) void loadSession(sid, true, true); }, handleCompact, handleHandoff, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction, handleBuiltinSlashCommand, togglePreCompactionHistory, diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index 5ad0f3143..3986a8d8c 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -335,6 +335,9 @@ "chatInput.stopDictation": "Stop recording", "chatInput.transcribeDictation": "Transcribe", "chatInput.fastLabel": "Fast", + "chatInput.slowLabel": "Slow", + "chatInput.slowTitleGlobal": "Slow: changes a saved omp setting shared by every session and terminal, not just this one.", + "chatInput.slowTitleSession": "Slow: switches only this session to the provider's slower, cheaper flex tier.", "chatInput.slowMode.lowPriority": "low priority until {time}", "chatInput.slowMode.lowPriorityAllowance": "low priority until {time} · {percent}% left", "chatInput.slowMode.lowPriorityNoReset": "low priority", diff --git a/lib/i18n/locales/ja.json b/lib/i18n/locales/ja.json index 813986b02..2fb13cc69 100644 --- a/lib/i18n/locales/ja.json +++ b/lib/i18n/locales/ja.json @@ -322,6 +322,9 @@ "chatInput.stopDictation": "録音を停止", "chatInput.transcribeDictation": "文字起こし", "chatInput.fastLabel": "高速", + "chatInput.slowLabel": "低速", + "chatInput.slowTitleGlobal": "低速:このセッションだけでなく、すべてのセッションとターミナルで共有される保存済みの omp 設定を変更します。", + "chatInput.slowTitleSession": "低速:このセッションだけを、プロバイダーの低速・低コストな flex ティアに切り替えます。", "chatInput.slowMode.lowPriority": "{time} まで低優先度", "chatInput.slowMode.lowPriorityAllowance": "{time} まで低優先度 · 残り {percent}%", "chatInput.slowMode.lowPriorityNoReset": "低優先度", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index fe5ac1ca9..4179efb27 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -322,6 +322,9 @@ "chatInput.stopDictation": "停止录音", "chatInput.transcribeDictation": "转写", "chatInput.fastLabel": "快速", + "chatInput.slowLabel": "慢速", + "chatInput.slowTitleGlobal": "慢速:更改已保存的 omp 设置,该设置由所有会话和终端共享,而不仅是当前会话。", + "chatInput.slowTitleSession": "慢速:仅将当前会话切换到提供商更慢、更便宜的 flex 层级。", "chatInput.slowMode.lowPriority": "低优先级至 {time}", "chatInput.slowMode.lowPriorityAllowance": "低优先级至 {time} · 剩余 {percent}%", "chatInput.slowMode.lowPriorityNoReset": "低优先级", diff --git a/lib/pi-types.ts b/lib/pi-types.ts index 9a274c076..775285335 100644 --- a/lib/pi-types.ts +++ b/lib/pi-types.ts @@ -8,10 +8,12 @@ export interface ContextUsage { tokens: number | null; } -export type AnthropicSlowModeState = +export type UsageLimitState = | { stage: "low_priority"; resetsAtSec: number; allowanceLeftPercent?: number } | { stage: "wrap_up"; resetsAtSec?: number; extraUsage: boolean }; +export type SlowModeScope = "session" | "global"; + export interface ModelLike { id: string; provider: string; @@ -83,8 +85,14 @@ export interface RpcSessionState { fastMode?: boolean; fastModeEnabled?: boolean; fastModeActive?: boolean; - /** Structured Claude usage-limit state; absent outside wrap-up and low priority. */ - anthropicSlowMode?: AnthropicSlowModeState; + /** `/slow` applies to the active model (omp ≥ slow-mode RPC). */ + slowModeSupported?: boolean; + /** Slow setting for the active model; false whenever unsupported. */ + slowModeEnabled?: boolean; + /** Present iff supported: `global` = persisted omp config shared by every session/terminal; `session` = this session's flex tier. */ + slowModeScope?: SlowModeScope; + /** Structured provider usage-limit state; absent outside wrap-up and low priority. */ + usageLimit?: UsageLimitState; /** omp's reported output throughput; null/undefined when not generating. */ tokensPerSecond?: number | null; } @@ -116,7 +124,10 @@ export interface WebSessionState { thinkingLevel: string; fastModeEnabled: boolean; fastModeActive?: boolean; - anthropicSlowMode?: AnthropicSlowModeState; + slowModeSupported: boolean; + slowModeEnabled: boolean; + slowModeScope?: SlowModeScope; + usageLimit?: UsageLimitState; autoRetryEnabled?: boolean; tokensPerSecond?: number | null; todoPhases: TodoPhase[]; diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 173b0dfeb..c9d7dbc65 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -1200,7 +1200,10 @@ export class AgentSessionWrapper { // The wrapper's own flag is only the spawn-time cache. fastModeEnabled: state.fastModeEnabled ?? state.fastMode ?? this.fastModeEnabled, fastModeActive: state.fastModeActive, - anthropicSlowMode: state.anthropicSlowMode, + slowModeSupported: state.slowModeSupported ?? false, + slowModeEnabled: state.slowModeEnabled ?? false, + slowModeScope: state.slowModeScope, + usageLimit: state.usageLimit, todoPhases: state.todoPhases ?? [], extensionStatuses: Array.from(this.extensionStatuses, ([key, text]) => ({ key, text })), extensionWidgets: Array.from(this.extensionWidgets.values()), @@ -1462,6 +1465,12 @@ export class AgentSessionWrapper { return { enabled: this.fastModeEnabled, active: result?.active ?? false }; } + case "set_slow_mode": { + const enabled = command.enabled === true; + const result = await this.proc.sendCommand<{ enabled?: boolean }>({ type: "set_slow_mode", enabled }); + return { enabled: result?.enabled ?? enabled }; + } + case "fork": { // omp's `branch` is pi-web's fork: it creates a branched session file // and switches this live process onto it (entryId must be a user From 1a3108cd1f5e19512e193297d8df7c67eac43c57 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sat, 3 Oct 2026 09:20:35 +0000 Subject: [PATCH 04/79] fix(queue): withdraw the queue through omp's atomic Esc on Stop Stop withdrew queued messages one by one from this client's queue snapshot before aborting. A steer missing from that snapshot (promoted after the last queue_update) or claimed by the run but not yet recorded was never withdrawn, and omp drained it into a new turn right after the abort: the user pressed Stop and the steer ran anyway. Stop now sends abort_and_restore_queue, omp's Esc in one step (clearQueue forInterrupt, then abort), and restores the returned texts to the draft. omp without the command falls back to the per-entry withdrawal. --- AGENTS.md | 23 +++++++++++--------- CHANGELOG.md | 2 +- hooks/useAgentSession.rpc.test.mjs | 35 ++++++++++++++++++++++++++++++ hooks/useAgentSession.ts | 29 +++++++++++++++++++++---- lib/rpc-manager.test.mjs | 19 ++++++++++++++++ lib/rpc-manager.ts | 9 ++++++-- 6 files changed, 100 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8610b319c..46eafda62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,16 +235,19 @@ a number when requested and applies only if no newer snapshot or `queue_update` was applied (HTTP and SSE can reorder). Edit/Delete use `remove_queued_message` (act only on `removed: true`), Steer uses `promote_queued_message`; the chip changes when omp's next snapshot arrives. -`handleAbort` coalesces overlapping Stops, then withdraws pending messages BEFORE sending `abort` (bounded by -`WITHDRAW_BEFORE_ABORT_MS`), like the TUI's Esc: omp runs a queued steer as -soon as an abort lands. Withdrawn text goes to the session draft via -`recoverDraftText`, saved as each removal confirms. A follow-up that answers -`removed: false` is retried on `steering` (a concurrent promotion moved it); -never the reverse. The abort is fenced to the prompt run id captured at Stop, -so it cannot kill a prompt started during the wait. Known gap: input taken by -live steering answers `removed: false`, and RPC `abort` does not call -`withdrawLiveSteering` (the TUI's `clearQueue({ forInterrupt: true })` does), -so omp requeues it on abort and runs it next; omp-web cannot prevent that. +`handleAbort` coalesces overlapping Stops, then sends `abort_and_restore_queue`: +omp's Esc (`clearQueue({ forInterrupt: true })`, then abort) in one step, +returning the withdrawn user messages, which go to the session draft via +`recoverDraftText`. It covers what a client snapshot cannot: a steer promoted +after the last `queue_update`, and live-steered input the run claimed but never +recorded (omp would otherwise requeue it and drain it into a new turn right +after the abort). Never reimplement this client-side. Fallback for omp without +the command (any error): withdraw each listed message with +`remove_queued_message` BEFORE sending `abort` (bounded by +`WITHDRAW_BEFORE_ABORT_MS`), saved as each removal confirms. A follow-up that +answers `removed: false` is retried on `steering` (a concurrent promotion moved +it); never the reverse. That abort is fenced to the prompt run id captured at +Stop, so it cannot kill a prompt started during the wait. ### Running state SSE + reconciliation - The sidebar listens to `/api/agent/running/events`, backed by `subscribeRunningSessions()` in `lib/rpc-manager.ts`, so running badges update without polling. diff --git a/CHANGELOG.md b/CHANGELOG.md index c02b7e7a5..919a23fd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - The **Agent environment variables** editor in Settings → System & Updates now sits below its title and description and follows the page width, instead of sitting beside them and extending past the right edge. The text box also grows with its contents. - Show queued steers and follow-ups on every device viewing a session. The queue panel now shows omp's own queue (omp 18.4.4 or later) instead of a copy kept by the tab that sent them, so it also stays correct across reloads. -- **Stop** no longer loses a queued steer or lets the agent run it anyway. The text of messages still waiting in the queue moves back into the composer (images attached to them are dropped). Requires omp 18.4.4 or later. A steer the model already picked up through live steering (for example on OpenAI models with `providers.openaiLiveSteering`) cannot be taken back: omp requeues it when the run stops and runs it next. +- **Stop** no longer loses a queued steer or lets the agent run it anyway: like Esc in omp's terminal, every message still waiting in the queue moves back into the composer (images attached to them are dropped). With omp's `abort_and_restore_queue` (newer omp), omp takes the queue back and aborts in one step, which also covers a steer you just promoted that this page's queue had not caught up with, and one the model already picked up through live steering (for example OpenAI models with `providers.openaiLiveSteering`). Older omp (18.4.4 or later) falls back to removing each message the page lists before the abort; there, a steer the page does not list or the model already took still runs after Stop. - Long voice transcriptions no longer fail with `Unexpected token '<', " { + const { getDraft, clearDraft } = await jiti.import("@/lib/draft-store"); + resetWorld(); + clearDraft("abort-atomic"); + primeSession("abort-atomic", [userMsg("u0", "loaded question")]); + const { w } = await startRun("abort-atomic", "hello agent"); + world.abortRestoreQueue = { + steering: [{ text: "steer the snapshot missed" }], + followUp: [{ text: "later follow-up", images: [{ type: "image", data: "x", mimeType: "image/png" }] }], + }; + + await act(async () => { await w.latest.handleAbort(); }); + + const commands = world.calls.map((c) => c.body?.type).filter(Boolean); + assert.equal(commands.filter((type) => type === "abort_and_restore_queue").length, 1); + assert.equal(commands.includes("abort"), false, "omp's own abort already stopped the run"); + assert.equal(commands.includes("remove_queued_message"), false); + assert.equal(getDraft("abort-atomic")?.value, "steer the snapshot missed\n\nlater follow-up"); + assert.deepEqual(w.latest.notices, []); + clearDraft("abort-atomic"); +}); diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 00b1c7724..ba5dfd224 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -2786,10 +2786,31 @@ export function useAgentSession(opts: UseAgentSessionOptions) { executeBashRef.current = executeBash; const withdrawAndAbort = useCallback(async (sid: string, onAbortSent: () => void) => { - // Take pending messages back out of omp BEFORE the abort, like the TUI's - // Esc: omp runs a queued steer as soon as an abort lands (and keeps a - // follow-up for after the next reply, #130). Withdrawn texts return to - // this session's draft; a message the model already took (removed: + // Like the TUI's Esc, omp takes every pending user message back and then + // aborts, in one step: it also catches a steer this client's queue + // snapshot does not list yet, or one the run already claimed but never + // recorded, which a removal by text cannot reach and which omp would + // otherwise run as a new turn right after the abort. The texts return to + // this session's draft (attached images are dropped). + try { + const restored = await sendAgentCommand<{ + steering?: Array<{ text?: unknown }>; + followUp?: Array<{ text?: unknown }>; + }>(sid, { type: "abort_and_restore_queue" }); + onAbortSent(); + const texts = [...(restored?.steering ?? []), ...(restored?.followUp ?? [])] + .map((entry) => entry.text) + .filter((text): text is string => typeof text === "string" && text.length > 0); + if (texts.length > 0) recoverDraftText(sid, texts.join("\n\n")); + return; + } catch { + // omp before abort_and_restore_queue ("Unknown command"), or a failed + // request: withdraw what the snapshot lists, then abort. Repeating the + // abort after one that did land is harmless. + } + // Take pending messages back out of omp BEFORE the abort: omp runs a + // queued steer as soon as an abort lands (and keeps a follow-up for after + // the next reply, #130). A message the model already took (removed: // false) lands in the transcript instead. A steer whose send has not // reached omp's queue snapshot yet is not withdrawn. const pending = queuedMessagesRef.current; diff --git a/lib/rpc-manager.test.mjs b/lib/rpc-manager.test.mjs index 3c805d80d..135161866 100644 --- a/lib/rpc-manager.test.mjs +++ b/lib/rpc-manager.test.mjs @@ -1362,3 +1362,22 @@ test("neither cleanup timer keeps the event loop alive", async () => { assert.match(out, /armed/, "the wrapper armed its timers before the process should exit"); }); + +test("abort_and_restore_queue returns omp's withdrawn messages and clears the run like abort", async (t) => { + const restored = { steering: [{ text: "steer" }], followUp: [{ text: "later" }] }; + const sent = []; + const { wrapper, emit } = snapshotSession(t, async (command) => { + sent.push(command.type); + if (command.type === "abort_and_restore_queue") return restored; + if (command.type === "get_state") return { sessionId: "abort-restore", isStreaming: false, isCompacting: false }; + return { ignored: true }; + }); + emit({ type: "agent_start" }); + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "partial" }] } }); + + assert.deepEqual(await wrapper.send({ type: "abort_and_restore_queue" }), restored); + assert.equal(wrapper.getStreamSnapshot().streamingMessage, null); + assert.equal(wrapper.getStreamSnapshot().isPromptRunning, false); + assert.equal(await wrapper.send({ type: "abort" }), null, "plain abort still answers nothing"); + assert.deepEqual(sent.filter((type) => type.startsWith("abort")), ["abort_and_restore_queue", "abort"]); +}); diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 173b0dfeb..89849bb04 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -1414,11 +1414,15 @@ export class AgentSessionWrapper { return result ?? null; } + // abort_and_restore_queue is omp's Esc: it takes queued user input back + // atomically, then aborts, and returns the withdrawn messages. case "abort": + case "abort_and_restore_queue": { this.responseObserved = false; this.responseRunActive = false; + let result: unknown = null; await this.withFinalRunningNotification(async () => { - await this.proc.sendCommand({ type: "abort" }); + result = await this.proc.sendCommand({ type }); // If the prompt was aborted before the agent loop started, no // agent_end will arrive to clear the flag; the streaming flag still // tracks a live turn that ends with its own agent_end. @@ -1432,7 +1436,8 @@ export class AgentSessionWrapper { this.continuationGraceUntil = 0; this.clearLiveSnapshots(); }); - return null; + return type === "abort" ? null : result ?? null; + } case "get_state": { try { From f8c875c22b2933e6f5e41ef0431c64a849225712 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sat, 3 Oct 2026 09:45:20 +0000 Subject: [PATCH 05/79] fix(queue): fence, retry and gate the atomic Stop fallback Review of the atomic Stop: - Capture the run fence at the click; a failed request is retried once and only while that run is current, so it can never abort a newer run. - Fall back to per-entry withdrawal only on "Unknown command" (omp without abort_and_restore_queue); any other failure retries the same command, and warns (new localized queueRestoreUncertain) when nothing came back. - Narrow the try to the request; drop an image-only "[Image]" label. - Tests: retry restores, lost response warns, no retry into a newer run, overlapping Stops share one request; tidy the server abort branch. --- AGENTS.md | 18 ++++--- CHANGELOG.md | 2 +- hooks/useAgentSession.rpc.test.mjs | 85 +++++++++++++++++++++++++++++- hooks/useAgentSession.ts | 63 ++++++++++++++-------- lib/i18n/locales/en.json | 1 + lib/i18n/locales/ja.json | 1 + lib/i18n/locales/zh-CN.json | 1 + lib/rpc-manager.ts | 6 +-- 8 files changed, 143 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 46eafda62..802aba9c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -241,13 +241,17 @@ returning the withdrawn user messages, which go to the session draft via `recoverDraftText`. It covers what a client snapshot cannot: a steer promoted after the last `queue_update`, and live-steered input the run claimed but never recorded (omp would otherwise requeue it and drain it into a new turn right -after the abort). Never reimplement this client-side. Fallback for omp without -the command (any error): withdraw each listed message with -`remove_queued_message` BEFORE sending `abort` (bounded by -`WITHDRAW_BEFORE_ABORT_MS`), saved as each removal confirms. A follow-up that -answers `removed: false` is retried on `steering` (a concurrent promotion moved -it); never the reverse. That abort is fenced to the prompt run id captured at -Stop, so it cannot kill a prompt started during the wait. +after the abort). Never reimplement this client-side. A failed request is +retried once (omp returns whatever is still queued) and only while the run +captured at the click is current; texts lost with a response that never +arrived cannot be recovered, so the hook warns (`queueRestoreUncertain`). +Fallback ONLY when omp answers "Unknown command" (omp without the command): +withdraw each listed message with `remove_queued_message` BEFORE sending +`abort` (bounded by `WITHDRAW_BEFORE_ABORT_MS`), saved as each removal +confirms. A follow-up that answers `removed: false` is retried on `steering` +(a concurrent promotion moved it); never the reverse. Every abort is fenced to +the prompt run id captured at the click, so it cannot kill a prompt started +during the wait. ### Running state SSE + reconciliation - The sidebar listens to `/api/agent/running/events`, backed by `subscribeRunningSessions()` in `lib/rpc-manager.ts`, so running badges update without polling. diff --git a/CHANGELOG.md b/CHANGELOG.md index 919a23fd6..31e94fd2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - The **Agent environment variables** editor in Settings → System & Updates now sits below its title and description and follows the page width, instead of sitting beside them and extending past the right edge. The text box also grows with its contents. - Show queued steers and follow-ups on every device viewing a session. The queue panel now shows omp's own queue (omp 18.4.4 or later) instead of a copy kept by the tab that sent them, so it also stays correct across reloads. -- **Stop** no longer loses a queued steer or lets the agent run it anyway: like Esc in omp's terminal, every message still waiting in the queue moves back into the composer (images attached to them are dropped). With omp's `abort_and_restore_queue` (newer omp), omp takes the queue back and aborts in one step, which also covers a steer you just promoted that this page's queue had not caught up with, and one the model already picked up through live steering (for example OpenAI models with `providers.openaiLiveSteering`). Older omp (18.4.4 or later) falls back to removing each message the page lists before the abort; there, a steer the page does not list or the model already took still runs after Stop. +- **Stop** no longer loses a queued steer or lets the agent run it anyway: as with Esc in omp's terminal, every message still waiting in the queue moves back into the composer, except attached images, which are dropped. With omp's `abort_and_restore_queue` (newer omp), omp takes the queue back and aborts in one step, which also covers a steer you just promoted that this page's queue had not caught up with, and one the model already picked up through live steering (for example OpenAI models with `providers.openaiLiveSteering`). If that request fails, Stop retries it once and warns when the messages may not have come back. Older omp (18.4.4 or later) falls back to removing each message the page lists before the abort; there, a steer the page does not list or the model already took still runs after Stop. - Long voice transcriptions no longer fail with `Unexpected token '<', " method === "POST" && body?.type === "abort_and_restore_queue"; + +test("a failed atomic Stop is retried once and restores what omp still held", async () => { + const { getDraft, clearDraft } = await jiti.import("@/lib/draft-store"); + resetWorld(); + clearDraft("abort-atomic-retry"); + primeSession("abort-atomic-retry", [userMsg("u0", "loaded question")]); + const { w } = await startRun("abort-atomic-retry", "hello agent"); + world.holds.push({ match: isAtomicStop, produce: async () => ({ status: 502, value: { error: "Bad Gateway" } }) }); + world.abortRestoreQueue = { steering: [{ text: "retry me" }], followUp: [] }; + + await act(async () => { await w.latest.handleAbort(); }); + + const commands = world.calls.map((c) => c.body?.type).filter(Boolean); + assert.equal(commands.filter((type) => type === "abort_and_restore_queue").length, 2); + assert.equal(commands.includes("abort"), false, "a failure is not mistaken for an omp without the command"); + assert.equal(commands.includes("remove_queued_message"), false); + assert.equal(getDraft("abort-atomic-retry")?.value, "retry me"); + assert.deepEqual(w.latest.notices, []); + clearDraft("abort-atomic-retry"); +}); + +test("an atomic Stop whose texts were lost with a failed response warns", async () => { + resetWorld(); + primeSession("abort-atomic-lost", [userMsg("u0", "loaded question")]); + const { w } = await startRun("abort-atomic-lost", "hello agent"); + // omp withdrew and aborted, but the answer never arrived; the queue is empty now. + world.holds.push({ match: isAtomicStop, produce: async () => ({ status: 502, value: { error: "Bad Gateway" } }) }); + world.abortRestoreQueue = { steering: [], followUp: [] }; + + await act(async () => { await w.latest.handleAbort(); }); + + assert.equal(world.calls.some((c) => c.body?.type === "abort"), false); + assert.deepEqual(w.latest.notices.map((n) => n.type), ["warning"]); +}); + +test("a failed atomic Stop never retries into a run started after the click", async () => { + resetWorld(); + primeSession("abort-atomic-fenced", [userMsg("u0", "loaded question")]); + const { w, es } = await startRun("abort-atomic-fenced", "hello agent"); + let release; + world.holds.push({ match: isAtomicStop, produce: () => new Promise((resolve) => { release = resolve; }) }); + world.abortRestoreQueue = { steering: [], followUp: [] }; + let stop; + await act(async () => { stop = w.latest.handleAbort(); }); + await act(async () => { + es.emit({ type: "message_end", message: assistantMsg("a1", "answer") }); + es.emit({ type: "agent_end", isTerminal: true }); + }); + await settle(); + let sending; + await act(async () => { sending = w.latest.handleSend("next prompt"); await sleep(30); }); + await act(async () => { lastEs().open(); await sending; }); + await act(async () => { + release({ status: 502, value: { error: "Bad Gateway" } }); + await stop; + }); + + assert.equal(world.calls.some((c) => c.body?.type === "prompt" && c.body?.message === "next prompt"), true); + assert.equal(world.calls.filter((c) => c.body?.type === "abort_and_restore_queue").length, 1, "no retry into the new run"); + assert.equal(world.calls.some((c) => c.body?.type === "abort"), false, "nor a fallback abort"); +}); + +test("overlapping Stops share one atomic withdrawal; an image-only entry does not come back as text", async () => { + const { getDraft, clearDraft } = await jiti.import("@/lib/draft-store"); + resetWorld(); + clearDraft("abort-atomic-twice"); + primeSession("abort-atomic-twice", [userMsg("u0", "loaded question")]); + const { w } = await startRun("abort-atomic-twice", "hello agent"); + world.abortRestoreQueue = { + steering: [{ text: "[Image]", images: [{ type: "image", data: "x", mimeType: "image/png" }] }], + followUp: [{ text: "words" }], + }; + + await act(async () => { await Promise.all([w.latest.handleAbort(), w.latest.handleAbort()]); }); + + assert.equal(world.calls.filter((c) => c.body?.type === "abort_and_restore_queue").length, 1); + assert.equal(getDraft("abort-atomic-twice")?.value, "words"); + clearDraft("abort-atomic-twice"); +}); diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index ba5dfd224..1a6bfb0a8 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -2786,40 +2786,61 @@ export function useAgentSession(opts: UseAgentSessionOptions) { executeBashRef.current = executeBash; const withdrawAndAbort = useCallback(async (sid: string, onAbortSent: () => void) => { + // Captured at the click: no abort below may reach a run that started + // since (this client's next prompt or another device's). + const runId = promptRunIdRef.current; + const runsEnded = runsEndedRef.current; + const sameRun = () => promptRunIdRef.current === runId && runsEndedRef.current === runsEnded; // Like the TUI's Esc, omp takes every pending user message back and then // aborts, in one step: it also catches a steer this client's queue // snapshot does not list yet, or one the run already claimed but never // recorded, which a removal by text cannot reach and which omp would // otherwise run as a new turn right after the abort. The texts return to - // this session's draft (attached images are dropped). - try { - const restored = await sendAgentCommand<{ - steering?: Array<{ text?: unknown }>; - followUp?: Array<{ text?: unknown }>; - }>(sid, { type: "abort_and_restore_queue" }); - onAbortSent(); - const texts = [...(restored?.steering ?? []), ...(restored?.followUp ?? [])] + // this session's draft (attached images are dropped). Known window: the + // texts exist only in the response until the abort finishes, so a reload + // during a slow abort loses them. + type RestoredQueue = { steering?: Array<{ text?: unknown; images?: unknown[] }>; followUp?: Array<{ text?: unknown; images?: unknown[] }> }; + let restoredQueue: RestoredQueue | null | undefined; + let unsupported = false; + let failures = 0; + // A failure after omp already withdrew would lose the texts on the + // per-entry path (the queue is empty by then). Retrying the same command + // is safe instead: omp returns whatever is still queued. + for (let attempt = 0; attempt < 2 && restoredQueue === undefined && !unsupported && (attempt === 0 || sameRun()); attempt++) { + try { + restoredQueue = (await sendAgentCommand(sid, { type: "abort_and_restore_queue" })) ?? null; + } catch (error) { + unsupported = error instanceof Error && error.message.includes("Unknown command"); + if (!unsupported) failures += 1; + } + } + if (!unsupported) { + const steering = Array.isArray(restoredQueue?.steering) ? restoredQueue.steering : []; + const followUp = Array.isArray(restoredQueue?.followUp) ? restoredQueue.followUp : []; + const texts = [...steering, ...followUp] + // An image-only message comes back as omp's "[Image]" chip label; the + // images themselves are not restored, so neither is the label. + .filter((entry) => !(entry.text === "[Image]" && Array.isArray(entry.images) && entry.images.length > 0)) .map((entry) => entry.text) .filter((text): text is string => typeof text === "string" && text.length > 0); if (texts.length > 0) recoverDraftText(sid, texts.join("\n\n")); + // A failed attempt may have withdrawn messages whose texts were in the + // lost response; say so unless the retry brought texts back. + if (failures > 0 && texts.length === 0 && hookAliveRef.current && sessionIdRef.current === sid) { + addNotice({ type: "warning", message: translate("agentSession.queueRestoreUncertain") }); + } return; - } catch { - // omp before abort_and_restore_queue ("Unknown command"), or a failed - // request: withdraw what the snapshot lists, then abort. Repeating the - // abort after one that did land is harmless. - } - // Take pending messages back out of omp BEFORE the abort: omp runs a - // queued steer as soon as an abort lands (and keeps a follow-up for after - // the next reply, #130). A message the model already took (removed: - // false) lands in the transcript instead. A steer whose send has not - // reached omp's queue snapshot yet is not withdrawn. + } + // omp before abort_and_restore_queue: take pending messages back out of + // omp BEFORE the abort: omp runs a queued steer as soon as an abort lands + // (and keeps a follow-up for after the next reply, #130). A message the + // model already took (removed: false) lands in the transcript instead. A + // steer whose send has not reached omp's queue snapshot yet is not withdrawn. const pending = queuedMessagesRef.current; const entries = [ ...pending.steering.map((text) => ({ text, queue: "steering" as const })), ...pending.followUp.map((text) => ({ text, queue: "followUp" as const })), ]; - const runId = promptRunIdRef.current; - const runsEnded = runsEndedRef.current; const removed: boolean[] = []; const restored: boolean[] = []; let recoveredBlock = ""; @@ -2867,7 +2888,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { recoverWithdrawn(); // The targeted run may have ended during the wait and another prompt // (this client's or another device's) started: this Stop is not for it. - if (promptRunIdRef.current === runId && runsEndedRef.current === runsEnded) { + if (sameRun()) { try { await sendAgentCommand(sid, { type: "abort" }); } catch (e) { diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index 5ad0f3143..3b2f52d47 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -31,6 +31,7 @@ "agentSession.queuedPromotionUnavailable": "The queued follow-up is no longer available to steer.", "agentSession.queuedRemovalUnavailable": "This message is no longer pending in that queue and could not be cancelled.", "agentSession.queueDiscardFailed": "The run was stopped, but some queued messages could not be taken back. The agent may still deliver them.", + "agentSession.queueRestoreUncertain": "The run was stopped, but the queued messages may not have come back to the composer. Check the queue and the chat before resending.", "agentSession.startingAgent": "Starting omp… the first launch of a session can take a while.", "agentSession.toolPresetNotice": "Tool presets apply when a session starts; this session keeps its current tools.", "agentSession.unknownSlashCommand": "/{name} is not a command this session can run — it was sent as plain text. TUI-only commands (for example /guided-goal) only work in the omp terminal.", diff --git a/lib/i18n/locales/ja.json b/lib/i18n/locales/ja.json index 813986b02..398f12a36 100644 --- a/lib/i18n/locales/ja.json +++ b/lib/i18n/locales/ja.json @@ -31,6 +31,7 @@ "agentSession.queuedPromotionUnavailable": "このフォローアップはキューに存在しないため、ステアリングに変更できません。", "agentSession.queuedRemovalUnavailable": "このメッセージはそのキューに存在しないため、キャンセルできませんでした。", "agentSession.queueDiscardFailed": "実行は停止しましたが、キュー内の一部のメッセージを取り戻せませんでした。エージェントがそれらを配信する可能性があります。", + "agentSession.queueRestoreUncertain": "実行は停止しましたが、キュー内のメッセージが入力欄に戻っていない可能性があります。再送信する前にキューとチャットを確認してください。", "agentSession.startingAgent": "omp を起動しています… セッションの初回起動には時間がかかる場合があります。", "agentSession.toolPresetNotice": "ツールプリセットはセッション開始時に適用されます。このセッションは現在のツールを維持します。", "agentSession.unknownSlashCommand": "/{name} はこのセッションが実行できるコマンドではないため、通常のテキストとして送信されました。TUI 専用のコマンド(例: /guided-goal)は omp のターミナルでのみ動作します。", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index fe5ac1ca9..4a1462fbe 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -31,6 +31,7 @@ "agentSession.queuedPromotionUnavailable": "该后续消息已不在队列中,无法转为引导消息。", "agentSession.queuedRemovalUnavailable": "该消息已不在此队列中,无法取消。", "agentSession.queueDiscardFailed": "运行已停止,但部分排队消息未能取回。智能体仍可能发送它们。", + "agentSession.queueRestoreUncertain": "已停止运行,但队列中的消息可能没有回到输入框。重新发送前请检查队列和聊天记录。", "agentSession.startingAgent": "正在启动 omp……会话首次启动可能需要一些时间。", "agentSession.toolPresetNotice": "工具预设在会话启动时生效;当前会话将保留现有工具。", "agentSession.unknownSlashCommand": "/{name} 不是此会话可执行的命令,已作为普通文本发送。仅 TUI 可用的命令(例如 /guided-goal)只能在 omp 终端中运行。", diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 89849bb04..d3f276549 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -1420,9 +1420,8 @@ export class AgentSessionWrapper { case "abort_and_restore_queue": { this.responseObserved = false; this.responseRunActive = false; - let result: unknown = null; - await this.withFinalRunningNotification(async () => { - result = await this.proc.sendCommand({ type }); + const result = await this.withFinalRunningNotification(async () => { + const response: unknown = await this.proc.sendCommand({ type }); // If the prompt was aborted before the agent loop started, no // agent_end will arrive to clear the flag; the streaming flag still // tracks a live turn that ends with its own agent_end. @@ -1435,6 +1434,7 @@ export class AgentSessionWrapper { this.awaitingAgentStartDeadline = 0; this.continuationGraceUntil = 0; this.clearLiveSnapshots(); + return response; }); return type === "abort" ? null : result ?? null; } From c141485db357dea237cc1402da794e8df4ec2d0c Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sat, 3 Oct 2026 18:01:12 +0000 Subject: [PATCH 06/79] feat(settings): add Auto thinking source selector Prepare ompweb for publisher-default Auto effort in can1357/oh-my-pi#14114. Persist the choice through native config, preserve provider settings, and show the control and search result only when Reasoning is auto. --- CHANGELOG.md | 1 + components/SettingsConfig.tsx | 19 ++++++++++++++++++- lib/i18n/locales/en.json | 4 ++++ lib/i18n/locales/ja.json | 4 ++++ lib/i18n/locales/zh-CN.json | 4 ++++ lib/omp/settings-config.test.mjs | 32 ++++++++++++++++++++++++++++++++ lib/omp/settings-config.ts | 12 ++++++++++++ 7 files changed, 75 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c02b7e7a5..0345c7853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f ### Added +- Choose **Classifier** or **Vendor default** in Settings → AI Model Defaults → **Auto Thinking Source** when Reasoning is `auto`. The choice is saved in omp's native config for newly started or restarted agents. Vendor mode prefers the publisher's effort, then omp's per-model default, then normal Auto fallback. Requires an omp release containing [can1357/oh-my-pi#14114](https://github.com/can1357/oh-my-pi/pull/14114). - Ask a side question with `/btw `: omp answers from the current conversation in a panel above the composer, even while the agent is working, and the question never enters the chat. Cancel a running answer, copy it, or ask follow-ups in the same topic. `/btw` on its own lists the session's past side questions, including ones asked in the terminal, each expandable to all its turns with **Copy** and **Follow up**. Side questions are text-only: with an attachment in the composer, `/btw` is refused and the draft is kept. An answer that omp lost (for example after its process restarted) shows as **Interrupted** instead of spinning. Requires an omp with the `btw` RPC commands; older omp shows "/btw requires a newer omp" instead of sending the text to the model. - Show omp's Claude usage-limit badge in the composer toolbar, matching the terminal status line during the wrap-up allowance or once `/slow` low priority takes over. Reset times use the browser's locale and timezone. Requires an omp version whose `get_state` reports structured `anthropicSlowMode` state. - Add **Paste image** to the composer's **+** menu, with Japanese and Simplified Chinese translations. Requires HTTPS (or localhost), browser support for clipboard image reads, and clipboard permission; the item is hidden where the browser cannot read images from the clipboard. Attaching a file remains available. diff --git a/components/SettingsConfig.tsx b/components/SettingsConfig.tsx index 4965da1f3..1b83db4ab 100644 --- a/components/SettingsConfig.tsx +++ b/components/SettingsConfig.tsx @@ -49,6 +49,7 @@ type WindowsServiceStatus = { type NativeSettings = { defaultThinkingLevel?: "auto" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; + providers?: { autoThinkingSource?: "classifier" | "vendor" }; hideThinkingBlock?: boolean; externalThinking?: boolean; textVerbosity?: "low" | "medium" | "high"; @@ -158,6 +159,7 @@ const SETTING_INDEX: SettingIndexEntry[] = [ { id: "extension-tool-requests", tab: "safety", sectionKey: "settingsConfig.toolSafetyApprovals", labelKey: "settingsConfig.extensionToolRequests", descKey: "settingsConfig.extensionToolRequestsDesc", fallbackSection: "Tool Safety & Approvals", fallbackLabel: "Extension Tool Requests", fallbackDesc: "Automatically approve extension tool authorization requests.", scope: "Native OMP" }, // AI Model Defaults { id: "reasoning", tab: "models", sectionKey: "settingsConfig.modelDefaults", labelKey: "settingsConfig.reasoning", descKey: "settingsConfig.reasoningDesc", fallbackSection: "AI Model Defaults", fallbackLabel: "Reasoning", fallbackDesc: "Default effort level for thinking-capable models.", scope: "Native OMP" }, + { id: "auto-thinking-source", tab: "models", sectionKey: "settingsConfig.modelDefaults", labelKey: "settingsConfig.autoThinkingSource", descKey: "settingsConfig.autoThinkingSourceDesc", fallbackSection: "AI Model Defaults", fallbackLabel: "Auto Thinking Source", fallbackDesc: "Choose prompt classification or the publisher default with omp fallback.", scope: "Native OMP" }, { id: "verbosity", tab: "models", sectionKey: "settingsConfig.modelDefaults", labelKey: "settingsConfig.verbosity", descKey: "settingsConfig.verbosityDesc", fallbackSection: "AI Model Defaults", fallbackLabel: "Verbosity", fallbackDesc: "Response detail level for supporting providers.", scope: "Native OMP" }, { id: "personality", tab: "models", sectionKey: "settingsConfig.modelDefaults", labelKey: "settingsConfig.personality", descKey: "settingsConfig.personalityDesc", fallbackSection: "AI Model Defaults", fallbackLabel: "Personality", fallbackDesc: "Style included in OMP's system prompt.", scope: "Native OMP" }, { id: "thinking-blocks", tab: "models", sectionKey: "settingsConfig.modelDefaults", labelKey: "settingsConfig.thinkingBlocks", descKey: "settingsConfig.thinkingBlocksDesc", fallbackSection: "AI Model Defaults", fallbackLabel: "Hide Thinking Blocks", fallbackDesc: "Hide model reasoning from output view.", scope: "Native OMP" }, @@ -910,6 +912,7 @@ export function SettingsConfig({ activeTab, toolCallsDefaultCollapsed, onToolCal } } for (const setting of SETTING_INDEX) { + if (setting.id === "auto-thinking-source" && nativeSettings?.defaultThinkingLevel !== "auto") continue; const trLabel = t(setting.labelKey); const trDesc = t(setting.descKey); const trSection = t(setting.sectionKey); @@ -922,7 +925,7 @@ export function SettingsConfig({ activeTab, toolCallsDefaultCollapsed, onToolCal } } return results; - }, [trimmedQuery, t]); + }, [trimmedQuery, t, nativeSettings?.defaultThinkingLevel]); const openSearchResult = useCallback((result: SearchResult) => { startTransition(() => onSelectTab(result.tab)); @@ -1263,6 +1266,20 @@ export function SettingsConfig({ activeTab, toolCallsDefaultCollapsed, onToolCal ))} + {nativeSettings?.defaultThinkingLevel === "auto" && ( + + + + )} setAliasValue(event.target.value)} - onBlur={commitAliasEdit} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - commitAliasEdit(); - } - if (event.key === "Escape") { - event.preventDefault(); - aliasCancelRef.current = true; - setAliasEditing(false); - } +
+
+ {aliasEditing ? ( +
event.stopPropagation()} + style={{ + flex: 1, + minWidth: 0, + alignSelf: "stretch", + display: "flex", + alignItems: "center", + gap: 7, + padding: "0 4px 0 10px", }} - style={{ flex: 1, minWidth: 0, height: 22, padding: "2px 6px", border: "1px solid var(--accent)", borderRadius: "var(--radius-control)", outline: "none", background: "var(--bg)", color: "var(--text)", fontSize: 12, fontFamily: "var(--font-mono)", fontWeight: 600 }} - /> -
- ) : ( - - {t("sessionSidebar.launchConfigDirectory")} - {project.path} - {project.launchConfig?.profile && profile: {project.launchConfig.profile}} - {project.launchConfig?.advisor && --advisor} - {project.launchConfig?.extraArgs?.map((arg, index) => {arg})} - - )} - side="right" - > -
+ ) : ( + + {t("sessionSidebar.launchConfigDirectory")} + {project.path} + {project.launchConfig?.profile && profile: {project.launchConfig.profile}} + {project.launchConfig?.advisor && --advisor} + {project.launchConfig?.extraArgs?.map((arg, index) => {arg})} + + )} + side="right" + > + - - )} - {worktreeBranch && worktreeToggleRef && ( - - )} -
- {hasActivity && ( - 0 ? "true" : "false"} - role="status" - aria-live="polite" - style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: SIDEBAR_STATUS_SLOT, height: SIDEBAR_STATUS_SLOT, flexShrink: 0, lineHeight: 0 }} - > - {(activity?.exited ?? 0) > 0 ? ( - - ) : ( -
+ {worktreeBranch && worktreeToggleRef && ( + + )} +
Date: Sun, 4 Oct 2026 20:54:04 +0000 Subject: [PATCH 19/79] fix: support app installation behind authentication --- CHANGELOG.md | 1 + README.md | 15 +++++++++++++++ app/api/manifest/route.ts | 33 +++++++++++++++++++++++++++++++++ app/layout.tsx | 1 + app/manifest.ts | 19 ------------------- proxy.ts | 8 ++++---- 6 files changed, 54 insertions(+), 23 deletions(-) create mode 100644 app/api/manifest/route.ts delete mode 100644 app/manifest.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 99cf728ec..6881de8a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f ### Fixes & Improvements - Fix settings crashes, stalled session catch-up, and failed voice transcription polling in browsers without `AbortSignal.timeout`, `AbortSignal.any`, or `Promise.withResolvers`, including development-mode clients. Client requests now use `AbortController` timers and ordinary promises while retaining timeouts and cancellation (#196). +- Fix authenticated app-installation metadata: serve `/api/manifest` through the existing password guard with one credentialed manifest link and embedded packaged PNG icons, avoiding cookie-free native installer icon downloads. Preserve the app identity and root launch URL/scope, retain private revalidating caching, and remove the obsolete public-manifest exception. Verified in local Chromium with HTTP icon URLs blocked and no installability errors; the owner also confirmed successful installation on a physical phone behind Cloudflare Access. - The **Agent environment variables** editor in Settings → System & Updates now sits below its title and description and follows the page width, instead of sitting beside them and extending past the right edge. The text box also grows with its contents. - Show queued steers and follow-ups on every device viewing a session. The queue panel now shows omp's own queue (omp 18.4.4 or later) instead of a copy kept by the tab that sent them, so it also stays correct across reloads. - **Stop** no longer loses a queued steer or lets the agent run it anyway: as with Esc in omp's terminal, every message still waiting in the queue moves back into the composer, except attached images, which are dropped. With omp's `abort_and_restore_queue` (newer omp), omp takes the queue back and aborts in one step, which also covers a steer you just promoted that this page's queue had not caught up with, and one the model already picked up through live steering (for example OpenAI models with `providers.openaiLiveSteering`). If that request fails, Stop retries it once and warns when the messages may not have come back. Older omp (18.4.4 or later) falls back to removing each message the page lists before the abort; there, a steer the page does not list or the model already took still runs after Stop. diff --git a/README.md b/README.md index 0e5a78847..4e56c0d69 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,21 @@ Queue **Delete** and **Edit** additionally require `remove_queued_message`. Dele The queue panel shows omp's own queue (`queuedMessages` in `get_state` and `queue_update` events, omp 18.4.4 or later), so every device viewing a session sees the same queued messages. **Stop** moves the text of messages still waiting in the queue back into the composer instead of letting the agent run them; a steer the model already picked up through live steering still runs. Older omp runtimes show no queue panel, and Stop cannot take queued messages back. +## App installation behind authentication + +To install the web app, sign in first and use your browser's installation menu. +The single manifest link requests `/api/manifest` with credentials; that endpoint +uses the existing web-password guard and private, revalidating caching. Its +192×192 and 512×512 PNG icons are embedded from the packaged assets because +Android's native installer fetches ordinary icon URLs without authentication +cookies. The app still launches at `/` with scope `/`. + +Keep Cloudflare Access and application authentication enabled; no public +manifest exception or Access bypass rule is needed. This does not add offline +support. Local Chromium verification covered cookie-gated metadata with HTTP +icon URLs blocked (zero installability errors). The owner also confirmed that +installation on a physical phone works as expected behind Cloudflare Access. + ## Quick Start **Run directly without installing:** diff --git a/app/api/manifest/route.ts b/app/api/manifest/route.ts new file mode 100644 index 000000000..a9c736e2e --- /dev/null +++ b/app/api/manifest/route.ts @@ -0,0 +1,33 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { MetadataRoute } from "next"; + +// Packaged public assets are read/encoded once per server module. Native +// installers fetch HTTP icons without cookies, even with a credentialed manifest. +const pngDataUrl = (filename: string) => + `data:image/png;base64,${readFileSync(join(process.cwd(), "public", filename)).toString("base64")}`; + +const manifest: MetadataRoute.Manifest = { + name: "omp web", + short_name: "omp web", + description: "Web UI for the oh-my-pi (omp) coding agent", + start_url: "/", + scope: "/", + display: "standalone", + background_color: "#000000", + theme_color: "#000000", + icons: [ + { src: pngDataUrl("icon-192.png"), sizes: "192x192", type: "image/png" }, + { src: pngDataUrl("icon.png"), sizes: "512x512", type: "image/png" }, + ], +}; + +// Authentication is enforced by the existing /api/ web-password proxy guard. +export function GET() { + return Response.json(manifest, { + headers: { + "Content-Type": "application/manifest+json", + "Cache-Control": "private, no-cache, max-age=0, must-revalidate", + }, + }); +} diff --git a/app/layout.tsx b/app/layout.tsx index 117fe39ea..691de25e7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -75,6 +75,7 @@ export default function RootLayout({ return ( + {/* Register before Next's router. Owned sidebar traversals must be handled diff --git a/app/manifest.ts b/app/manifest.ts deleted file mode 100644 index fc063fb11..000000000 --- a/app/manifest.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { MetadataRoute } from "next"; - -export default function manifest(): MetadataRoute.Manifest { - return { - name: "omp web", - short_name: "omp web", - description: "Web UI for the oh-my-pi (omp) coding agent", - start_url: "/", - display: "standalone", - background_color: "#000000", - theme_color: "#000000", - // Chrome's install prompt requires both a 192px and a 512px PNG. - icons: [ - { src: "/icon.svg", type: "image/svg+xml", sizes: "any" }, - { src: "/icon-192.png", sizes: "192x192", type: "image/png" }, - { src: "/icon.png", sizes: "512x512", type: "image/png" }, - ], - }; -} diff --git a/proxy.ts b/proxy.ts index f9ab274a6..6f339353a 100644 --- a/proxy.ts +++ b/proxy.ts @@ -26,7 +26,7 @@ export function proxy(request: NextRequest) { } // The sign-in screen still needs its Next.js JavaScript and CSS before a -// session exists; these are public build assets, not workspace data. The -// same goes for the web app manifest and its icons: browsers fetch them -// without cookies, and a login redirect there breaks PWA installation. -export const config = { matcher: "/((?!_next/static|_next/image|favicon\\.ico|manifest\\.webmanifest|icon\\.svg|icon\\.png|icon-192\\.png).*)" }; +// session exists; these and the ordinary browser icons are public assets, +// not workspace data. /api/manifest stays authenticated: its link includes +// credentials and its embedded installation icons need no separate request. +export const config = { matcher: "/((?!_next/static|_next/image|favicon\\.ico|icon\\.svg|icon\\.png|icon-192\\.png).*)" }; From 6250641c73d72823fad290b8dc59ad8bd29c1b8f Mon Sep 17 00:00:00 2001 From: Steve Lalonde <16458080+lalondesteve@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:07:30 +0000 Subject: [PATCH 20/79] ci: rerun verification From 193047f16e3addf6242e2484bae21c4b67474319 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 5 Oct 2026 00:22:17 +0000 Subject: [PATCH 21/79] fix(ui): move session information to the top-right bar --- CHANGELOG.md | 1 + app/globals.css | 3 +++ components/AppShell.tsx | 21 ++++++++++++++++++--- components/ChatInput.tsx | 35 +++++++++++++++++++++++++---------- components/ChatWindow.tsx | 4 +++- 5 files changed, 50 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99cf728ec..ed5cf240f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - omp-web no longer exits when a browser disconnects in the middle of a POST request, for example when a tab closes while a request is still in flight. A Next.js bug (vercel/next.js#99278) could turn that disconnect into an uncaught `Error: aborted`, which the crash handler treated as fatal. These disconnects are now logged to `diagnostics.log` as `client-abort` and the server keeps running; every other uncaught error still exits. - When `PI_CODING_AGENT_DIR` points away from `~/.omp/agent`, the crash journal (`diagnostics.log`) is written to `/omp-web/` instead of `~/.omp/omp-web/`, so isolated test and development servers no longer write to the real journal. - Slide the file panel in from the right edge on phones, with the same timing as the left sidebar, instead of popping it open full-screen. The first open slides too, without a **Loading…** placeholder or a dimmed backdrop flashing over the chat. The panel still covers the whole screen once open, and it opens and closes instantly when reduced motion is enabled. +- Move the session information control (context ring with stats, tokens, cost, and **Compact context**) from the composer toolbar to the top-right of the top bar, keeping the ring usage coloring, its popover, keyboard dismissal, and the `/session` shortcut working as before. - Keep **Collapse input** available after expanding a long user message with **Show full input**, so the message can be collapsed again. - Agent host tools (`open_url`, `notify`, `open_file`) no longer fail when you switch to another session mid-run. Any open omp-web tab now answers them, and a URL or file from a session you are not viewing opens only after you confirm it in a dialog. - Ask before opening links from the agent. Turn on **Open agent links without asking** in Settings → Interface & Behavior to open links from the session you are viewing right away; links from other sessions always ask. diff --git a/app/globals.css b/app/globals.css index 7029118c8..ec37e1fe9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3453,6 +3453,9 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { gap: 0 4px !important; padding-right: 48px !important; } + .shell-topbar:has(.shell-session-info) { + padding-right: 92px !important; + } .shell-topbar-overflow-content { overflow-x: auto; justify-content: flex-start; diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 0d8559893..99cf52484 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -648,6 +648,7 @@ export function AppShell() { }, [resetAppUpdateVisibleStage]); const chatInputRef = useRef(null); const topBarRef = useRef(null); + const [sessionInfoContainer, setSessionInfoContainer] = useState(null); // Branch navigator state — populated by ChatWindow via onBranchDataChange const [branchTree, setBranchTree] = useState([]); @@ -762,8 +763,7 @@ export function AppShell() { }); }, [activeTopPanel, systemPrompt, systemPromptLoading, toggleTopPanel]); - // The topbar session panel is gone (its content lives in the composer ring - // popover), so /session opens that instead. + // /session opens the same information panel as the top-bar control. const openSessionStatsPanel = useCallback(() => { chatInputRef.current?.openContextPanel(); }, []); @@ -2156,7 +2156,7 @@ export function AppShell() { alignItems: "center", justifyContent: "flex-end", gap: 6, - paddingRight: rightPanelOpen ? 8 : 44, + paddingRight: (rightPanelOpen ? 8 : 44) + (showChat ? 36 : 0), minWidth: hasGenerationSpeed ? "calc(10ch + 33px)" : 0, width: hasGenerationSpeed ? 200 : 0, fontSize: 11, @@ -2213,6 +2213,20 @@ export function AppShell() { ); })()}
+ {showChat && ( +
+ )}
@@ -2282,6 +2296,7 @@ export function AppShell() { onSystemPromptLoaderChange={handleSystemPromptLoaderChange} onSessionStatsChange={handleSessionStatsChange} onSessionStatsPanelOpen={openSessionStatsPanel} + sessionInfoContainer={sessionInfoContainer} onGenerationSpeedChange={handleGenerationSpeedChange} onOpenProviders={() => setSettingsTab("providers")} toolCallsDefaultCollapsed={toolCallsDefaultCollapsed} diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 8b99e757a..0a1eea931 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useRef, useState, useCallback, useEffect, useLayoutEffect, useImperativeHandle, forwardRef, memo, KeyboardEvent } from "react"; +import { createPortal } from "react-dom"; import { ChevronDown, ClipboardPaste, ListChecks, Loader2, Mic, Paperclip, Plus, Shrink, Snail, Sparkles, Wrench, X, Zap } from "lucide-react"; import { getSubmitDuringRunBehavior, isWordCompletionEnabled } from "@/lib/composer-prefs"; import type { BuiltinSlashCommandResult, CompactResultInfo, QueuedMessages, SlashCommandInfo } from "@/hooks/useAgentSession"; @@ -183,12 +184,14 @@ interface Props { advisorActive?: boolean; /** Resolved advisor role (display model + reasoning) for the composer tooltips. */ advisorModel?: { name: string; reasoning: string | null } | null; - /** Compact the session context from the composer toolbar. */ + /** Compact the session context from the session information panel. */ onCompact?: () => void; - /** Live context totals feeding the composer context ring. */ + /** Live context totals feeding the session information ring. */ contextUsage?: { percent: number | null; contextWindow: number; tokens: number | null } | null; /** Session stats shown in the context ring popover. */ sessionStats?: SessionStatsInfo | null; + /** Top-bar mount point for the session information control. */ + sessionInfoContainer?: HTMLDivElement | null; /** Model capacity shown in the context ring popover. */ modelCapacity?: { contextWindow?: number; maxTokens?: number } | null; /** Generation speed shown in the context ring popover. */ @@ -327,6 +330,7 @@ export const ChatInput = memo(forwardRef(function ChatIn onCompact, contextUsage, sessionStats, + sessionInfoContainer, modelCapacity, generationSpeed, onRemoveQueuedMessage, @@ -1694,7 +1698,7 @@ export const ChatInput = memo(forwardRef(function ChatIn saved: formatTokenCount(compactSavedTokens, locale), }) : null; - // Composer context ring: live totals, falling back to the session snapshot. + // Session information ring: live totals, falling back to the session snapshot. const ringCtx = contextUsage ?? sessionStats?.contextUsage ?? null; const ringPct = ringCtx?.percent ?? null; const ringTone = ringPct !== null && ringPct > 90 @@ -3140,9 +3144,18 @@ export const ChatInput = memo(forwardRef(function ChatIn )} - {/* Context ring: usage gauge opening the session context popover */} - {onCompact && ( -
+ {/* Keep live session information with the composer state, but render it in the top bar. */} + {onCompact && sessionInfoContainer && createPortal( +
{ + if (event.key !== "Escape" || !contextOpen) return; + event.stopPropagation(); + setContextOpen(false); + contextWrapRef.current?.querySelector("button")?.focus(); + }} + >
)} -
+
, + sessionInfoContainer, )} {/* Dictation */} diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 6257d52dc..402dddc7a 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -50,6 +50,7 @@ interface Props { onSessionForked?: (newSessionId: string) => void; modelsRefreshKey?: number; chatInputRef?: React.RefObject; + sessionInfoContainer?: HTMLDivElement | null; onBranchDataChange?: (tree: SessionTreeNode[], activeLeafId: string | null, onLeafChange: (leafId: string | null) => void) => void; onSystemPromptChange?: (prompt: string | null) => void; onSystemPromptLoaderChange?: (loader: (() => Promise) | null) => void; @@ -490,7 +491,7 @@ const CommittedTranscript = memo(function CommittedTranscript({ ); }); -export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCallsDefaultCollapsed = true, hideThinkingBlock = false, onAgentEnd, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemPromptLoaderChange, onSessionStatsChange, onSessionStatsPanelOpen, onProviderUsageContextChange, onGenerationSpeedChange, onOpenFile, onOpenUrl, onOpenProviders }: Props) { +export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCallsDefaultCollapsed = true, hideThinkingBlock = false, onAgentEnd, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, sessionInfoContainer, onBranchDataChange, onSystemPromptChange, onSystemPromptLoaderChange, onSessionStatsChange, onSessionStatsPanelOpen, onProviderUsageContextChange, onGenerationSpeedChange, onOpenFile, onOpenUrl, onOpenProviders }: Props) { const { t, tn } = useI18n(); const { playDoneSound, unlockAudio } = useAudio(); const isMobile = useIsMobile(); @@ -1040,6 +1041,7 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa const chatInputElement = ( Date: Sat, 3 Oct 2026 21:00:44 +0000 Subject: [PATCH 22/79] feat: show skill startup diagnostics Use OMP live resolution instead of duplicating discovery. Keep the native notice preference and never resume stopped sessions for inspection. --- CHANGELOG.md | 1 + README.md | 13 + app/api/agent/[id]/route.ts | 16 +- components/ChatWindow.tsx | 4 + components/SettingsConfig.tsx | 12 +- components/SkillDiagnostics.test.mjs | 56 ++++ components/SkillDiagnostics.tsx | 158 ++++++++++ components/SkillsConfig.tsx | 6 + hooks/useAgentSession-stream.ts | 2 + hooks/useAgentSession.rpc.test.mjs | 319 +++++++++++++++++++++ hooks/useAgentSession.ts | 113 ++++++-- lib/agent-skill-diagnostics-route.test.mjs | 152 ++++++++++ lib/i18n/locales/en.json | 28 ++ lib/i18n/locales/ja.json | 28 ++ lib/i18n/locales/zh-CN.json | 28 ++ lib/omp/settings-config.test.mjs | 35 +++ lib/omp/settings-config.ts | 13 + lib/pi-types.ts | 6 + lib/rpc-manager.test.mjs | 122 ++++++++ lib/rpc-manager.ts | 38 +++ lib/skill-diagnostics.test.mjs | 87 ++++++ lib/skill-diagnostics.ts | 90 ++++++ 22 files changed, 1304 insertions(+), 23 deletions(-) create mode 100644 components/SkillDiagnostics.test.mjs create mode 100644 components/SkillDiagnostics.tsx create mode 100644 lib/agent-skill-diagnostics-route.test.mjs create mode 100644 lib/skill-diagnostics.test.mjs create mode 100644 lib/skill-diagnostics.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 99cf728ec..5637c5df4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f ### Added - Choose **Classifier** or **Vendor default** in Settings → AI Model Defaults → **Auto Thinking Source** when Reasoning is `auto`. The choice is saved in omp's native config for newly started or restarted agents. Vendor mode prefers the publisher's effort, then omp's per-model default, then normal Auto fallback. Requires an omp release containing [can1357/oh-my-pi#14114](https://github.com/can1357/oh-my-pi/pull/14114). +- Show conflicting skill names and deduplicated installations when an OMP session starts, with resolution details and a persisted **Turn off startup notices** action. Inspect a running session manually in **Settings → Extensions & Tools → Skills**, even when notices are off; inspection never resumes a stopped session. Requires an OMP runtime with the skill-diagnostics RPC commands; older runtimes report live diagnostics as unavailable. - Ask a side question with `/btw `: omp answers from the current conversation in a panel above the composer, even while the agent is working, and the question never enters the chat. Cancel a running answer, copy it, or ask follow-ups in the same topic. `/btw` on its own lists the session's past side questions, including ones asked in the terminal, each expandable to all its turns with **Copy** and **Follow up**. Side questions are text-only: with an attachment in the composer, `/btw` is refused and the draft is kept. An answer that omp lost (for example after its process restarted) shows as **Interrupted** instead of spinning. Requires an omp with the `btw` RPC commands; older omp shows "/btw requires a newer omp" instead of sending the text to the model. - Show omp's Claude usage-limit badge in the composer toolbar, matching the terminal status line during the wrap-up allowance or once `/slow` low priority takes over. Reset times use the browser's locale and timezone. Requires an omp version whose `get_state` reports structured `usageLimit` state. - Add a **Slow** toggle next to **Fast** in the composer toolbar, the equivalent of omp's `/slow`. On Claude models it turns low priority on or off in your omp settings, so it applies to every session and terminal; on OpenAI and Google models it switches this session to the provider's flex tier. Once the session's omp process runs, the toggle follows omp's `get_state` (`slowModeSupported`); before that, including in a new session, it follows the selected model's provider (`anthropic`, `openai`, `openai-codex`, `google`, `google-vertex`) and, on Claude, the `providers.anthropic.slowMode` setting. Clicking it there starts the session's omp process. diff --git a/README.md b/README.md index 0e5a78847..523724732 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,19 @@ Queue **Delete** and **Edit** additionally require `remove_queued_message`. Dele The queue panel shows omp's own queue (`queuedMessages` in `get_state` and `queue_update` events, omp 18.4.4 or later), so every device viewing a session sees the same queued messages. **Stop** moves the text of messages still waiting in the queue back into the composer instead of letting the agent run them; a steer the model already picked up through live steering still runs. Older omp runtimes show no queue panel, and Stop cannot take queued messages back. +Skill startup notices require an OMP runtime with `get_skill_diagnostics`, +`set_skill_startup_diagnostics`, and `skill_diagnostics_update`. Conflicts and +redundant installations appear above the composer when its OMP session starts; +an empty new-chat page does not start OMP just for diagnostics. **Details** shows +the resolved default, variants, identical copies, backing paths, sources, and +selection reason. **Turn off startup notices** and **Settings → Interface & +Behavior → Skill startup notices** use OMP's persisted +`skills.showStartupDiagnostics` preference, not a separate browser setting. +**Settings → Extensions & Tools → Skills → View skill diagnostics** remains +available for a selected running session when notices are off. Inspection does +not resume stopped sessions. Missing support or no running session is unavailable, +not a clean result. + ## Quick Start **Run directly without installing:** diff --git a/app/api/agent/[id]/route.ts b/app/api/agent/[id]/route.ts index f4eb1ba92..d6b394545 100644 --- a/app/api/agent/[id]/route.ts +++ b/app/api/agent/[id]/route.ts @@ -18,6 +18,13 @@ const NO_SPAWN_REPLIES: Record = { btw_cancel: { cancelled: false }, }; +// Live diagnostics of a skill-resolving omp. A saved or exited session has none, +// and resuming a full child (MCP, LSP) just to answer a read-only inspection or +// flip one setting is not what the click asked for: with no live wrapper these +// answer 409 instead of starting omp, and a live idle child is never replaced +// because the advisor flag differs. +const LIVE_SESSION_ONLY = new Set(["get_skill_diagnostics", "set_skill_startup_diagnostics"]); + /** omp-web's own failures carry a stable code the client can localize; omp's * errors stay opaque English text. */ function commandErrorResponse(error: unknown) { @@ -60,14 +67,21 @@ export async function POST( // running and pick the flag up at the next natural respawn. const existing = getRpcSession(id); const noSpawn = Object.hasOwn(NO_SPAWN_REPLIES, body.type); + const liveOnly = LIVE_SESSION_ONLY.has(body.type); if (existing?.isAlive()) { - if (noSpawn || existing.advisorSpawned === advisor || existing.isRunning()) { + if (noSpawn || liveOnly || existing.advisorSpawned === advisor || existing.isRunning()) { const result = await existing.send(body); return NextResponse.json({ success: true, data: result }); } await existing.destroyAndWait(); } if (noSpawn) return NextResponse.json({ success: true, data: NO_SPAWN_REPLIES[body.type] }); + if (liveOnly) { + return NextResponse.json( + { error: "Skill diagnostics require a running session", code: "skill_diagnostics_unavailable" }, + { status: 409 }, + ); + } const resolved = await resolveSessionPathOr404(id); if ("response" in resolved) return resolved.response; diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 6257d52dc..30bb80ba9 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -13,6 +13,7 @@ import { ExtensionDialog } from "./ExtensionDialog"; import { SubagentTranscriptDialog } from "./SubagentTranscriptDialog"; import { ChatMinimap, useMessageRefs } from "./ChatMinimap"; import { ComposerPanels } from "./ComposerPanels"; +import { SkillDiagnosticsNotice } from "./SkillDiagnostics"; import { BtwHistoryDialog, type BtwPanelProps } from "./BtwPanel"; import OmpWebLogo from "./OmpWebLogo"; import { CHAT_COLUMN_MAX_WIDTH, MINIMAP_WIDTH } from "@/lib/chat-layout"; @@ -532,6 +533,7 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa isCompacting, compactResult, tokensPerSecond, displayModel: displayModelValue, sessionStats, slashCommands, slashCommandsLoading, queuedMessages, advisorActive, advisorEnabled, handleAdvisorChange, notices, dismissNotice, extensionDialog, extensionCustomUi, extensionStatuses, extensionWidgets, respondToExtensionUi, sendExtensionCustomInput, + skillDiagnostics, setSkillStartupDiagnostics, isAutoModelSelection, agentPhase, activeGoal, activePlan, liveToolResults, @@ -1232,6 +1234,7 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa
{newSessionWorkspace} + setSkillStartupDiagnostics(false)} /> {/* ChatInput insets itself by CHAT_COLUMN_PADDING; cancel this column's padding so the composer matches its in-session width. */} @@ -1447,6 +1450,7 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa /> )} + setSkillStartupDiagnostics(false)} /> { if (currentTab === "system") { @@ -1140,6 +1142,9 @@ export function SettingsConfig({ activeTab, toolCallsDefaultCollapsed, onToolCal + + patchSection("skills", { showStartupDiagnostics: enabled })} /> +