diff --git a/app/api/agent/[id]/events/route.ts b/app/api/agent/[id]/events/route.ts index dfb0620a8e..8c33d62458 100644 --- a/app/api/agent/[id]/events/route.ts +++ b/app/api/agent/[id]/events/route.ts @@ -51,7 +51,7 @@ export async function GET( }; // Send initial connected event - encode({ type: "connected", sessionId: id }); + encode({ type: "connected", sessionId: session.sessionId, runtimeId: session.runtimeId }); const unsubscribe = session.onEvent((event) => { const clientEvent = toClientEvent(event); diff --git a/app/api/agent/[id]/route.ts b/app/api/agent/[id]/route.ts index ea7debda2e..8d7e1488be 100644 --- a/app/api/agent/[id]/route.ts +++ b/app/api/agent/[id]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { resolveSessionPath } from "@/lib/session-reader"; -import { startRpcSession, getRpcSession } from "@/lib/rpc-manager"; +import { startRpcSession, getRpcSession, getFinalRpcSessionId } from "@/lib/rpc-manager"; import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security"; // POST /api/agent/[id] - Send a command to an existing session @@ -28,7 +28,15 @@ export async function POST( if (existing?.isAlive()) { const result = await existing.send(body); promptAccepted = body.type === "prompt"; - return NextResponse.json({ success: true, data: result }); + existing.syncSessionIdentity(); + return NextResponse.json({ success: true, data: result, sessionId: existing.sessionId, runtimeId: existing.runtimeId }); + } + + if (id.startsWith("runtime:")) { + return NextResponse.json({ + error: "Runtime session expired", code: "runtime_expired", accepted: false, + sessionId: getFinalRpcSessionId(id), + }, { status: 404 }); } const filePath = await resolveSessionPath(id); @@ -45,7 +53,8 @@ export async function POST( const result = await session.send(body); promptAccepted = body.type === "prompt"; - return NextResponse.json({ success: true, data: result }); + session.syncSessionIdentity(); + return NextResponse.json({ success: true, data: result, sessionId: session.sessionId, runtimeId: session.runtimeId }); } catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : String(error), @@ -64,9 +73,13 @@ export async function GET( const { id } = await params; try { - const session = getRpcSession(id); + let session = getRpcSession(id); if (!session || !session.isAlive()) { - return NextResponse.json({ running: false }); + const sessionId = getFinalRpcSessionId(id); + session = sessionId ? getRpcSession(sessionId) : undefined; + if (!session?.isAlive()) { + return NextResponse.json({ running: false, ...(sessionId ? { state: { sessionId } } : {}) }); + } } const state = await session.send({ type: "get_state" }); diff --git a/app/api/agent/new/route.ts b/app/api/agent/new/route.ts index 60adfb2ddb..98cd8c7cc8 100644 --- a/app/api/agent/new/route.ts +++ b/app/api/agent/new/route.ts @@ -65,7 +65,7 @@ export async function POST(req: Request) { // that share a key onto one session. Date.now() (ms resolution) collides for // requests in the same millisecond, merging two new sessions into one. const tempKey = `__new__${randomUUID()}`; - const { session, realSessionId } = await startRpcSession(tempKey, "", cwd, { + const { session } = await startRpcSession(tempKey, "", cwd, { ...(toolNames ? { toolNames } : {}), ...(provider && modelId ? { initialModel: { provider, modelId } } : {}), ...(explicitThinkingLevel ? { thinkingLevel: explicitThinkingLevel } : {}), @@ -86,7 +86,8 @@ export async function POST(req: Request) { if (promptCommand.type === "ensure_session") { return NextResponse.json({ success: true, - sessionId: realSessionId, + sessionId: session.sessionId, + runtimeId: session.runtimeId, data: null, model: state.model ? { provider: state.model.provider, modelId: state.model.id } @@ -101,7 +102,8 @@ export async function POST(req: Request) { return NextResponse.json({ success: true, - sessionId: realSessionId, + sessionId: session.sessionId, + runtimeId: session.runtimeId, data: result, model: state.model ? { provider: state.model.provider, modelId: state.model.id } diff --git a/app/api/agent/running/route.ts b/app/api/agent/running/route.ts index ecde5b7b64..4cab9dd7ad 100644 --- a/app/api/agent/running/route.ts +++ b/app/api/agent/running/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; -import { getRunningRpcSessionIds } from "@/lib/rpc-manager"; +import { getRpcSessionActivitySnapshot } from "@/lib/rpc-manager"; export const dynamic = "force-dynamic"; // GET /api/agent/running - Lightweight snapshot for visible-tab polling. export async function GET() { return NextResponse.json( - { runningSessionIds: getRunningRpcSessionIds() }, + getRpcSessionActivitySnapshot(), { headers: { "Cache-Control": "no-store" } }, ); } diff --git a/app/api/sessions/[id]/auto-name/route.ts b/app/api/sessions/[id]/auto-name/route.ts index 70fb23dbbb..9ad85182b1 100644 --- a/app/api/sessions/[id]/auto-name/route.ts +++ b/app/api/sessions/[id]/auto-name/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { generateSessionTitle } from "@/lib/session-title"; -import { getRpcSession, startRpcSession } from "@/lib/rpc-manager"; +import { getRpcSession, startRpcSession, getFinalRpcSessionId } from "@/lib/rpc-manager"; import { invalidateSessionListCache, resolveSessionPath } from "@/lib/session-reader"; export async function POST( @@ -10,16 +10,16 @@ export async function POST( const { id } = await params; try { - const filePath = await resolveSessionPath(id); - if (!filePath) { - return NextResponse.json({ error: "Session not found" }, { status: 404 }); + let session = getRpcSession(id); + if (!session?.isAlive()) { + const canonicalId = id.startsWith("runtime:") ? getFinalRpcSessionId(id) : id; + const filePath = canonicalId ? await resolveSessionPath(canonicalId) : undefined; + if (!canonicalId || !filePath) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + ({ session } = await startRpcSession(canonicalId, filePath, undefined)); } - const existing = getRpcSession(id); - const { session } = existing?.isAlive() - ? { session: existing } - : await startRpcSession(id, filePath, undefined); - // globalThis keeps wrappers alive across dev hot reloads; older instances // may predate waitUntilReady(), but those have already completed startup. await session.waitUntilReady?.(); @@ -40,7 +40,7 @@ export async function POST( await session.inner.sessionManager.setSessionName(result.title, "auto"); invalidateSessionListCache(); - return NextResponse.json({ title: result.title }); + return NextResponse.json({ title: result.title, sessionId: session.sessionId, runtimeId: session.runtimeId }); } catch (error) { return NextResponse.json( { error: error instanceof Error ? error.message : String(error) }, diff --git a/app/api/sessions/[id]/context/route.ts b/app/api/sessions/[id]/context/route.ts index fe2692e3a7..b2c452d2c6 100644 --- a/app/api/sessions/[id]/context/route.ts +++ b/app/api/sessions/[id]/context/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { SessionManager } from "@oh-my-pi/pi-coding-agent"; import { resolveSessionPath, buildSessionContext, getHistoricalContextUsage } from "@/lib/session-reader"; -import { getRpcSession } from "@/lib/rpc-manager"; +import { getRpcSession, getFinalRpcSessionId } from "@/lib/rpc-manager"; export async function GET( req: Request, @@ -14,9 +14,11 @@ export async function GET( const deferToolResultImages = url.searchParams.has("deferMedia"); try { - const rpc = getRpcSession(id); + const directRpc = getRpcSession(id); + const canonicalId = directRpc?.sessionId ?? getFinalRpcSessionId(id) ?? id; + const rpc = directRpc ?? getRpcSession(canonicalId); const liveRpc = rpc?.isAlive() ? rpc : undefined; - const filePath = liveRpc ? null : await resolveSessionPath(id); + const filePath = liveRpc ? null : await resolveSessionPath(canonicalId); if (!liveRpc && !filePath) { return NextResponse.json({ error: "Session not found" }, { status: 404 }); } @@ -30,6 +32,8 @@ export async function GET( const contextUsage = await getHistoricalContextUsage(entries, leafId); return NextResponse.json({ + sessionId: liveRpc?.sessionId ?? sm.getSessionId(), + runtimeId: liveRpc?.runtimeId ?? null, context, ...(contextUsage ? { contextUsage } : {}), }); diff --git a/app/api/sessions/[id]/export/route.ts b/app/api/sessions/[id]/export/route.ts index 4680bc6dd1..901ab460fc 100644 --- a/app/api/sessions/[id]/export/route.ts +++ b/app/api/sessions/[id]/export/route.ts @@ -7,6 +7,7 @@ import { promisify } from "util"; import { fileURLToPath, pathToFileURL } from "url"; import { NextResponse } from "next/server"; import { isApiRequestAllowed } from "@/lib/request-security"; +import { getRpcSession, getFinalRpcSessionId } from "@/lib/rpc-manager"; import { resolveSessionPath } from "@/lib/session-reader"; const execFileAsync = promisify(execFile); @@ -114,7 +115,9 @@ export async function GET( const inline = new URL(req.url).searchParams.get("inline") === "1"; try { - const filePath = await resolveSessionPath(id); + const canonicalId = getRpcSession(id)?.sessionId ?? getFinalRpcSessionId(id) + ?? (id.startsWith("runtime:") ? undefined : id); + const filePath = canonicalId ? await resolveSessionPath(canonicalId) : null; if (!filePath) { return NextResponse.json({ error: "Session not found" }, { status: 404 }); } diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts index f8e3e4dfb7..9476b1e03b 100644 --- a/app/api/sessions/[id]/route.ts +++ b/app/api/sessions/[id]/route.ts @@ -44,6 +44,10 @@ export async function GET( const deferThinking = searchParams.has("deferThinking"); const deferToolResultImages = searchParams.has("deferMedia"); const context = buildSessionContext(entries, leafId, { deferThinking, deferToolResultImages }); + // A runtime fallback can differ from the last persisted model selector. + if (liveRpc?.inner.model) { + context.model = { provider: liveRpc.inner.model.provider, modelId: liveRpc.inner.model.id }; + } const contextUsage = await getHistoricalContextUsage(entries, leafId); const totalActiveMs = computeSessionTotalActiveMs(entries); const header = sm.getHeader(); @@ -72,7 +76,8 @@ export async function GET( } : null; return NextResponse.json({ - sessionId: id, + sessionId: liveRpc?.sessionId ?? sm.getSessionId(), + runtimeId: liveRpc?.runtimeId, filePath, info, leafId, diff --git a/app/api/sessions/export-route.test.mjs b/app/api/sessions/export-route.test.mjs new file mode 100644 index 0000000000..3e858ce77b --- /dev/null +++ b/app/api/sessions/export-route.test.mjs @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { + alias: { "@": process.cwd() }, + interopDefault: true, + moduleCache: false, +}); +const { GET: getExport } = await jiti.import("./[id]/export/route.ts"); + +function exportFixture(t) { + const stateKeys = [ + "__ompSessions", "__ompFinalSessionIds", "__ompSessionPathCache", + "__ompPathToSessionIdCache", "__ompSessionListCache", + ]; + const previousState = stateKeys.map((key) => [key, globalThis[key]]); + const dir = mkdtempSync(join(tmpdir(), "omp-web-export-test-")); + const a = "export-history-a"; + const b = "export-current-b"; + const runtimeId = "runtime:export-r"; + const timestamp = "2026-08-12T01:02:03.000Z"; + const paths = new Map(); + for (const id of [a, b]) { + const path = join(dir, `${id}.jsonl`); + paths.set(id, path); + writeFileSync(path, [ + { type: "session", version: 3, id, timestamp, cwd: dir }, + { + type: "message", id: "u1", parentId: null, timestamp, + message: { role: "user", content: `transcript ${id}` }, + }, + ].map((entry) => JSON.stringify(entry)).join("\n") + "\n"); + } + globalThis.__ompSessions = new Map([[a, { + sessionId: b, + runtimeId, + sessionFile: paths.get(b), + isAlive: () => true, + syncSessionIdentity: () => {}, + }]]); + globalThis.__ompFinalSessionIds = new Map(); + globalThis.__ompSessionPathCache = new Map(paths); + globalThis.__ompPathToSessionIdCache = new Map([...paths].map(([id, path]) => [path, id])); + // Missing files must not scan the user's session directory. + globalThis.__ompSessionListCache = { data: [], ts: Date.now() }; + t.after(() => { + for (const [key, value] of previousState) globalThis[key] = value; + rmSync(dir, { recursive: true, force: true }); + }); + return { a, b, runtimeId, paths }; +} + +function requestExport(id, headers = { host: "localhost" }) { + return getExport( + new Request(`http://localhost/api/sessions/${encodeURIComponent(id)}/export?inline=1`, { headers }), + { params: Promise.resolve({ id }) }, + ); +} + +async function assertTranscript(response, id) { + assert.equal(response.status, 200, await response.clone().text()); + assert.equal(response.headers.get("Content-Type"), "text/html; charset=utf-8"); + assert.match(response.headers.get("Content-Disposition"), /^inline;/); + const html = await response.text(); + const encoded = html.match(/