From e9287506c6308ad78ef15c8f24ef7122491b5a32 Mon Sep 17 00:00:00 2001 From: touch2be Date: Sun, 14 Jun 2026 23:49:32 +0200 Subject: [PATCH 1/4] fix: surface harness session errors --- pi-bridge.ts | 2 +- src/App.tsx | 46 ++++++++++++++++ src/agents/shared.test.ts | 17 ++++++ src/agents/shared.ts | 4 ++ src/hooks/agent-backend-events.test.ts | 17 ++++-- src/hooks/agent-backend-events.ts | 10 ++-- src/hooks/agent-contexts.ts | 1 + src/hooks/agent-initial-state.ts | 1 + src/hooks/agent-local-intent.test.ts | 1 + src/hooks/agent-reducer.test.ts | 76 ++++++++++++++++++++++++++ src/hooks/agent-reducer.ts | 64 +++++++++++++++++++++- src/hooks/agent-state-types.ts | 2 + src/hooks/use-agent-impl-core.tsx | 2 + src/i18n/locales/de.json | 5 ++ src/i18n/locales/en.json | 5 ++ src/i18n/locales/es.json | 5 ++ 16 files changed, 247 insertions(+), 11 deletions(-) diff --git a/pi-bridge.ts b/pi-bridge.ts index aa73b42..8271b17 100644 --- a/pi-bridge.ts +++ b/pi-bridge.ts @@ -5,7 +5,7 @@ import { createServer as createNetServer } from "node:net"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { existsSync } from "node:fs"; -import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { mkdir, open, readFile, unlink, writeFile } from "node:fs/promises"; import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { getOAuthProvider } from "@earendil-works/pi-ai/oauth"; import { diff --git a/src/App.tsx b/src/App.tsx index 42d73b1..d9687b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { MergeDialog } from "@/components/MergeDialog"; import { QueueList } from "@/components/QueueList"; import { UpdateDialog } from "@/components/UpdateDialog"; import { NoProjectConnected, NoSessionSelected } from "@/components/EmptyChatStates"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { SidebarInset, SidebarProvider, useSidebar } from "@/components/ui/sidebar"; import { Toaster } from "@/components/ui/sonner"; @@ -40,6 +41,12 @@ import { SetupWizard } from "./components/SetupWizard"; import { TitleBar } from "./components/TitleBar"; import "./index.css"; +function extractTerminalCommand(message: string | null) { + if (!message) return null; + const match = message.match(/\bRun\s+['"`]([^'"`]+)['"`]\s+in\s+(?:your\s+)?terminal/i); + return match?.[1]?.trim() || null; +} + function AppContent({ detachedProject, suppressBootErrors, @@ -75,6 +82,7 @@ function AppContent({ isLoadingMessages, activeTargetDirectory, sessionMeta, + sessionErrors, } = useSessionState(); const { messages } = useMessages(); const { providers, selectedModel, providerDefaults } = useModelState(); @@ -93,6 +101,11 @@ function AppContent({ [bootLogs], ); const activeSessionId = sessionActiveId; + const activeSessionError = activeSessionId ? (sessionErrors[activeSessionId] ?? null) : null; + const activeSessionErrorCommand = useMemo( + () => extractTerminalCommand(activeSessionError), + [activeSessionError], + ); const { activeSession, activeSessionDirectory, @@ -178,6 +191,13 @@ function AppContent({ const contextPercent = contextInfo.percent; + const openTerminalForSessionError = useCallback(() => { + if (!activeSessionDirectory) return; + void getDesktopShellClient() + .system.openInTerminal(activeSessionDirectory) + .catch((error) => console.error(error)); + }, [activeSessionDirectory]); + // Check for app updates on startup const updateCheck = useUpdateCheck(); @@ -256,6 +276,32 @@ function AppContent({ {showPromptBox && (
+ {activeSessionError && ( + + {t("sessionError.title")} + +

{activeSessionError}

+ {activeSessionErrorCommand && ( +
+

{t("sessionError.nextStep")}

+ + {activeSessionErrorCommand} + +
+ )} + {activeSessionErrorCommand && activeSessionDirectory && ( + + )} +
+
+ )} {queuedPrompts.length > 0 && (
{ }); }); + test("prefixes session error ids", () => { + const event = { + type: "pi:event", + payload: { + type: "session.error", + sessionID: "raw-session", + error: "Claude auth expired", + }, + } as unknown as NativeBackendEvent; + + expect(normalizeTaggedBackendEvent("pi", event, "pi:event")).toEqual({ + type: "session.error", + sessionID: "pi:raw-session", + error: "Claude auth expired", + }); + }); + test("ignores unrelated native event channels", () => { const event = { type: "pi:event", diff --git a/src/agents/shared.ts b/src/agents/shared.ts index faa0010..39815b5 100644 --- a/src/agents/shared.ts +++ b/src/agents/shared.ts @@ -152,6 +152,10 @@ function normalizeBackendEventPayload(harnessId: HarnessId, payload: HarnessEven case "permission.cleared": case "question.cleared": return { ...payload, sessionID: codec.compose(payload.sessionID) }; + case "session.error": + return payload.sessionID + ? { ...payload, sessionID: codec.compose(payload.sessionID) } + : payload; case "permission.requested": return { ...payload, diff --git a/src/hooks/agent-backend-events.test.ts b/src/hooks/agent-backend-events.test.ts index 2a51d47..498a63a 100644 --- a/src/hooks/agent-backend-events.test.ts +++ b/src/hooks/agent-backend-events.test.ts @@ -166,7 +166,7 @@ describe("handleHarnessEvent", () => { expect(actions).toEqual([{ type: "SESSION_DELETED", payload: "session-1" }]); }); - test("surfaces only global session errors", () => { + test("surfaces session-scoped and global session errors", () => { const actions: Array> = []; const dispatch = (action: Record) => { actions.push(action); @@ -174,12 +174,12 @@ describe("handleHarnessEvent", () => { const sessionScoped: HarnessEvent = { type: "session.error", - error: "hidden", + error: "visible in session", sessionID: "session-1", }; const globalError: HarnessEvent = { type: "session.error", - error: "visible", + error: "visible globally", }; handleHarnessEvent({ @@ -199,6 +199,15 @@ describe("handleHarnessEvent", () => { dispatch: dispatch as never, }); - expect(actions).toEqual([{ type: "SET_ERROR", payload: "visible" }]); + expect(actions).toEqual([ + { + type: "SESSION_ERROR", + payload: { sessionID: "session-1", error: "visible in session" }, + }, + { + type: "SESSION_ERROR", + payload: { sessionID: undefined, error: "visible globally" }, + }, + ]); }); }); diff --git a/src/hooks/agent-backend-events.ts b/src/hooks/agent-backend-events.ts index c0c15fe..4b73d72 100644 --- a/src/hooks/agent-backend-events.ts +++ b/src/hooks/agent-backend-events.ts @@ -62,7 +62,8 @@ type BackendEventDispatch = ( type: "SET_QUESTION"; payload: QuestionRequest | { sessionID: string; clear: true }; } - | { type: "SET_ERROR"; payload: string | null }, + | { type: "SET_ERROR"; payload: string | null } + | { type: "SESSION_ERROR"; payload: { sessionID?: string; error: string } }, ) => void; const seenDeltaEventIds = new Set(); @@ -285,9 +286,10 @@ export function handleHarnessEvent({ }); return; case "session.error": - if (!event.sessionID) { - dispatch({ type: "SET_ERROR", payload: event.error }); - } + dispatch({ + type: "SESSION_ERROR", + payload: { sessionID: event.sessionID, error: event.error }, + }); return; } } diff --git a/src/hooks/agent-contexts.ts b/src/hooks/agent-contexts.ts index ccf40b9..9c09a8e 100644 --- a/src/hooks/agent-contexts.ts +++ b/src/hooks/agent-contexts.ts @@ -40,6 +40,7 @@ export interface SessionContextValue { unreadSessionIds: Set; sessionDrafts: Record; sessionMeta: SessionMetaMap; + sessionErrors: Record; } export interface MessagesContextValue { diff --git a/src/hooks/agent-initial-state.ts b/src/hooks/agent-initial-state.ts index c5c38fa..3eb1c2f 100644 --- a/src/hooks/agent-initial-state.ts +++ b/src/hooks/agent-initial-state.ts @@ -27,6 +27,7 @@ export const initialAgentState: InternalAgentState = { pendingPermissions: {}, pendingQuestions: {}, lastError: null, + sessionErrors: {}, bootState: "idle", bootError: null, bootLogs: null, diff --git a/src/hooks/agent-local-intent.test.ts b/src/hooks/agent-local-intent.test.ts index 235a92e..28e5f5b 100644 --- a/src/hooks/agent-local-intent.test.ts +++ b/src/hooks/agent-local-intent.test.ts @@ -20,6 +20,7 @@ function makeState(overrides: Partial = {}): InternalAgentSt pendingPermissions: {}, pendingQuestions: {}, lastError: null, + sessionErrors: {}, bootState: "idle", bootError: null, bootLogs: null, diff --git a/src/hooks/agent-reducer.test.ts b/src/hooks/agent-reducer.test.ts index 71bdd7c..4d165cb 100644 --- a/src/hooks/agent-reducer.test.ts +++ b/src/hooks/agent-reducer.test.ts @@ -33,6 +33,7 @@ function baseState(overrides: Partial = {}): InternalAgentSt pendingPermissions: {}, pendingQuestions: {}, lastError: null, + sessionErrors: {}, bootState: "idle", bootError: null, bootLogs: null, @@ -135,6 +136,81 @@ describe("mergeProjectBackendSessions", () => { expect(selected.isBusy).toBe(true); }); + test("session error stops active turn and records message", () => { + const sessionId = "pi:session-1"; + const running = reducer( + baseState({ activeSessionId: sessionId, sessions: [session(sessionId, "pi")] }), + { + type: "TURN_RUN_STARTED", + payload: { + id: "turn-1", + sessionID: sessionId, + startedAt: 1, + status: "running", + }, + } as Parameters[1], + ); + + const next = reducer(running, { + type: "SESSION_ERROR", + payload: { sessionID: sessionId, error: "Claude auth expired" }, + } as Parameters[1]); + + expect(next.isBusy).toBe(false); + expect(next.busySessionIds.has(sessionId)).toBe(false); + expect(next.sessionErrors[sessionId]).toBe("Claude auth expired"); + expect(next.lastError).toBe("Claude auth expired"); + expect(next.turnRuns["turn-1"]?.status).toBe("error"); + }); + + test("retry session status keeps session busy and records message", () => { + const sessionId = "opencode:session-1"; + const next = reducer(baseState({ activeSessionId: sessionId }), { + type: "SESSION_STATUS", + payload: { + sessionID: sessionId, + status: { + type: "retry", + attempt: 4, + message: "Claude authentication expired or invalid. Run 'claude login' in your terminal.", + next: Date.now() + 9000, + }, + }, + } as Parameters[1]); + + expect(next.isBusy).toBe(true); + expect(next.busySessionIds.has(sessionId)).toBe(true); + expect(next.sessionErrors[sessionId]).toContain("claude login"); + }); + + test("idle session status clears retry message", () => { + const sessionId = "opencode:session-1"; + const next = reducer( + baseState({ activeSessionId: sessionId, sessionErrors: { [sessionId]: "retrying" } }), + { + type: "SESSION_STATUS", + payload: { sessionID: sessionId, status: { type: "idle" } }, + } as Parameters[1], + ); + + expect(next.sessionErrors[sessionId]).toBeUndefined(); + }); + + test("new turn clears previous session error", () => { + const sessionId = "pi:session-1"; + const next = reducer(baseState({ sessionErrors: { [sessionId]: "old error" } }), { + type: "TURN_RUN_STARTED", + payload: { + id: "turn-1", + sessionID: sessionId, + startedAt: 1, + status: "running", + }, + } as Parameters[1]); + + expect(next.sessionErrors[sessionId]).toBeUndefined(); + }); + test("preserves chat-infra connection kind across backend status updates", () => { const next = reducer( baseState({ diff --git a/src/hooks/agent-reducer.ts b/src/hooks/agent-reducer.ts index d1f0c6c..75a5157 100644 --- a/src/hooks/agent-reducer.ts +++ b/src/hooks/agent-reducer.ts @@ -206,6 +206,7 @@ export type Action = }; } | { type: "SET_ERROR"; payload: string | null } + | { type: "SESSION_ERROR"; payload: { sessionID?: string; error: string } } | { type: "SET_BOOT_STATE"; payload: { @@ -922,8 +923,10 @@ export function reducer(state: InternalAgentState, action: Action): InternalAgen const run = action.payload; const busySessionIds = new Set(state.busySessionIds); busySessionIds.add(run.sessionID); + const { [run.sessionID]: _clearedSessionError, ...sessionErrors } = state.sessionErrors; return { ...state, + sessionErrors, busySessionIds, ...(run.sessionID === state.activeSessionId ? { isBusy: true } : {}), turnRuns: { @@ -938,7 +941,48 @@ export function reducer(state: InternalAgentState, action: Action): InternalAgen } case "SET_ERROR": - return { ...state, lastError: action.payload }; + return { + ...state, + lastError: action.payload, + ...(action.payload === null ? { sessionErrors: {} } : {}), + }; + + case "SESSION_ERROR": { + const { sessionID, error } = action.payload; + if (!sessionID) return { ...state, lastError: error }; + + const newBusy = new Set(state.busySessionIds); + newBusy.delete(sessionID); + const activeTurnId = getTurnRunIdForSession(state, sessionID); + const activeTurn = activeTurnId ? state.turnRuns[activeTurnId] : undefined; + const nextTurnRuns = + activeTurn?.status === "running" + ? { + ...state.turnRuns, + [activeTurn.id]: { + ...activeTurn, + completedAt: Date.now(), + status: "error" as const, + }, + } + : state.turnRuns; + const nextActiveTurnRunBySession = Object.fromEntries( + Object.entries(state.activeTurnRunBySession).filter(([sid, turnId]) => { + if (sid === sessionID) return false; + return turnId !== activeTurnId; + }), + ); + + return { + ...state, + lastError: error, + sessionErrors: { ...state.sessionErrors, [sessionID]: error }, + busySessionIds: newBusy, + turnRuns: nextTurnRuns, + activeTurnRunBySession: nextActiveTurnRunBySession, + ...(sessionID === state.activeSessionId ? { isBusy: false } : {}), + }; + } case "SET_PERMISSION": { const p = action.payload; @@ -1666,13 +1710,27 @@ export function reducer(state: InternalAgentState, action: Action): InternalAgen case "SESSION_STATUS": { const { sessionID, status } = action.payload; - const isBusy = status.type === "busy"; + const retryMessage = + status.type === "retry" && + "message" in status && + typeof status.message === "string" && + status.message.trim() + ? status.message.trim() + : null; + const isBusy = status.type === "busy" || status.type === "retry"; const newBusy = new Set(state.busySessionIds); if (isBusy) { newBusy.add(sessionID); } else { newBusy.delete(sessionID); } + const nextSessionErrors = retryMessage + ? { ...state.sessionErrors, [sessionID]: retryMessage } + : status.type === "idle" && state.sessionErrors[sessionID] + ? Object.fromEntries( + Object.entries(state.sessionErrors).filter(([id]) => id !== sessionID), + ) + : state.sessionErrors; // Keep session buffer cached even when session goes idle so // switching back to it is instant (LRU eviction handles cleanup). const nextBuffers = state._sessionBuffers; @@ -1705,6 +1763,8 @@ export function reducer(state: InternalAgentState, action: Action): InternalAgen ...state, ...completedTurnPatch, busySessionIds: newBusy, + sessionErrors: nextSessionErrors, + ...(retryMessage ? { lastError: retryMessage } : {}), unreadSessionIds: nextUnread, _sessionBuffers: nextBuffers, ...(sessionID === state.activeSessionId && !isBusy diff --git a/src/hooks/agent-state-types.ts b/src/hooks/agent-state-types.ts index 3dbb162..657a3b8 100644 --- a/src/hooks/agent-state-types.ts +++ b/src/hooks/agent-state-types.ts @@ -104,6 +104,8 @@ export interface InternalAgentState { pendingQuestions: Record; /** Last error surfaced to UI */ lastError: string | null; + /** Last error per session, shown next to active chat input */ + sessionErrors: Record; /** App startup status for local server bootstrap */ bootState: "idle" | "checking-server" | "starting-server" | "ready" | "error"; /** Startup error shown only when bootstrap fails */ diff --git a/src/hooks/use-agent-impl-core.tsx b/src/hooks/use-agent-impl-core.tsx index 8f765b9..c7e6cf8 100644 --- a/src/hooks/use-agent-impl-core.tsx +++ b/src/hooks/use-agent-impl-core.tsx @@ -2644,6 +2644,7 @@ function InternalAgentProvider({ unreadSessionIds: state.unreadSessionIds, sessionDrafts: state.sessionDrafts, sessionMeta: state.sessionMeta, + sessionErrors: state.sessionErrors, childSessions: state.childSessions, }), [ @@ -2662,6 +2663,7 @@ function InternalAgentProvider({ state.unreadSessionIds, state.sessionDrafts, state.sessionMeta, + state.sessionErrors, state.childSessions, ], ); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 3a218da..8bfcc2c 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -364,6 +364,11 @@ "checkingLocalServer": "Lokaler Server wird geprüft...", "startingLocalServer": "Lokaler Server wird gestartet..." }, + "sessionError": { + "title": "Agentenfehler", + "nextStep": "Nächster Schritt: Führe diesen Befehl im Terminal aus und versuche es erneut:", + "openTerminal": "Terminal öffnen" + }, "emptyStates": { "noProjectTitle": "Kein Projekt verbunden", "noProjectCanStart": "Verbinde jetzt ein Projekt oder starte einen Chat.", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 75b044d..78d4a99 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -361,6 +361,11 @@ "checkingLocalServer": "Checking local server...", "startingLocalServer": "Starting local server..." }, + "sessionError": { + "title": "Agent error", + "nextStep": "Next step: run this command in your terminal, then retry:", + "openTerminal": "Open terminal" + }, "emptyStates": { "noProjectTitle": "No project connected", "noProjectCanStart": "Connect a project now or start a chat.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 7c37a83..ede6c60 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -364,6 +364,11 @@ "checkingLocalServer": "Comprobando servidor local...", "startingLocalServer": "Iniciando servidor local..." }, + "sessionError": { + "title": "Error del agente", + "nextStep": "Siguiente paso: ejecuta este comando en tu terminal y vuelve a intentarlo:", + "openTerminal": "Abrir terminal" + }, "emptyStates": { "noProjectTitle": "No hay ningún proyecto conectado", "noProjectCanStart": "Conecta un proyecto ahora o inicia un chat.", From 224bdc16eb51e6d91ab3c4e295dca59676221c7d Mon Sep 17 00:00:00 2001 From: touch2be Date: Mon, 15 Jun 2026 00:02:33 +0200 Subject: [PATCH 2/4] Fix routed session revert actions --- server/services/harness-service.ts | 11 +++- server/services/session-lifecycle-actions.ts | 16 +++++- server/web-server.ts | 3 +- src/agents/backend.ts | 11 +++- src/hooks/agent-session-lifecycle.ts | 16 ++++-- src/hooks/use-agent-impl-core.tsx | 30 +++++++++- src/protocol/http-client.test.ts | 60 ++++++++++++++++++++ src/protocol/http-client.ts | 49 +++++++++------- 8 files changed, 163 insertions(+), 33 deletions(-) diff --git a/server/services/harness-service.ts b/server/services/harness-service.ts index 2dc2a43..2c916bb 100644 --- a/server/services/harness-service.ts +++ b/server/services/harness-service.ts @@ -346,6 +346,7 @@ export class HarnessService { async revertSession(input: { session: SessionRecord; + scope: HarnessScope; messageId: string; partId?: string; }): Promise { @@ -353,11 +354,17 @@ export class HarnessService { input.session.rawId, input.messageId, input.partId, + input.scope.directory, + undefined, ]); } - async unrevertSession(input: { session: SessionRecord }): Promise { - return this.backendRpc(input.session.harnessId, "session:unrevert", [input.session.rawId]); + async unrevertSession(input: { session: SessionRecord; scope: HarnessScope }): Promise { + return this.backendRpc(input.session.harnessId, "session:unrevert", [ + input.session.rawId, + input.scope.directory, + undefined, + ]); } async loadResources(input: { diff --git a/server/services/session-lifecycle-actions.ts b/server/services/session-lifecycle-actions.ts index 8e6d589..58eb22a 100644 --- a/server/services/session-lifecycle-actions.ts +++ b/server/services/session-lifecycle-actions.ts @@ -93,12 +93,18 @@ export async function forkSessionThroughHarness(input: { export async function revertSessionThroughHarness(input: { services: BackendServiceContext; + project: ProjectRecord; session: SessionRecord; messageId: string; partId?: string; }): Promise { const runtimeSession = await input.services.harnesses.revertSession({ session: input.session, + scope: buildHarnessScope({ + project: input.project, + harnessId: input.session.harnessId, + sessionId: input.session.id, + }), messageId: input.messageId, partId: input.partId, }); @@ -115,9 +121,17 @@ export async function revertSessionThroughHarness(input: { export async function unrevertSessionThroughHarness(input: { services: BackendServiceContext; + project: ProjectRecord; session: SessionRecord; }): Promise { - const runtimeSession = await input.services.harnesses.unrevertSession({ session: input.session }); + const runtimeSession = await input.services.harnesses.unrevertSession({ + session: input.session, + scope: buildHarnessScope({ + project: input.project, + harnessId: input.session.harnessId, + sessionId: input.session.id, + }), + }); if (!runtimeSession || typeof runtimeSession !== "object" || Array.isArray(runtimeSession)) { return null; } diff --git a/server/web-server.ts b/server/web-server.ts index 414c77b..b5da031 100644 --- a/server/web-server.ts +++ b/server/web-server.ts @@ -1323,6 +1323,7 @@ async function handleSessionRequest(request: Request) { } const session = await revertSessionThroughHarness({ services, + project, session: existing, messageId: body.messageId, partId: toOptionalString(body.partId, "partId"), @@ -1333,7 +1334,7 @@ async function handleSessionRequest(request: Request) { if (child === "unrevert") { if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 }); - const session = await unrevertSessionThroughHarness({ services, session: existing }); + const session = await unrevertSessionThroughHarness({ services, project, session: existing }); if (session) return Response.json({ ok: true, value: session }); return Response.json({ ok: true, value: true }); } diff --git a/src/agents/backend.ts b/src/agents/backend.ts index 4e6fd3b..76fa701 100644 --- a/src/agents/backend.ts +++ b/src/agents/backend.ts @@ -143,9 +143,14 @@ interface HarnessRuntime { deleteSession(sessionId: string): Promise; renameSession(sessionId: string, title: string): Promise; compactSession(sessionId: string, model?: SelectedModel, target?: HarnessTarget): Promise; - forkSession(sessionId: string, messageID?: string): Promise; - revertSession(sessionId: string, messageID: string, partID?: string): Promise; - unrevertSession(sessionId: string): Promise; + forkSession(sessionId: string, messageID?: string, target?: HarnessTarget): Promise; + revertSession( + sessionId: string, + messageID: string, + partID?: string, + target?: HarnessTarget, + ): Promise; + unrevertSession(sessionId: string, target?: HarnessTarget): Promise; sendCommand(input: { sessionId: string; command: string; diff --git a/src/hooks/agent-session-lifecycle.ts b/src/hooks/agent-session-lifecycle.ts index 20cba15..acf7bf5 100644 --- a/src/hooks/agent-session-lifecycle.ts +++ b/src/hooks/agent-session-lifecycle.ts @@ -1,4 +1,5 @@ import type { HarnessId } from "@/agents"; +import type { HarnessTarget } from "@/agents/backend"; import type { SessionMeta, WorktreeParentMap } from "@/hooks/agent-state-persistence"; import { resolvePendingPromptCreationHarnessRoute } from "@/hooks/agent-harness-routing"; import { getSessionHarnessId, getSessionProjectTarget } from "@/hooks/agent-session-utils"; @@ -65,9 +66,14 @@ interface SessionsClient { } interface SessionRuntime { - forkSession(sessionId: string, messageID?: string): Promise; - revertSession(sessionId: string, messageID: string): Promise; - unrevertSession(sessionId: string): Promise; + forkSession(sessionId: string, messageID?: string, target?: HarnessTarget): Promise; + revertSession( + sessionId: string, + messageID: string, + partID?: string, + target?: HarnessTarget, + ): Promise; + unrevertSession(sessionId: string, target?: HarnessTarget): Promise; } interface SessionMutationResult { @@ -368,6 +374,7 @@ export async function forkLifecycleSession({ selectSession, forceSessionTitle, dispatch, + target, }: { messageId: string; activeSessionId: string | null; @@ -376,12 +383,13 @@ export async function forkLifecycleSession({ selectSession: (sessionId: string, options?: { session?: Session }) => Promise; forceSessionTitle: (sessionId: string, title: string) => void; dispatch: (action: LifecycleAction) => void; + target?: HarnessTarget; }) { const plan = createSessionForkPlan({ activeSessionId, sessions }); if (!plan) return; try { - const session = await runtime.forkSession(plan.sourceSessionId, messageId); + const session = await runtime.forkSession(plan.sourceSessionId, messageId, target); const titledSession = { ...session, title: plan.forkTitle }; dispatch({ type: "SESSION_CREATED", payload: titledSession }); forceSessionTitle(session.id, plan.forkTitle); diff --git a/src/hooks/use-agent-impl-core.tsx b/src/hooks/use-agent-impl-core.tsx index c7e6cf8..67c4eac 100644 --- a/src/hooks/use-agent-impl-core.tsx +++ b/src/hooks/use-agent-impl-core.tsx @@ -2326,9 +2326,18 @@ function InternalAgentProvider({ ) ?? undefined, }); } + const activeSession = stateRef.current.sessions.find( + (session) => session.id === state.activeSessionId, + ); + const projectTarget = + getSessionProjectTarget( + activeSession, + activeSession ? stateRef.current.sessionMeta[activeSession.id] : undefined, + ) ?? undefined; await refreshLifecycleSession({ sessionId: state.activeSessionId, - mutateSession: () => runtime.revertSession(state.activeSessionId!, messageID), + mutateSession: () => + runtime.revertSession(state.activeSessionId!, messageID, undefined, projectTarget), fetchMessagePage, dispatch, errorMessage: "Failed to revert session", @@ -2346,9 +2355,17 @@ function InternalAgentProvider({ const unrevert = useCallback(async () => { if (!runtime || !state.activeSessionId) return; + const activeSession = stateRef.current.sessions.find( + (session) => session.id === state.activeSessionId, + ); + const projectTarget = + getSessionProjectTarget( + activeSession, + activeSession ? stateRef.current.sessionMeta[activeSession.id] : undefined, + ) ?? undefined; await refreshLifecycleSession({ sessionId: state.activeSessionId, - mutateSession: () => runtime.unrevertSession(state.activeSessionId!), + mutateSession: () => runtime.unrevertSession(state.activeSessionId!, projectTarget), fetchMessagePage, dispatch, errorMessage: "Failed to unrevert session", @@ -2358,6 +2375,14 @@ function InternalAgentProvider({ const forkFromMessage = useCallback( async (messageID: string) => { if (!runtime || !state.activeSessionId) return; + const activeSession = stateRef.current.sessions.find( + (session) => session.id === state.activeSessionId, + ); + const projectTarget = + getSessionProjectTarget( + activeSession, + activeSession ? stateRef.current.sessionMeta[activeSession.id] : undefined, + ) ?? undefined; await forkLifecycleSession({ messageId: messageID, activeSessionId: state.activeSessionId, @@ -2366,6 +2391,7 @@ function InternalAgentProvider({ selectSession, forceSessionTitle, dispatch, + target: projectTarget, }); }, [runtime, state.activeSessionId, selectSession, forceSessionTitle], diff --git a/src/protocol/http-client.test.ts b/src/protocol/http-client.test.ts index 5d5eb59..dbf121e 100644 --- a/src/protocol/http-client.test.ts +++ b/src/protocol/http-client.test.ts @@ -303,6 +303,66 @@ describe("createHttpOpenGuiClient", () => { }); }); + test("routes runtime revert through session project target", async () => { + const calls: Array<{ url: string; method: string; body: unknown }> = []; + const sessionRecord = { + id: "session_1", + rawId: "native-1", + projectId: "project_1", + harnessId: "opencode", + title: "Chat", + status: "unknown", + createdAt: "2026-05-12T00:00:00.000Z", + updatedAt: "2026-05-12T00:00:00.000Z", + }; + const projectRecord = { + id: "project_1", + displayName: "repo", + path: "/repo", + canonicalPath: "/repo", + createdAt: "2026-05-12T00:00:00.000Z", + updatedAt: "2026-05-12T00:00:00.000Z", + }; + const client = createHttpOpenGuiClient({ + baseUrl: "http://example.test", + fetchImpl: async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined; + calls.push({ url, method, body }); + if (url.endsWith("/api/projects") && method === "GET") { + return json({ ok: true, value: [projectRecord] }); + } + if (url.endsWith("/api/projects/project_1") && method === "GET") { + return json({ ok: true, value: projectRecord }); + } + if ( + url === + "http://example.test/api/sessions/opencode%3Asession_1/revert?harnessId=opencode&projectId=project_1" && + method === "POST" + ) { + return json({ ok: true, value: sessionRecord }); + } + throw new Error(`Unexpected fetch: ${method} ${url}`); + }, + }); + + const session = await client.harnesses + .get("opencode") + ?.runtime.revertSession("opencode:session_1", "msg_1", undefined, { + directory: "/repo", + workspaceId: "workspace-1", + }); + + expect(session).toMatchObject({ id: "opencode:native-1", _projectDir: "/repo" }); + expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([ + "GET http://example.test/api/projects", + "POST http://example.test/api/sessions/opencode%3Asession_1/revert?harnessId=opencode&projectId=project_1", + "GET http://example.test/api/projects/project_1", + ]); + expect(calls[1]?.body).toEqual({ messageId: "msg_1" }); + }); + test("sends session and project context with question replies", async () => { const calls: Array<{ url: string; method: string; body: unknown }> = []; const client = createHttpOpenGuiClient({ diff --git a/src/protocol/http-client.ts b/src/protocol/http-client.ts index 071caaa..b70909f 100644 --- a/src/protocol/http-client.ts +++ b/src/protocol/http-client.ts @@ -150,10 +150,12 @@ function createWebBackendDescriptor( renameSession: (sessionId, title) => backendCall("session:update", [sessionId, title]), compactSession: (sessionId, model, target) => backendCall("session:summarize", [sessionId, model, ...targetArgs(target)]), - forkSession: (sessionId, messageID) => backendCall("session:fork", [sessionId, messageID]), - revertSession: (sessionId, messageID, partID) => - backendCall("session:revert", [sessionId, messageID, partID]), - unrevertSession: (sessionId) => backendCall("session:unrevert", [sessionId]), + forkSession: (sessionId, messageID, target) => + backendCall("session:fork", [sessionId, messageID, ...targetArgs(target)]), + revertSession: (sessionId, messageID, partID, target) => + backendCall("session:revert", [sessionId, messageID, partID, ...targetArgs(target)]), + unrevertSession: (sessionId, target) => + backendCall("session:unrevert", [sessionId, ...targetArgs(target)]), sendCommand: (input) => backendCall("command:send", [ input.sessionId, @@ -578,15 +580,20 @@ export function createHttpOpenGuiClient(options: HttpOpenGuiClientOptions = {}): ); return await toFrontendSession(record); }, - compactSession: async (sessionId, model) => { - await request(await resolveSessionPath({ sessionId, harnessId }, "/compact"), { - method: "POST", - body: JSON.stringify({ model }), - }); + compactSession: async (sessionId, model, target) => { + await requestAt( + requestBaseUrlForSession({ sessionId, harnessId, target }), + await resolveSessionPath({ sessionId, harnessId, target }, "/compact"), + { + method: "POST", + body: JSON.stringify({ model }), + }, + ); }, - forkSession: async (sessionId, messageID) => { - const record = await request( - await resolveSessionPath({ sessionId, harnessId }, "/fork"), + forkSession: async (sessionId, messageID, target) => { + const record = await requestAt( + requestBaseUrlForSession({ sessionId, harnessId, target }), + await resolveSessionPath({ sessionId, harnessId, target }, "/fork"), { method: "POST", body: JSON.stringify({ messageId: messageID }), @@ -594,9 +601,10 @@ export function createHttpOpenGuiClient(options: HttpOpenGuiClientOptions = {}): ); return await toFrontendSession(record); }, - revertSession: async (sessionId, messageID, partID) => { - const value = await request( - await resolveSessionPath({ sessionId, harnessId }, "/revert"), + revertSession: async (sessionId, messageID, partID, target) => { + const value = await requestAt( + requestBaseUrlForSession({ sessionId, harnessId, target }), + await resolveSessionPath({ sessionId, harnessId, target }, "/revert"), { method: "POST", body: JSON.stringify({ messageId: messageID, partId: partID }), @@ -604,18 +612,19 @@ export function createHttpOpenGuiClient(options: HttpOpenGuiClientOptions = {}): ); return await toFrontendSession( typeof value === "boolean" - ? await getSessionRecord(sessionId, { sessionId, harnessId }) + ? await getSessionRecord(sessionId, { sessionId, harnessId, target }) : value, ); }, - unrevertSession: async (sessionId) => { - const value = await request( - await resolveSessionPath({ sessionId, harnessId }, "/unrevert"), + unrevertSession: async (sessionId, target) => { + const value = await requestAt( + requestBaseUrlForSession({ sessionId, harnessId, target }), + await resolveSessionPath({ sessionId, harnessId, target }, "/unrevert"), { method: "POST" }, ); return await toFrontendSession( typeof value === "boolean" - ? await getSessionRecord(sessionId, { sessionId, harnessId }) + ? await getSessionRecord(sessionId, { sessionId, harnessId, target }) : value, ); }, From 549a639606288513495191c039b78579d254db7c Mon Sep 17 00:00:00 2001 From: touch2be Date: Mon, 15 Jun 2026 16:16:31 +0200 Subject: [PATCH 3/4] fix: queue prompts for sessions without project records --- server/services/prompt-queue-service.ts | 23 +++++-- src/components/PromptBox.tsx | 3 +- src/hooks/agent-local-intent.test.ts | 53 ++++++++++++++++ src/hooks/agent-local-intent.ts | 35 +++++++---- src/hooks/agent-session-queue.ts | 9 +-- src/hooks/use-agent-impl-core.tsx | 7 ++- src/hooks/use-prompt-submit.ts | 83 ++++++++++++++++--------- src/protocol/client.ts | 2 +- src/server-prompt-queue-service.test.ts | 72 +++++++++++++++++++++ 9 files changed, 230 insertions(+), 57 deletions(-) create mode 100644 src/server-prompt-queue-service.test.ts diff --git a/server/services/prompt-queue-service.ts b/server/services/prompt-queue-service.ts index 3b5b17c..73e5544 100644 --- a/server/services/prompt-queue-service.ts +++ b/server/services/prompt-queue-service.ts @@ -90,7 +90,7 @@ export class PromptQueueService { const created = await this.storage.createPromptQueueEntry({ sessionId: session.id, harnessId: session.harnessId, - projectDirectory: await this.getProjectDirectory(session.projectId), + projectDirectory: await this.getProjectDirectory(session), harnessSessionId: session.rawId, text: input.text, model: input.model, @@ -271,10 +271,23 @@ export class PromptQueueService { return sessions; } - private async getProjectDirectory(projectId: string): Promise { - const project = await this.projects.getProject(projectId); - if (!project) throw new Error("Project not found"); - return project.canonicalPath || project.path; + private async getProjectDirectory(session: SessionRecord): Promise { + const project = await this.projects.getProject(session.projectId); + if (project) return project.canonicalPath || project.path; + + const metadataDirectory = + session.metadata && typeof session.metadata.directory === "string" + ? session.metadata.directory.trim() + : ""; + if (metadataDirectory) return metadataDirectory; + + // Older Session records sometimes used the directory itself as projectId. + // Keep queue usable for those records instead of failing a running Session. + if (session.projectId.startsWith("/") || session.projectId.startsWith("~")) { + return session.projectId; + } + + throw new Error("Project not found"); } private async reindex(sessionId: string): Promise { diff --git a/src/components/PromptBox.tsx b/src/components/PromptBox.tsx index 6d16c46..2c6ccee 100644 --- a/src/components/PromptBox.tsx +++ b/src/components/PromptBox.tsx @@ -439,7 +439,8 @@ export const PromptBox = React.forwardRef( : t("prompt.queue") : t("prompt.send") } - disabled={isDisabled || !promptSubmit.hasValue} + disabled={isDisabled} + className={!promptSubmit.hasValue ? "opacity-50" : undefined} onClick={(e) => { e.stopPropagation(); void promptSubmit.submit(); diff --git a/src/hooks/agent-local-intent.test.ts b/src/hooks/agent-local-intent.test.ts index 28e5f5b..874ee45 100644 --- a/src/hooks/agent-local-intent.test.ts +++ b/src/hooks/agent-local-intent.test.ts @@ -302,6 +302,59 @@ describe("createLocalIntentOrchestrator", () => { ]); }); + test("queues busy-session prompts even when local Session lacks project directory metadata", async () => { + const session = makeSession({ + id: "pi:session-1", + directory: undefined as never, + _harnessId: "pi", + _rawId: "session-1", + _workspaceId: "workspace-1", + }); + const model: SelectedModel = { providerID: "openai", modelID: "gpt-5" }; + const state = makeState({ + activeSessionId: session.id, + sessions: [session], + busySessionIds: new Set([session.id]), + selectedModel: model, + }); + const queued: Array> = []; + + const orchestrator = createLocalIntentOrchestrator({ + getState: () => state, + getResourceRuntime: () => undefined, + getCurrentVariant: () => undefined, + sessionsClient: { + prompt: async () => undefined, + abort: async () => undefined, + queue: { + enqueue: async (input: Record) => { + queued.push(input); + return [{ id: "queue-1", text: String(input.text), mode: "queue" }]; + }, + }, + } as never, + createSession: async () => null, + scheduleSessionMessageReconcile: () => undefined, + requestSessionAutoName: () => undefined, + dispatch: () => undefined, + sessionCreatingRef: { current: false }, + }); + + await orchestrator.sendPrompt("Queue without directory"); + + expect(queued).toEqual([ + expect.objectContaining({ + sessionId: "pi:session-1", + text: "Queue without directory", + model, + mode: "queue", + insertAt: "back", + harnessId: "pi", + target: { workspaceId: "workspace-1" }, + }), + ]); + }); + test("sends commands through the active backend runtime and reconciles afterward", async () => { const session = makeSession({ id: "session-1", diff --git a/src/hooks/agent-local-intent.ts b/src/hooks/agent-local-intent.ts index bfe8650..541b942 100644 --- a/src/hooks/agent-local-intent.ts +++ b/src/hooks/agent-local-intent.ts @@ -18,6 +18,7 @@ import { import { decideSessionEntry } from "@/hooks/agent-session-entry"; import { getSessionProjectTarget } from "@/hooks/agent-session-utils"; import type { InternalAgentState, QueueMode, Session } from "@/hooks/agent-state-types"; +import { getErrorMessage } from "@/lib/utils"; import { getSessionDraftKey } from "@/lib/session-drafts"; import { generateSessionTitle } from "@/lib/session-namer"; import type { OpenGuiClient } from "@/protocol/client"; @@ -207,8 +208,9 @@ export function createLocalIntentOrchestrator( mode, }); scheduleSessionMessageReconcile(sessionId, projectTarget); - } catch { + } catch (error) { dispatch({ type: "SET_BUSY", payload: false }); + dispatch({ type: "SET_ERROR", payload: getErrorMessage(error, "Failed to send prompt") }); } }; @@ -239,16 +241,24 @@ export function createLocalIntentOrchestrator( dispatch({ type: "SET_ERROR", payload: "Choose a Harness model before sending." }); return false; } - await sessionQueue.enqueuePrompt({ - sessionId: input.sessionId, - text: prepareDirectoryChangePrompt(input.sessionId, input.text), - model: selection.model, - agent: selection.agent, - variant: selection.variant, - mode: input.mode, - insertAt: input.insertAt, - }); - return true; + try { + await sessionQueue.enqueuePrompt({ + sessionId: input.sessionId, + text: prepareDirectoryChangePrompt(input.sessionId, input.text), + model: selection.model, + agent: selection.agent, + variant: selection.variant, + mode: input.mode, + insertAt: input.insertAt, + }); + return true; + } catch (error) { + dispatch({ + type: "SET_ERROR", + payload: getErrorMessage(error, "Failed to queue prompt"), + }); + return false; + } }; const resolveNamedSession = async (sourceText: string): Promise => { @@ -347,8 +357,9 @@ export function createLocalIntentOrchestrator( }, }); scheduleSessionMessageReconcile(sessionId, projectTarget); - } catch { + } catch (error) { dispatch({ type: "SET_BUSY", payload: false }); + dispatch({ type: "SET_ERROR", payload: getErrorMessage(error, "Failed to send command") }); } }; diff --git a/src/hooks/agent-session-queue.ts b/src/hooks/agent-session-queue.ts index a3d2b26..8115d43 100644 --- a/src/hooks/agent-session-queue.ts +++ b/src/hooks/agent-session-queue.ts @@ -1,5 +1,5 @@ import { resolveSessionHarnessRoute } from "@/hooks/agent-harness-routing"; -import { getSessionProjectTarget } from "@/hooks/agent-session-utils"; +import { getSessionProjectTarget, getSessionWorkspaceId } from "@/hooks/agent-session-utils"; import type { SessionMetaMap } from "@/hooks/agent-state-persistence"; import type { InternalAgentState, QueuedPrompt, Session } from "@/hooks/agent-state-types"; import type { OpenGuiClient, QueueScopeInput } from "@/protocol/client"; @@ -33,10 +33,11 @@ function getSessionLookup( const harnessId = resolveSessionHarnessRoute(session).harnessId ?? undefined; const target = getSessionProjectTarget(session, session ? sessionMeta[session.id] : undefined) ?? undefined; - if (!harnessId || !target?.directory) { - throw new Error("Queued prompt target requires Harness, Project directory, and Session ID"); + const workspaceId = getSessionWorkspaceId(session) ?? undefined; + if (!harnessId) { + throw new Error("Queued prompt target requires Harness and Session ID"); } - return { harnessId, target: { ...target, directory: target.directory } }; + return { harnessId, target: target ?? (workspaceId ? { workspaceId } : undefined) }; } export function createSessionQueueOrchestrator( diff --git a/src/hooks/use-agent-impl-core.tsx b/src/hooks/use-agent-impl-core.tsx index 67c4eac..46247ce 100644 --- a/src/hooks/use-agent-impl-core.tsx +++ b/src/hooks/use-agent-impl-core.tsx @@ -2213,12 +2213,13 @@ function InternalAgentProvider({ session, session ? stateRef.current.sessionMeta[session.id] : undefined, ) ?? undefined; - if (!harnessId || !target?.directory) { - throw new Error("Queued prompt target requires Harness, Project directory, and Session ID"); + const workspaceId = getSessionWorkspaceId(session) ?? undefined; + if (!harnessId) { + throw new Error("Queued prompt target requires Harness and Session ID"); } return { harnessId, - target: { ...target, directory: target.directory }, + target: target ?? (workspaceId ? { workspaceId } : undefined), }; }, []); diff --git a/src/hooks/use-prompt-submit.ts b/src/hooks/use-prompt-submit.ts index 969e850..ad9aaeb 100644 --- a/src/hooks/use-prompt-submit.ts +++ b/src/hooks/use-prompt-submit.ts @@ -68,21 +68,53 @@ export function usePromptSubmit({ resetHistory: () => void; }) { const submittingRef = React.useRef(false); + const latestRef = React.useRef({ + value, + disabled, + isUploading, + isLoading, + queueMode, + parseSlashCommand, + sendCommand, + onSubmit, + clearPromptDraft, + onAfterSubmit, + resetSlashCommand, + resetHistory, + }); + + latestRef.current = { + value, + disabled, + isUploading, + isLoading, + queueMode, + parseSlashCommand, + sendCommand, + onSubmit, + clearPromptDraft, + onAfterSubmit, + resetSlashCommand, + resetHistory, + }; + const hasValue = value.trim().length > 0; const submit = React.useCallback(async () => { + const latest = latestRef.current; + const currentValue = latest.value; if (submittingRef.current) return; - if (disabled || isUploading) return; - if (!hasValue) return; + if (latest.disabled || latest.isUploading) return; + if (currentValue.trim().length === 0) return; submittingRef.current = true; const decision = decidePromptSubmit({ - value, - disabled, - isUploading, - isLoading, - queueMode, - slashInvocation: parseSlashCommand(value), + value: currentValue, + disabled: latest.disabled, + isUploading: latest.isUploading, + isLoading: latest.isLoading, + queueMode: latest.queueMode, + slashInvocation: latest.parseSlashCommand(currentValue), }); if (decision.type === "skip") { @@ -92,35 +124,24 @@ export function usePromptSubmit({ try { if (decision.type === "command") { - clearPromptDraft(); - resetSlashCommand(); - resetHistory(); - await sendCommand(decision.commandName, decision.args); + latest.clearPromptDraft(); + latest.resetSlashCommand(); + latest.resetHistory(); + await latest.sendCommand(decision.commandName, decision.args); return; } - await onSubmit?.(decision.text, decision.mode); - clearPromptDraft(); - onAfterSubmit?.(); - resetHistory(); + const submission = Promise.resolve(latest.onSubmit?.(decision.text, decision.mode)); + latest.clearPromptDraft(); + latest.onAfterSubmit?.(); + latest.resetHistory(); + submission.catch((error) => { + console.error("Failed to submit prompt", error); + }); } finally { submittingRef.current = false; } - }, [ - clearPromptDraft, - disabled, - hasValue, - isUploading, - isLoading, - onSubmit, - onAfterSubmit, - parseSlashCommand, - queueMode, - resetHistory, - resetSlashCommand, - sendCommand, - value, - ]); + }, []); return { hasValue, submit }; } diff --git a/src/protocol/client.ts b/src/protocol/client.ts index 74625ed..d6056ed 100644 --- a/src/protocol/client.ts +++ b/src/protocol/client.ts @@ -139,7 +139,7 @@ export type QueueHarnessTarget = HarnessTarget & { directory: string }; export interface QueueScopeInput { harnessId: HarnessId; - target: QueueHarnessTarget; + target?: HarnessTarget; } export interface OpenGuiClient { diff --git a/src/server-prompt-queue-service.test.ts b/src/server-prompt-queue-service.test.ts new file mode 100644 index 0000000..71fb702 --- /dev/null +++ b/src/server-prompt-queue-service.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "@voidzero-dev/vite-plus-test"; +import { PromptQueueService } from "../server/services/prompt-queue-service.ts"; +import type { PromptQueueEntryRecord, StorageService } from "../server/services/storage-service.ts"; +import type { SessionRecord } from "../server/services/session-types.ts"; + +function makeStorage(entries: PromptQueueEntryRecord[] = []): StorageService { + return { + listPromptQueue: async () => entries, + createPromptQueueEntry: async (input) => { + const entry: PromptQueueEntryRecord = { + id: input.id ?? "queue-1", + sessionId: input.sessionId, + harnessId: input.harnessId, + projectDirectory: input.projectDirectory, + harnessSessionId: input.harnessSessionId, + text: input.text, + createdAt: input.createdAt ?? 1, + model: input.model, + agent: input.agent, + variant: input.variant, + mode: input.mode, + order: input.order ?? entries.length, + }; + entries.push(entry); + return entry; + }, + replacePromptQueue: async (_sessionId, next) => { + entries.splice(0, entries.length, ...next); + return entries; + }, + } as StorageService; +} + +describe("PromptQueueService", () => { + test("uses Session metadata directory when Project record is missing", async () => { + const session: SessionRecord = { + id: "session_1", + rawId: "raw-1", + projectId: "missing-project", + harnessId: "opencode", + title: "Running", + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + metadata: { directory: "/repo" }, + }; + const queue = new PromptQueueService( + makeStorage(), + { + getSession: async () => session, + } as never, + { + getProject: async () => null, + } as never, + ); + + const entries = await queue.enqueue( + "opencode:raw-1", + { text: "Continue", mode: "queue" }, + { projectId: session.projectId, harnessId: session.harnessId }, + ); + + expect(entries).toEqual([ + expect.objectContaining({ + sessionId: "opencode:raw-1", + canonicalSessionId: "session_1", + projectDirectory: "/repo", + text: "Continue", + }), + ]); + }); +}); From ab46f382eb8a305fec9c32dc1c7a8774fd917bf7 Mon Sep 17 00:00:00 2001 From: akemmanuel Date: Tue, 16 Jun 2026 14:33:16 -0300 Subject: [PATCH 4/4] chore: clarify dev scripts and move desktop dev to scripts/ --- AGENTS.md | 8 +++++--- CONTRIBUTING.md | 14 ++++---------- README.md | 21 +++++++-------------- docs/architecture.md | 3 ++- package.json | 3 ++- dev.ts => scripts/dev-desktop.ts | 5 ++--- src/components/EmptyChatStates.test.tsx | 6 +++++- 7 files changed, 27 insertions(+), 33 deletions(-) rename dev.ts => scripts/dev-desktop.ts (92%) diff --git a/AGENTS.md b/AGENTS.md index 001332a..7a0a31a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,8 @@ Default to using Vite+ (`vp`) instead of raw runtime or package-manager commands. -- Use `vp dev` for development +- Use `pnpm run dev` for desktop development (Electron) +- Use `pnpm run dev:web` for web development (browser) +- Use `pnpm run start` / `pnpm run start:web` for production runs - Use `vp check` for lint, format, and type checks - Use `vp lint` for lint only - Use `vp fmt` for format only @@ -8,7 +10,7 @@ Default to using Vite+ (`vp`) instead of raw runtime or package-manager commands - Use `vp build` for production build - Use `vp run ` for project tasks - Use `vp exec ` for local binaries -- Use `vp node ` for Node.js scripts +- Use `node --experimental-strip-types ` for project TypeScript scripts (or `vp node` when Vite+ env shims are installed) - Use `vp dlx ` for one-off package binaries - Use `vp cache` for task cache - Use `pnpm install` to install dependencies @@ -18,7 +20,7 @@ Default to using Vite+ (`vp`) instead of raw runtime or package-manager commands ## Development -Use Vite+ task runner and pnpm-managed deps. +Run the app with `pnpm run dev` (Electron) or `pnpm run dev:web` (browser). Use Vite+ (`vp`) for lint, format, typecheck, test, and build—not for choosing dev vs prod. ## Code quality diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 625d5f8..99a8919 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,16 +22,9 @@ pnpm install ## Development -Run the development app: - -```bash -vp dev -``` - -Or run the browser version with backend API: - ```bash -vp run dev:web +pnpm run dev # Electron (desktop) +pnpm run dev:web # Browser + local backend API ``` ## Code Style @@ -39,7 +32,8 @@ vp run dev:web This project uses Vite+ tasks: ```bash -vp dev # development +pnpm run dev # desktop development +pnpm run dev:web # web development vp lint # lint check vp check # lint, format, and type checks vp test # unit tests diff --git a/README.md b/README.md index 8fe5a1d..ed76656 100644 --- a/README.md +++ b/README.md @@ -109,19 +109,12 @@ No manual config file needed. Connection settings live in UI. Pick a Harness, co ### Development -Run the development app: +| Goal | Command | +| ---------------------------------- | ------------------ | +| Desktop app (Electron, hot reload) | `pnpm run dev` | +| Web UI only (browser + API) | `pnpm run dev:web` | -```bash -vp dev -``` - -Run web app with local backend API (projects, git, Harnesses): - -```bash -vp run dev:web -``` - -Open `http://127.0.0.1:3000`. Browser folder picker uses server paths. Set `OPENGUI_ALLOWED_ROOTS=/path/to/projects` to restrict browsable folders. +For `dev:web`, open the URL Vite prints in the terminal (default port is often 5173). Browser folder picker uses server paths. Set `OPENGUI_ALLOWED_ROOTS=/path/to/projects` to restrict browsable folders. ### Docker @@ -142,13 +135,13 @@ vp build Run Electron app in production mode: ```bash -vp run start +pnpm run start ``` Build and run web app in production mode: ```bash -vp run start:web +pnpm run start:web ``` For internet-facing deploys, keep OpenGUI bound to localhost and put Apache or another HTTPS reverse proxy in front. diff --git a/docs/architecture.md b/docs/architecture.md index 27ed83c..11e8573 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,7 +65,8 @@ Use Vite+ (`vp`) for project tasks and pnpm for dependency changes: ```bash pnpm install # install dependencies -vp dev # development +pnpm run dev # desktop development (Electron) +pnpm run dev:web # web development (browser) vp check # lint, format, and type checks vp lint # lint only vp fmt # format only diff --git a/package.json b/package.json index 2a34684..d0adeb2 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "scripts": { "postinstall": "node ./scripts/ensure-electron.mjs", "prepare": "vp config", - "dev:electron": "vp run dev", + "dev": "node --experimental-strip-types scripts/dev-desktop.ts", + "dev:web": "vp dev", "build": "vp build", "check": "vp check", "check:fix": "vp check --fix", diff --git a/dev.ts b/scripts/dev-desktop.ts similarity index 92% rename from dev.ts rename to scripts/dev-desktop.ts index c763130..30d5791 100644 --- a/dev.ts +++ b/scripts/dev-desktop.ts @@ -1,7 +1,6 @@ /** - * Dev script - replaces concurrently + wait-on. - * Starts the Vite+ dev server, waits for it to be ready, then launches Electron. - * Kills both processes on exit. + * Desktop development: build Electron main/preload, run Vite + web backend, launch Electron. + * Use: pnpm run dev */ import { spawn, spawnSync } from "node:child_process"; diff --git a/src/components/EmptyChatStates.test.tsx b/src/components/EmptyChatStates.test.tsx index 6ada058..83e935c 100644 --- a/src/components/EmptyChatStates.test.tsx +++ b/src/components/EmptyChatStates.test.tsx @@ -1,8 +1,12 @@ -import { describe, expect, test } from "@voidzero-dev/vite-plus-test"; +import { describe, expect, test, beforeAll } from "@voidzero-dev/vite-plus-test"; import { renderToStaticMarkup } from "react-dom/server"; +import { initI18n } from "@/i18n"; import { NoProjectConnected, NoSessionSelected } from "./EmptyChatStates"; describe("EmptyChatStates", () => { + beforeAll(async () => { + await initI18n(); + }); test("NoProjectConnected invites project connection when chat cannot start", () => { const markup = renderToStaticMarkup( {}} />,