Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9917e1a
fix: follow runtime session identity after model fallback
yiliang114 Oct 15, 2026
c4da3e3
fix: retain live session aliases across repeated fallbacks
yiliang114 Oct 15, 2026
2fe183f
fix: migrate live session keys without redirecting history
yiliang114 Oct 15, 2026
87c63e4
fix: preserve live session continuity across missed identity events
yiliang114 Oct 15, 2026
c6a3763
fix: track sidebar completion by runtime identity
yiliang114 Oct 15, 2026
97a71f2
fix: preserve selection and drafts during runtime relocation
yiliang114 Oct 15, 2026
1af9df7
fix: retain final session identity and follow live runs in place
yiliang114 Oct 15, 2026
da4e305
fix: carry runtime identity through settings and completion
yiliang114 Oct 15, 2026
8b74a8a
fix: retain per-tab session continuation through refresh
yiliang114 Oct 15, 2026
b93b11b
fix: clear confirmed expired continuations and allow reopening
yiliang114 Oct 15, 2026
46b746f
fix: synchronize expired runtime handles across session callers
yiliang114 Oct 15, 2026
6840a4d
fix: upgrade retained session wrappers after hot reload
yiliang114 Oct 15, 2026
918c437
fix: recover canonical state and route session actions by runtime
yiliang114 Oct 15, 2026
c1e823f
fix: restore canonical runtimes before assuming idle or missing history
yiliang114 Oct 15, 2026
5c77c1c
fix: unify runtime identity and async session ownership
yiliang114 Oct 15, 2026
722018e
fix: avoid duplicate completion after runtime mapping loss
yiliang114 Oct 17, 2026
2085d4a
fix: guard runtime state ownership and history export
yiliang114 Oct 17, 2026
79d995d
fix: reconnect expired runtimes and guard context ownership
yiliang114 Oct 18, 2026
3628617
fix: preserve pending streams during runtime discovery
yiliang114 Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/api/agent/[id]/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 18 additions & 5 deletions app/api/agent/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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),
Expand All @@ -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" });
Expand Down
8 changes: 5 additions & 3 deletions app/api/agent/new/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand All @@ -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 }
Expand All @@ -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 }
Expand Down
4 changes: 2 additions & 2 deletions app/api/agent/running/route.ts
Original file line number Diff line number Diff line change
@@ -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" } },
);
}
20 changes: 10 additions & 10 deletions app/api/sessions/[id]/auto-name/route.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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?.();
Expand All @@ -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) },
Expand Down
10 changes: 7 additions & 3 deletions app/api/sessions/[id]/context/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 });
}
Expand All @@ -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 } : {}),
});
Expand Down
5 changes: 4 additions & 1 deletion app/api/sessions/[id]/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
}
Expand Down
7 changes: 6 additions & 1 deletion app/api/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
124 changes: 124 additions & 0 deletions app/api/sessions/export-route.test.mjs
Original file line number Diff line number Diff line change
@@ -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(/<script id="session-data" type="application\/json">([^<]+)<\/script>/)?.[1];
assert.ok(encoded, "export contains the SDK's embedded transcript");
const data = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
assert.equal(data.header.id, id);
assert.deepEqual(data.entries.map((entry) => entry.message?.content), [`transcript ${id}`]);
}

test("export follows a live runtime to its current transcript after a missed identity update", async (t) => {
const { runtimeId, b } = exportFixture(t);
await assertTranscript(await requestExport(runtimeId), b);
});

test("export follows a destroyed runtime's final identity", async (t) => {
const { runtimeId, b } = exportFixture(t);
globalThis.__ompSessions.clear();
globalThis.__ompFinalSessionIds.set(runtimeId, b);
await assertTranscript(await requestExport(runtimeId), b);
});

test("exporting a historical ID still returns its original transcript", async (t) => {
const { a } = exportFixture(t);
await assertTranscript(await requestExport(a), a);
});

test("unknown runtime handles cannot be treated as historical file IDs", async (t) => {
const { a, paths } = exportFixture(t);
globalThis.__ompSessionPathCache.set("runtime:unknown", paths.get(a));
const response = await requestExport("runtime:unknown");
assert.equal(response.status, 404);
assert.deepEqual(await response.json(), { error: "Session not found" });
});

for (const alive of [true, false]) {
test(`export never falls back to the old file when the ${alive ? "live" : "destroyed"} runtime's current file is missing`, async (t) => {
const { runtimeId, a, b, paths } = exportFixture(t);
if (!alive) {
globalThis.__ompSessions.clear();
globalThis.__ompFinalSessionIds.set(runtimeId, b);
}
globalThis.__ompSessionPathCache.set(runtimeId, paths.get(a));
rmSync(paths.get(b));
const response = await requestExport(runtimeId);
assert.equal(response.status, 404);
assert.deepEqual(await response.json(), { error: "Session not found" });
});
}

test("export rejects untrusted requests before accessing the runtime registry", async (t) => {
const { runtimeId } = exportFixture(t);
globalThis.__ompSessions.values().next().value.syncSessionIdentity = () => {
assert.fail("untrusted request must not access runtimes");
};
const response = await requestExport(runtimeId, { host: "untrusted.invalid" });
assert.equal(response.status, 403);
assert.deepEqual(await response.json(), { error: "Untrusted API request" });
});
4 changes: 2 additions & 2 deletions app/api/sessions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
listAllSessions,
mergeSessionLists,
} from "@/lib/session-reader";
import { getRpcSessionInfos, getRunningRpcSessionIds } from "@/lib/rpc-manager";
import { getRpcSessionInfos, getRpcSessionActivitySnapshot } from "@/lib/rpc-manager";
import { readArchivedIds } from "@/lib/session-archive";

export const dynamic = "force-dynamic";
Expand All @@ -21,7 +21,7 @@ export async function GET(req: Request) {
const sessions = mergeSessionLists(persistedSessions, runtimeSessions)
.filter((s) => (showArchived ? archived.has(s.id) : !archived.has(s.id)));
return NextResponse.json(
{ sessions, runningSessionIds: getRunningRpcSessionIds() },
{ sessions, ...getRpcSessionActivitySnapshot() },
{ headers: { "Cache-Control": "no-store" } },
);
} catch (error) {
Expand Down
Loading