fix(remote): close the session-id migration gaps across serve and desktop

Legacy-to-identity migration left the desktop tree, the serve listing, and
the CLI pickers disagreeing about which sessions exist. The serve rows now
carry preview/turns/metadata readiness, route identity sessions by
session-id:, and fold engine mirrors of in-flight transcripts; the desktop
prefers the session id over the legacy path everywhere it routes; frame tags
keep the session id across controller swaps, migrations re-point the tag at
the new identity, and the serve boot seeds it for exclusive sessions.
This commit is contained in:
XTLine committed 2026-09-15 21:39:02 +08:00
1 parent faaaa1b790
commit 9ddb8d57a9
49 files changed
+1126 -130

No files matched your search

@@ -448,6 +448,6 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// measure 2488853 B. Keep the next tenth; gzip, CSS, and chunk limits unchanged.
// Combined model-settings and read-evidence integration measures 2492541 B,
// adding 3688 B (0.148%) over the base. Retain the next one-decimal ceiling.
const rawInitialBudgetKiB = 2_434.2;
const rawInitialBudgetKiB = 2_434.4;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
+2
View File
@@ -55,6 +55,7 @@ export function AppRuntime() {
const activeSessionIdentity = sessionIdentityKey({
tabId: activeTabId,
sessionPath: activeTab?.sessionPath ?? state.meta?.sessionPath,
sessionId: activeTab?.sessionId ?? state.meta?.sessionId,
sessionGeneration: activeTab?.sessionGeneration ?? state.meta?.sessionGeneration ?? state.sessionGen,
scope: activeTab?.scope,
workspaceRoot: activeTab?.workspaceRoot ?? state.meta?.cwd,
@@ -70,6 +71,7 @@ export function AppRuntime() {
...tabMetas.filter(tab => tab.id !== activeTabId).map(tab => ({
tabId: tab.id,
sessionKey: sessionIdentityKey({ tabId: tab.id, sessionPath: tab.sessionPath,
sessionId: tab.sessionId,
sessionGeneration: tab.sessionGeneration, scope: tab.scope, workspaceRoot: tab.workspaceRoot, topicId: tab.topicId }),
})),
],
@@ -48,7 +48,8 @@ ok(
ok(
/remoteSession: \{ hostId: node\.remote!\.hostId, workspace: node\.remote!\.workspace, name: row\.name, path: row\.path, sessionId: row\.sessionId, title: row\.title \}/.test(remoteSource) &&
/openRemoteSessionNode\(remote, openRemoteProject\)/.test(source) &&
/sessionPath: remote\.path, sessionId: remote\.sessionId, sessionTitle: remote\.title/.test(remoteSource),
/sessionPath: remote\.path, sessionId: remote\.sessionId, sessionTitle: remote\.title/.test(remoteSource) &&
/remoteSessionIdentity\(row\)/.test(remoteSource),
"session rows open the matching in-app remote session",
);
ok(
@@ -107,6 +108,7 @@ ok(
);
ok(
/const remote = index\.get\(topicId\);[\s\S]*?if \(!remote\) return false;[\s\S]*?await action\(remote\);/.test(remoteSource) &&
/remote\.name \|\| remote\.sessionId/.test(remoteSource) &&
!/if \(remote\.name\) await action\(remote\)/.test(remoteSource),
"synthetic blank remote sessions still invoke rename, pin, and delete mutations",
);
@@ -8,6 +8,22 @@ import { sessionIdentityKey } from "../app-runtime/sessionTarget";
const dom = new JSDOM("<div id='root'></div>");
Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true });
assert.notEqual(
sessionIdentityKey({ tabId: "remote", sessionId: "session-a" }),
sessionIdentityKey({ tabId: "remote", sessionId: "session-b" }),
"canonical remote session IDs must fence reused remote tabs separately",
);
const legacyLocalKey = sessionIdentityKey({ tabId: "local", sessionPath: "/sessions/local.jsonl", sessionGeneration: 3 });
assert.equal(
sessionIdentityKey({ tabId: "local", sessionPath: "/sessions/local.jsonl", sessionGeneration: 3, sessionId: "" }),
legacyLocalKey,
"legacy local sessions keep their path+generation identity when no ID exists",
);
assert.notEqual(
sessionIdentityKey({ tabId: "local", sessionPath: "", sessionGeneration: 3, sessionId: "local-v3" }),
legacyLocalKey,
"canonical local sessions use their immutable ID only when the v3 identity is present",
);
const resource = (tabId: string, generation = 1): SessionResource => ({ tabId,
sessionKey: sessionIdentityKey({ tabId, sessionPath: `/${tabId}`, sessionGeneration: generation }) });
const a = resource("A"), b = resource("B");
@@ -19,6 +19,7 @@ export function projectControllerProfiles(tabs: readonly TabMeta[], profiles: Re
return [{ target: active.target, profile: runtimeProfile(active.profile), remote: active.remote },
...tabs.filter(tab => tab.id !== active.target.tabId).map(tab => ({
target: { tabId: tab.id, sessionKey: sessionIdentityKey({ tabId: tab.id, sessionPath: tab.sessionPath,
sessionId: tab.sessionId,
sessionGeneration: tab.sessionGeneration, scope: tab.scope, workspaceRoot: tab.workspaceRoot, topicId: tab.topicId }) },
profile: runtimeProfile(profiles[tab.id] ?? composerProfileFromTab(tab)), remote: Boolean(tab.remote),
}))];
@@ -1,6 +1,7 @@
export type SessionIdentityInput = {
tabId?: string;
sessionPath?: string;
sessionId?: string;
sessionGeneration?: number;
scope?: string;
workspaceRoot?: string;
@@ -9,6 +10,10 @@ export type SessionIdentityInput = {
/** Runtime session identity; intentionally distinct from draft/workspace keys. */
export function sessionIdentityKey(input: SessionIdentityInput): string {
const sessionId = (input.sessionId ?? "").trim();
if (sessionId) {
return ["session-id", input.tabId ?? "", sessionId].join("\u0000");
}
const sessionPath = (input.sessionPath ?? "").trim();
if (sessionPath) {
return ["session", sessionPath, String(input.sessionGeneration ?? 0)].join("\u0000");
@@ -53,7 +53,10 @@ export function useComposerProfileProjection(input: ComposerProfileProjectionInp
const goal = composerProfile.goal;
const collaborationMode = displayedComposerProfileCollaborationMode(composerProfile);
const toolApprovalMode = composerProfile.toolApprovalMode;
const remoteComposerProfileReady = useRemoteComposerProfileSync({ activeTabId, remote,
const remoteSessionRoute = activeTab?.remote
? (activeTab.sessionId ? `session-id:${activeTab.sessionId}` : activeTab.sessionPath ?? "")
: "";
const remoteComposerProfileReady = useRemoteComposerProfileSync({ activeTabId, sessionRoute: remoteSessionRoute, remote,
remoteProfile: remoteSession.composerProfile, collaborationMode, toolApprovalMode, goal,
qualityFloor: composerProfile.qualityFloor, pending: composerProfile.pending, setProfiles: setProfilesByTab });
@@ -75,7 +75,10 @@ export function buildSidebarRegionProps(input: {
projectTree: {
activeScope: input.projectTree.activeTab?.scope, activeWorkspaceRoot: input.projectTree.activeTab?.workspaceRoot,
activeTopicId: input.projectTree.activeTab?.topicId, activeSessionPath: input.projectTree.activeTab?.sessionPath,
activeRemote: input.projectTree.activeTab?.remote, imTopicSources: input.projectTree.imTopicSources, onOpenTopic: commands.onOpenTopic,
activeRemote: input.projectTree.activeTab?.remote
? { ...input.projectTree.activeTab.remote, sessionId: input.projectTree.activeTab.sessionId }
: undefined,
imTopicSources: input.projectTree.imTopicSources, onOpenTopic: commands.onOpenTopic,
onCreateTopic: topics.onCreateTopic, onCreateIsolatedWorktree: topics.onCreateIsolatedWorktree,
onTopicsChanged: topics.refreshProjectsAndTabs, onRenameTopic: topics.renameTopic, refreshSignal: input.projectTree.refreshSignal,
onAddProject: topics.onAddProject,
@@ -26,7 +26,7 @@ import { summarizeProjectTreeSessions } from "../lib/projectTreeDiagnostics";
import { GLOBAL_PROJECT_ORDER_KEY, ProjectTreeFolderActivity, ProjectTreeGroupRows, applyProjectOrder, projectTreeProjectRoots, reorderedProjectRoots, useProjectTreeOrganization, type ProjectDropPosition } from "./ProjectTreeOrganization";
import { ProjectTreeSessionArchiveMenu } from "./ProjectTreeSessionArchiveMenu";
import { ProjectTreeHeaderAddControl, ProjectTreeRemoteAction, projectTreeHeaderAddItems } from "./ProjectTreeAddControls";
import { activeRemoteProjectAncestorKeys, buildRemoteProjectMenuItems, useRemoteRuntimeTree, openRemoteSessionNode, remoteProjectKey, remoteServeBadgeState, renameRemoteProjectTitle, RemoteProjectEmptyState, useRemoteProjectGroups, useRemoteSessionActions } from "./ProjectTreeRemoteGroups";
import { activeRemoteProjectAncestorKeys, buildRemoteProjectMenuItems, useRemoteRuntimeTree, openRemoteSessionNode, remoteProjectKey, remoteServeBadgeState, renameRemoteProjectTitle, RemoteProjectEmptyState, remoteSessionActionIdentity, useRemoteProjectGroups, useRemoteSessionActions } from "./ProjectTreeRemoteGroups";
import type { ProjectTreeProps } from "./ProjectTreeProps";
function projectNodeKey(node: ProjectNode, depth: number): string {
@@ -533,12 +533,12 @@ export function ProjectTree({
useEffect(() => {
const markActive = (nodes: ProjectNode[]) => {
for (const node of nodes) {
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) markNodeRead(node);
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, activeRemote)) markNodeRead(node);
markActive(asArray(node.children));
}
};
markActive(treeWithRemoteSessions);
}, [activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, markNodeRead, treeWithRemoteSessions]);
}, [activeRemote, activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, markNodeRead, treeWithRemoteSessions]);
useEffect(() => {
try {
@@ -762,7 +762,7 @@ export function ProjectTree({
setEditingTopic(null);
if (!title) return;
try {
if (await remoteSessionActions.mutate(topicId, (remote) => app.RenameRemoteProjectSession(remote.hostId, remote.workspace, remote.name, title))) return;
if (await remoteSessionActions.mutate(topicId, (remote) => app.RenameRemoteProjectSession(remote.hostId, remote.workspace, remoteSessionActionIdentity(remote), title))) return;
if (onRenameTopic) await onRenameTopic(topicId, title);
else await app.RenameTopic(topicId, title);
// Paint the new label immediately; the catalog event round-trip can lag.
@@ -804,7 +804,7 @@ export function ProjectTree({
setMenuNodeKey(null);
setMenuPoint(null);
try {
if (await remoteSessionActions.mutate(topicId, (remote) => app.SetRemoteSessionPinned(remote.hostId, remote.workspace, remote.name, pinned))) return;
if (await remoteSessionActions.mutate(topicId, (remote) => app.SetRemoteSessionPinned(remote.hostId, remote.workspace, remoteSessionActionIdentity(remote), pinned))) return;
await app.SetTopicPinned(topicId, pinned);
await refresh();
await onTopicsChanged?.();
@@ -986,7 +986,7 @@ export function ProjectTree({
queryActive: query.trim().length > 0,
timeFilterActive: timeFilter !== "all",
projectNodeKey,
isActive: (node) => topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath),
isActive: (node) => topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, activeRemote),
isUnread: (node) => projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, readBaselineAt),
});
return {
@@ -1010,7 +1010,7 @@ export function ProjectTree({
treeRevision: latestRevisionRef.current,
organizationRevision,
};
}, [activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, catalogStatus, expanded, organizationRevision, query, readActivity, readBaselineAt, timeFilter, tree, variant, visibleTree]);
}, [activeRemote, activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, catalogStatus, expanded, organizationRevision, query, readActivity, readBaselineAt, timeFilter, tree, variant, visibleTree]);
useProjectTreeFrontendDiagnostics(projectTreeDiagnosticSnapshot);
@@ -1030,7 +1030,7 @@ export function ProjectTree({
const scope = openRequest?.scope ?? "project";
const scopeClass = scope === "global" ? " project-tree__topic--global" : " project-tree__topic--project";
const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, activeRemote);
const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
const activityAt = node.lastActivityAt || node.createdAt || 0;
// Every variant is a single-line row with the activity time on the right;
@@ -1107,7 +1107,8 @@ export function ProjectTree({
key: "trash",
icon: <Archive className={topicTrashing ? "project-tree__archive-spinner" : undefined} size={13} />,
label: confirmArchiveTarget === archiveTargetKey ? t("history.confirmMoveToTrash") : t("history.moveToTrash"),
disabled: archiveBlocked || topicTrashing,
disabled: archiveBlocked || topicTrashing
|| Boolean(node.remoteSession && (node.remoteSession.current || !remoteSessionActionIdentity(node.remoteSession))),
danger: true,
onSelect: () => {
if (confirmArchiveTarget === archiveTargetKey) void trashTopicAny(topicId);
@@ -16,6 +16,36 @@ export function remoteProjectKey(ref: RemoteTabRefView): string {
return `${ref.hostId}\u0000${ref.workspace}`;
}
// Canonical remote sessions may not have a legacy transcript path. Use the
// immutable session ID first so React keys, topic IDs, and action lookups do
// not collapse a canonical row into a path-only or blank row.
export function remoteSessionIdentity(row: Pick<RemoteSessionView, "sessionId" | "path" | "name">): string {
return row.sessionId?.trim() || row.path?.trim() || row.name?.trim() || "new";
}
// Mutations still use the historical bridge argument named `name`, but that
// argument is an identity token, not the visible title. Canonical rows must
// use sessionId; legacy rows retain their stable basename.
export function remoteSessionActionIdentity(remote: Pick<RemoteSessionView, "sessionId" | "name" | "path">): string {
// Keep the legacy name fallback for old Serve rows, but never fall back to
// path: the synthetic current row may have a path while still being a
// blank, non-catalogued session.
const legacyIdentity = remote.name || remote.sessionId || "";
const explicit = remote.sessionId?.trim() || legacyIdentity.trim();
if (explicit) return explicit;
const route = remote.path?.trim() || "";
return route.startsWith("session-id:") ? route.slice("session-id:".length).trim() : "";
}
export function remoteSessionLabel(row: Pick<RemoteSessionView, "sessionId" | "name" | "title">, t: Translator): string {
const title = row.title?.trim();
if (title) return title;
// Canonical rows use `name` as an identity compatibility field, which is
// the opaque session ID. Never expose that ID as the visible topic label.
if (row.sessionId?.trim()) return t("projectTree.newTopic");
return row.name?.trim() || t("projectTree.newTopic");
}
export function useRemoteRuntimeTree(tree: ProjectNode[], sessions: Record<string, RemoteSessionView[]>, t: Translator) {
const runtime = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getSnapshot);
const failed = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getFailed);
@@ -50,26 +80,28 @@ export function mergeRemoteSessionsIntoTree(
if (!node.remote) return node;
const rows = sessions[remoteProjectKey(node.remote)] ?? [];
const remoteChildren = rows.map((row): ProjectNode => {
const identity = remoteSessionIdentity(row);
const session = runtime?.sessions.find(session => session.hostId === node.remote!.hostId && session.workspaceRoot === node.remote!.workspace && (
row.sessionId ? session.sessionId === row.sessionId : session.sessionPath === row.path
));
const state = selectRuntime(session, failed);
const status = state.unknown ? "unknown" : state.known && state.kind !== "idle" && state.kind !== "legacy" ? state.kind : undefined;
return ({
key: `remote-session-${node.remote!.hostId}-${node.remote!.workspace}-${row.name}`,
key: `remote-session-${node.remote!.hostId}-${node.remote!.workspace}-${identity}`,
kind: "topic",
label: row.title || row.name || t("projectTree.newTopic"),
label: remoteSessionLabel(row, t),
root: node.remote!.workspace,
topicId: `${node.remote!.hostId}\u0000${node.remote!.workspace}\u0000${row.name}`,
topicId: `${node.remote!.hostId}\u0000${node.remote!.workspace}\u0000${identity}`,
sessionPath: row.path,
turns: row.turns,
running: state.known ? state.unknown ? false : Boolean(state.running || session!.state.pendingPrompt || session!.state.backgroundJobs) : row.running,
status: status as ProjectNode["status"],
lastActivityAt: row.lastActivityAt,
pinned: row.pinned,
remoteSession: { hostId: node.remote!.hostId, workspace: node.remote!.workspace, name: row.name, path: row.path, sessionId: row.sessionId, title: row.title },
remoteSession: { hostId: node.remote!.hostId, workspace: node.remote!.workspace, name: row.name, path: row.path, sessionId: row.sessionId, title: row.title, current: row.current },
children: [],
}); });
});
});
return { ...node, children: [...remoteChildren, ...(node.children ?? [])] };
});
}
@@ -80,17 +112,21 @@ export function useRemoteSessionActions(
reportError: (error: unknown) => void,
) {
const index = useMemo(() => {
const next = new Map<string, { hostId: string; workspace: string; name: string }>();
const next = new Map<string, { hostId: string; workspace: string; name: string; path?: string; sessionId?: string }>();
for (const [groupKey, rows] of Object.entries(sessions)) {
const [hostId, workspace] = groupKey.split("\u0000");
for (const row of rows) next.set(`${hostId}\u0000${workspace}\u0000${row.name}`, { hostId, workspace, name: row.name });
for (const row of rows) {
next.set(`${hostId}\u0000${workspace}\u0000${remoteSessionIdentity(row)}`, {
hostId, workspace, name: row.name, path: row.path, sessionId: row.sessionId,
});
}
}
return next;
}, [sessions]);
const resolve = useCallback((topicId: string) => index.get(topicId), [index]);
const mutate = useCallback(async (
topicId: string,
action: (remote: { hostId: string; workspace: string; name: string }) => Promise<unknown>,
action: (remote: { hostId: string; workspace: string; name: string; path?: string; sessionId?: string }) => Promise<unknown>,
) => {
const remote = index.get(topicId);
if (!remote) return false;
@@ -104,7 +140,14 @@ export function useRemoteSessionActions(
}, [index, refresh]);
const remove = useCallback(async (topicId: string, local: () => Promise<unknown>) => {
try {
if (await mutate(topicId, (remote) => app.DeleteRemoteProjectSession(remote.hostId, remote.workspace, remote.name))) return;
if (await mutate(topicId, (remote) => {
const identity = remoteSessionActionIdentity(remote);
// A blank, not-yet-catalogued session has no identity the Serve can
// delete; the deterministic "name required" rejection would only
// surface as a broken trash action.
if (!identity) return Promise.resolve();
return app.DeleteRemoteProjectSession(remote.hostId, remote.workspace, identity);
})) return;
await local();
} catch (error) {
reportError(error);
@@ -35,7 +35,8 @@ export function startControllerEventRecovery(ports: ControllerRecoveryPorts, sub
for (const tab of tabs) {
if (!scope.bindings.has(tab.id)) continue;
const meta = ports.meta(tab.id);
const changedSession = meta?.sessionPath !== tab.sessionPath || meta?.sessionGeneration !== tab.sessionGeneration;
const changedSession = meta?.sessionId !== tab.sessionId
|| meta?.sessionPath !== tab.sessionPath || meta?.sessionGeneration !== tab.sessionGeneration;
ports.prepare(tab);
if (changedSession || hydrating.has(tab.id)) {
hydrating.add(tab.id);
@@ -97,7 +97,7 @@ export function createMockRemoteProjects(tabs: MockRemoteTabCatalog): {
return (sessions[key(hostId, workspace)] ?? []).map((row) => ({ ...row }));
},
async SetRemoteSessionPinned(hostId, workspace, name, pinned) {
const row = (sessions[key(hostId, workspace)] ?? []).find((item) => item.name === name);
const row = (sessions[key(hostId, workspace)] ?? []).find((item) => (item.sessionId || item.name) === name);
if (row) row.pinned = pinned;
},
async SetRemoteProjectTitle(hostId, workspace, title) {
@@ -105,11 +105,11 @@ export function createMockRemoteProjects(tabs: MockRemoteTabCatalog): {
if (project) project.title = title.trim() || undefined;
},
async RenameRemoteProjectSession(hostId, workspace, name, title) {
const row = (sessions[key(hostId, workspace)] ?? []).find((item) => item.name === name);
const row = (sessions[key(hostId, workspace)] ?? []).find((item) => (item.sessionId || item.name) === name);
if (row) row.title = title.trim();
},
async DeleteRemoteProjectSession(hostId, workspace, name) {
sessions[key(hostId, workspace)] = (sessions[key(hostId, workspace)] ?? []).filter((item) => item.name !== name);
sessions[key(hostId, workspace)] = (sessions[key(hostId, workspace)] ?? []).filter((item) => (item.sessionId || item.name) !== name);
},
async CloseRemoteTab(tabId) { tabs.remove(tabId); },
async SubmitRemoteTab(tabId, text) {
+27 -2
View File
@@ -286,7 +286,29 @@ function topicMatchesActiveIdentity(node: ProjectNode, activeScope?: string, act
return activeScope === "project" && activeTopicId === node.topicId && activeWorkspaceRoot === node.root;
}
export function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceRoot?: string, activeTopicId?: string, activeSessionPath?: string): boolean {
type ActiveRemoteSessionIdentity = {
hostId: string;
workspace: string;
sessionId?: string;
};
function remoteTopicMatchesActiveSession(node: ProjectNode, activeRemote?: ActiveRemoteSessionIdentity): boolean {
const remote = node.remoteSession;
const active = activeRemote;
if (!remote || !active || remote.hostId !== active.hostId || remote.workspace !== active.workspace) return false;
const sessionID = active.sessionId?.trim();
if (!sessionID) return false;
return remote.sessionId?.trim() === sessionID || (!remote.sessionId?.trim() && remote.name.trim() === sessionID);
}
export function topicIsActive(
node: ProjectNode,
activeScope?: string,
activeWorkspaceRoot?: string,
activeTopicId?: string,
activeSessionPath?: string,
activeRemote?: ActiveRemoteSessionIdentity,
): boolean {
if (isRuntimeSessionNode(node)) {
return Boolean(node.sessionPath && activeSessionPath && activeSessionPath === node.sessionPath);
}
@@ -296,7 +318,10 @@ export function topicIsActive(node: ProjectNode, activeScope?: string, activeWor
// same absolute session path. Their synthesized rows already carry a
// host-qualified topicId, so never let the generic path fallback mark a row
// from another host active.
if (node.remoteSession) return topicMatchesActiveIdentity(node, activeScope, activeWorkspaceRoot, activeTopicId);
if (node.remoteSession) {
if (remoteTopicMatchesActiveSession(node, activeRemote)) return true;
return topicMatchesActiveIdentity(node, activeScope, activeWorkspaceRoot, activeTopicId);
}
if (topicMatchesActiveIdentity(node, activeScope, activeWorkspaceRoot, activeTopicId)) return true;
return Boolean(node.sessionPath && activeSessionPath && activeSessionPath === node.sessionPath);
}
+3 -1
View File
@@ -25,6 +25,8 @@ export type RemoteServerState =
export interface RemoteTabRefView {
hostId: string;
workspace: string;
/** Stable identity of the active remote session, when known. */
sessionId?: string;
}
export interface RemoteTabMetaFields {
@@ -36,7 +38,7 @@ export interface RemoteTabMetaFields {
export interface RemoteProjectNodeFields {
remote?: RemoteTabRefView;
remoteSession?: { hostId: string; workspace: string; name: string; path?: string; sessionId?: string; title?: string };
remoteSession?: { hostId: string; workspace: string; name: string; path?: string; sessionId?: string; title?: string; current?: boolean };
}
export interface RemoteSessionMetaFields {
+2
View File
@@ -575,6 +575,7 @@ export interface TabMeta extends RemoteTabMetaFields {
topicId: string;
topicTitle: string;
sessionPath?: string;
sessionId?: string;
sessionRevision?: number;
sessionDigest?: string;
sessionGeneration?: number;
@@ -1041,6 +1042,7 @@ export interface Meta extends RemoteSessionMetaFields {
startupErr?: string;
eventChannel: string;
sessionPath?: string;
sessionId?: string;
sessionRevision?: number;
sessionDigest?: string;
sessionGeneration?: number;
@@ -1,4 +1,4 @@
import { useEffect, type Dispatch, type SetStateAction } from "react";
import { useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import { useCommittedCommand } from "./useCommittedCommand";
import { executeRemoteSend, executeComposerRuntime } from "../app-runtime/remoteComposerOwner";
import type { SessionResource, useSessionOperations } from "../app-runtime/useSessionOperations";
@@ -72,6 +72,7 @@ export function useRemoteComposerSend(
export function useRemoteComposerProfileSync(options: {
activeTabId?: string;
sessionRoute?: string;
remote: boolean;
remoteProfile: RemoteProfile;
collaborationMode: CollaborationMode;
@@ -81,11 +82,18 @@ export function useRemoteComposerProfileSync(options: {
pending: ComposerProfile["pending"];
setProfiles: Dispatch<SetStateAction<ComposerProfilesByTab>>;
}): boolean {
const { activeTabId, remote, remoteProfile, collaborationMode, toolApprovalMode, goal, qualityFloor, pending, setProfiles } = options;
const { activeTabId, sessionRoute, remote, remoteProfile, collaborationMode, toolApprovalMode, goal, qualityFloor, pending, setProfiles } = options;
const lastRouteRef = useRef<string>("");
useEffect(() => {
if (!activeTabId || !remote || !remoteProfile) return;
// One remote tab rotates through many sessions; a cached profile belongs
// to the session it was read from, so a route change adopts the backend
// snapshot instead of reconciling over the previous session's overrides.
const routeKey = `${activeTabId}|${sessionRoute ?? ""}`;
const routeChanged = lastRouteRef.current !== routeKey;
lastRouteRef.current = routeKey;
setProfiles((current) => {
const existing = current[activeTabId];
const existing = routeChanged ? undefined : current[activeTabId];
const backend: ComposerProfile = {
collaborationMode: remoteProfile.collaborationMode,
goalDraftMode: false,
@@ -97,7 +105,7 @@ export function useRemoteComposerProfileSync(options: {
const next = reconcileComposerProfile(existing, backend);
return existing === next ? current : { ...current, [activeTabId]: next };
});
}, [activeTabId, remote, remoteProfile, setProfiles]);
}, [activeTabId, sessionRoute, remote, remoteProfile, setProfiles]);
return !remote || Boolean(remoteProfile
&& (pending.collaborationMode || collaborationMode === remoteProfile.collaborationMode)
+6 -6
View File
@@ -102,22 +102,22 @@ export function useActiveRemoteSession(
) {
const t = useT();
const active = Boolean(activeTab?.remote);
const session = useRemoteSession(active && activeTab ? activeTab.id : undefined, activeTab?.remoteState, activeTab?.sessionPath);
const session = useRemoteSession(active && activeTab ? activeTab.id : undefined, activeTab?.remoteState, activeTab?.sessionPath, activeTab?.sessionId);
const composer = useRemoteComposer(session, showToast);
useEffect(() => {
if (!activeTab?.remote || !activeTab.id) return;
const legacyQuality = session.transcript.items.some(item => item.kind === "notice" && (item.code === "final_readiness" || item.variant === "delivery"));
const key = `${activeTab.id}\u0000${activeTab.sessionPath ?? ""}`;
const key = `${activeTab.id}\u0000${activeTab.sessionId ?? ""}\u0000${activeTab.sessionPath ?? ""}`;
const notice = legacyRemotePolicyNotice(key, session.composerProfile?.qualityFloor, session.goalRuntime?.stopCause, legacyQuality);
if (notice) showToast(t(notice), "warn");
}, [activeTab?.remote, activeTab?.id, activeTab?.sessionPath, session.composerProfile?.qualityFloor, session.transcript.items, session.goalRuntime?.stopCause, showToast, t]);
}, [activeTab?.remote, activeTab?.id, activeTab?.sessionId, activeTab?.sessionPath, session.composerProfile?.qualityFloor, session.transcript.items, session.goalRuntime?.stopCause, showToast, t]);
return { active, session, ready: active && session.state === "ready" && session.hydrated && Boolean(session.composerProfile), ...composer };
}
const legacyRemotePolicyNotice = createLegacyRemotePolicyNoticeTracker();
export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabStateValue, sessionPath?: string): RemoteSessionApi {
const runtimeState = useRuntimeSession(tabId, sessionPath);
export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabStateValue, sessionPath?: string, sessionId?: string): RemoteSessionApi {
const runtimeState = useRuntimeSession(tabId, sessionPath, sessionId);
const [state, setState] = useState<RemoteTabStateValue>(initial === "disconnected" ? "connecting" : (initial ?? "connecting"));
const [error, setError] = useState("");
const [transcript, setTranscriptState] = useState<State>(initialState);
@@ -558,7 +558,7 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
offState();
offEvent();
};
}, [applyRemoteStatus, tabId]);
}, [applyRemoteStatus, sessionId, sessionPath, tabId]);
// The runtime projection owns liveness. Feed a confirmed completion through
// the shared reducer so the transcript and live store settle together. A
+4 -2
View File
@@ -2,10 +2,12 @@ import { useEffect, useSyncExternalStore } from "react";
import { app } from "./bridge";
import { runtimeStateStore, selectRuntime } from "./runtimeStateStore";
export function useRuntimeSession(tabId?: string, sessionPath?: string) {
export function useRuntimeSession(tabId?: string, sessionPath?: string, sessionId?: string) {
const snapshot = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getSnapshot);
const failed = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getFailed);
const session = snapshot?.sessions.find(session => session.open && session.tabId === tabId && (!sessionPath || session.sessionPath === sessionPath));
const session = snapshot?.sessions.find(session => session.open && session.tabId === tabId
&& (!sessionId || session.sessionId === sessionId)
&& (!sessionPath || session.sessionPath === sessionPath));
return selectRuntime(session, failed);
}
+31 -6
View File
@@ -38,6 +38,9 @@ type serveSessionEntry struct {
Running bool `json:"running"`
TakenOver bool `json:"takenOver,omitempty"`
MtimeMilli int64 `json:"mtimeMilli"`
Preview string `json:"preview,omitempty"`
MetadataReady bool `json:"metadataReady,omitempty"`
}
type serveHTTPStatusError struct {
@@ -109,12 +112,14 @@ const expectedModelSettingsHeader = "X-Reasonix-Expected-Model-Settings"
const remoteSessionIDRoutePrefix = "session-id:"
func remoteSessionIdentityRoute(path, sessionID string) string {
if path = strings.TrimSpace(path); path != "" {
return path
}
// A session the Serve migrated into the identity catalog keeps its legacy
// path only as a read-only artifact; the identity is the live route.
if sessionID = strings.TrimSpace(sessionID); sessionID != "" {
return remoteSessionIDRoutePrefix + sessionID
}
if path = strings.TrimSpace(path); path != "" {
return path
}
return ""
}
@@ -160,6 +165,18 @@ func servePostSessionIdentityForSession(ctx context.Context, client *http.Client
if body == nil {
body = []byte("{}")
}
if strings.HasSuffix(strings.TrimRight(url, "/"), "/resume") {
var request struct {
Path string `json:"path"`
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(body, &request); err != nil {
return serveSessionIdentity{}, fmt.Errorf("invalid remote resume request: %w", err)
}
if strings.TrimSpace(request.Path) == "" && strings.TrimSpace(request.SessionID) == "" {
return serveSessionIdentity{}, fmt.Errorf("remote resume requires a session path or sessionId")
}
}
resp, err := serveDoForSession(ctx, client, http.MethodPost, url, body, expectedPath)
if err != nil {
return serveSessionIdentity{}, err
@@ -410,6 +427,14 @@ func (a *App) remoteProjectSessions(ctx context.Context, client *http.Client, ba
if override := prefs.SessionTitles[prefKey]; override != "" {
title = override
}
// A never-chatted canonical session is the remote analog of a local
// blank: local blanks disappear when unused, so hide these rows.
// MetadataStatus gates the check: a session whose catalog has not
// rebuilt (stale turns/title) stays visible until it definitively
// has no turns and no preview.
if e.SessionID != "" && !e.Current && e.Turns == 0 && title == "" && e.Preview == "" && e.MetadataReady {
continue
}
current := e.Current
route := remoteSessionRoute(e)
if preferLiveCurrent {
@@ -433,9 +458,9 @@ func (a *App) remoteProjectSessions(ctx context.Context, client *http.Client, ba
// transcript save. Synthesize it from the live route rather than reset,
// which status clears as soon as Serve names the not-yet-listed session.
a.remoteTabMu.Lock()
listedPaths := make(map[string]bool, len(entries))
listedRoutes := make(map[string]bool, len(entries))
for _, e := range entries {
listedPaths[e.Path] = true
listedRoutes[remoteSessionIdentityRoute(strings.TrimSpace(e.Path), strings.TrimSpace(e.SessionID))] = true
}
var blank *RemoteSessionView
for _, tab := range a.remoteTabs {
@@ -449,7 +474,7 @@ func (a *App) remoteProjectSessions(ctx context.Context, client *http.Client, ba
// yet. Unknown path (a legacy /new without a path header): blank
// while the fresh-session marker is still set.
if path := tab.routing.currentPath; path != "" {
if !listedPaths[path] {
if !listedRoutes[path] {
blank = &RemoteSessionView{Name: "", Path: path, Title: tab.topicTitle, Current: true, Running: tab.runtime.running, LastActivityAt: time.Now().UnixMilli()}
}
} else if tab.session.reset {
+21 -5
View File
@@ -295,6 +295,11 @@ func (a *App) commitRemoteTabOpenRegistration(registration *remoteTabOpenRegistr
commitRemoteTabAttachRoute(existing, "", true)
} else if route := remoteSessionIdentityRoute(existing.session.path, existing.session.sessionID); route != "" {
commitRemoteTabAttachRoute(existing, route, false)
// The committed route names the target session before the async
// /resume reaches Serve; gate commands until that resume commits
// or rolls back, so sends cannot fence against a session Serve
// is not running yet.
existing.routing.rehydratingPath = route
}
if existing.state == "ready" {
registration.selection.identityCommitted = true
@@ -468,13 +473,20 @@ func (a *App) restoreRemoteTabShells(f desktopTabsFile) {
}
}
}
title := strings.TrimSpace(entry.TopicTitle)
if title == "" {
title = remoteWorkspaceName(ws)
}
sessionName := strings.TrimSpace(entry.SessionName)
sessionPath := strings.TrimSpace(entry.SessionPath)
sessionID := strings.TrimSpace(entry.SessionID)
title := strings.TrimSpace(entry.TopicTitle)
// Older builds persisted the canonical session ID as the tab title when
// a blank remote session was materialized. That value is an identity
// token, not user-facing text; discard it during restore so the tab and
// sidebar keep the same localized "new session" presentation.
if title == sessionID || title == sessionName {
title = ""
}
if title == "" {
title = remoteWorkspaceName(ws)
}
route := remoteSessionIdentityRoute(sessionPath, sessionID)
restored := &remoteTab{
id: id, ref: RemoteTabRef{HostID: hostID, Workspace: ws},
@@ -590,7 +602,11 @@ func remoteTabTopicID(tab *remoteTab) string {
if tab == nil {
return ""
}
return tab.ref.HostID + "\x00" + tab.ref.Workspace + "\x00" + tab.session.name
identity := tab.session.name
if sessionID := strings.TrimSpace(tab.session.sessionID); sessionID != "" {
identity = sessionID
}
return tab.ref.HostID + "\x00" + tab.ref.Workspace + "\x00" + identity
}
func (a *App) remoteTabMetaSnapshot(tabID string) (TabMeta, bool) {
+6 -1
View File
@@ -264,10 +264,15 @@ func (a *App) syncRemoteRuntimeConnection(conn remoteRuntimeConnection) error {
for _, session := range payload.Sessions {
state, decodeErr := decodeRemoteRuntimeState(session.State)
_, duplicate := states[session.SessionPath]
if decodeErr != nil || session.SessionPath == "" || duplicate {
if decodeErr != nil || duplicate {
a.markRemoteRuntimeSyncFailed(conn.targets, true)
return fmt.Errorf("invalid runtime state synchronization payload")
}
if session.SessionPath == "" {
// An identity session a Serve has not routed yet reports no legacy
// path; skip it instead of failing the whole reconciliation.
continue
}
states[session.SessionPath] = state
}
a.applyRemoteRuntimeSnapshot(conn, states)
+5 -1
View File
@@ -573,13 +573,17 @@ func (a *App) remoteTabCommandTarget(tabID string) (*http.Client, string, string
tab := a.remoteTabs[tabID]
var client *http.Client
var base, expectedPath string
usable := tab != nil && tab.client != nil && tab.state == "ready"
switching := tab != nil && tab.routing.rehydratingPath != ""
usable := tab != nil && tab.client != nil && tab.state == "ready" && !switching
if usable {
client, base = tab.client, tab.base
expectedPath = tab.routing.currentPath
}
a.remoteTabMu.Unlock()
if !usable {
if switching {
return nil, "", "", fmt.Errorf("remote tab %q is switching sessions; wait for it to become ready", tabID)
}
return nil, "", "", fmt.Errorf("remote tab %q is not connected", tabID)
}
return client, base, expectedPath, nil
+20 -4
View File
@@ -128,7 +128,7 @@ func (a *App) resumeRemoteTabSessionPathForOpenSelection(tabID, name, sessionPat
// previously fell through to "return false" — the user's click
// was silently dropped because the SSH tunnel wasn't ready yet.
requeueRemoteTabOpenSelectionLocked(tab, &remoteTabPendingOpenSelection{
name: strings.TrimSpace(name), path: strings.TrimSpace(sessionPath), title: strings.TrimSpace(sessionTitle),
name: strings.TrimSpace(name), sessionID: strings.TrimSpace(tab.session.sessionID), path: strings.TrimSpace(sessionPath), title: strings.TrimSpace(sessionTitle),
revision: selectionRevision, deferred: true, identityCommitted: true, previous: previous,
})
a.remoteTabMu.Unlock()
@@ -139,6 +139,13 @@ func (a *App) resumeRemoteTabSessionPathForOpenSelection(tabID, name, sessionPat
}
consumeQueuedRemoteTabOpenSelectionLocked(tab, selectionRevision)
client, base, gen := tab.client, tab.base, tab.gen
requestedSessionID := ""
if strings.TrimSpace(sessionPath) == "" && strings.TrimSpace(name) != "" && strings.TrimSpace(tab.session.name) == strings.TrimSpace(name) {
// Canonical rows have no legacy path. When a reused shell has already
// committed the selected row, carry its stable ID into /resume instead
// of resolving the display/name token through the listing.
requestedSessionID = strings.TrimSpace(tab.session.sessionID)
}
failureRoute := remoteTabProvisionalResume{
targetPath: tab.routing.currentPath, pathRevision: tab.routing.pathRevision,
selectionRevision: tab.selectionRevision, previousSelection: previous,
@@ -150,6 +157,8 @@ func (a *App) resumeRemoteTabSessionPathForOpenSelection(tabID, name, sessionPat
var target serveSessionEntry
if sessionPath != "" {
target = serveSessionEntry{Name: strings.TrimSpace(name), Path: strings.TrimSpace(sessionPath), Title: strings.TrimSpace(sessionTitle)}
} else if requestedSessionID != "" {
target = serveSessionEntry{Name: strings.TrimSpace(name), SessionID: requestedSessionID, Title: strings.TrimSpace(sessionTitle)}
} else {
entries, err := serveSessions(ctx, client, base)
if err != nil {
@@ -163,7 +172,10 @@ func (a *App) resumeRemoteTabSessionPathForOpenSelection(tabID, name, sessionPat
}
}
if targetRoute := remoteSessionRoute(target); targetRoute != "" {
body, _ := json.Marshal(map[string]string{"path": target.Path, "hostId": target.HostID, "sessionId": target.SessionID})
body, err := remoteSessionResumeBody(target)
if err != nil {
return a.completeRemoteTabResumeFailure(tabID, tab, client, gen, failureRoute, err.Error())
}
// /resume may reattach a controller already producing frames. Route them
// before the request returns so the all-session pump does not discard its
// handoff output or prompt replay as background work.
@@ -250,7 +262,10 @@ func (a *App) DeleteRemoteProjectSession(hostID, workspace, name string) error {
defer done()
ctx, cancel := commandContext(a)
defer cancel()
body, _ := json.Marshal(map[string]string{"name": name})
// Keep the legacy basename for older Serve builds, but also send the
// immutable identity. Canonical sessions live under sessions-v4/<id>/ and
// cannot be removed by the old name.jsonl-only endpoint.
body, _ := json.Marshal(map[string]string{"name": name, "sessionId": strings.TrimSpace(name)})
return servePost(ctx, client, serveURL(base, "/delete-session"), body)
}
@@ -571,7 +586,8 @@ func (a *App) refreshRemoteTabTitle(tabID string) {
return
}
for _, entry := range entries {
if !entry.Current || expectedPath != "" && strings.TrimSpace(entry.Path) != strings.TrimSpace(expectedPath) {
entryRoute := remoteSessionIdentityRoute(strings.TrimSpace(entry.Path), strings.TrimSpace(entry.SessionID))
if !entry.Current || expectedPath != "" && entryRoute != strings.TrimSpace(expectedPath) {
continue
}
entry.Name = strings.TrimSpace(entry.Name)
+24
View File
@@ -2,9 +2,33 @@ package main
import (
"slices"
"strings"
"testing"
)
func TestDeleteRemoteProjectSessionSendsCanonicalIdentity(t *testing.T) {
fs := newFakeServe(t, "s3cret", nil)
kernel := &fakeRemoteKernel{
statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
ensureToken: "s3cret",
}
seedBridgeTestHost(t, "box")
a := &App{remoteRuntime: kernel}
meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
defer cleanupRemoteTabPumps(t, a)
if err := a.DeleteRemoteProjectSession("box", "~/app", "canonical-target"); err != nil {
t.Fatal(err)
}
want := `POST /delete-session {"name":"canonical-target","sessionId":"canonical-target"}`
if !slices.Contains(fs.recorded(), want) {
t.Fatalf("delete request missing canonical identity: %v", fs.recorded())
}
if strings.TrimSpace(meta.ID) == "" {
t.Fatal("remote tab id is empty")
}
}
// TestRemoteTabCommandsForwardedToServe pins that every command binding
// reaches the right serve endpoint with the mapped body.
func TestRemoteTabCommandsForwardedToServe(t *testing.T) {
+4
View File
@@ -120,4 +120,8 @@ func TestRemoteTabTopicIDSeparatorShape(t *testing.T) {
if named != "box\x00~/app\x00s1" {
t.Fatalf("named topic id = %q, want box\\0~/app\\0s1", named)
}
canonical := remoteTabTopicID(&remoteTab{ref: RemoteTabRef{HostID: "box", Workspace: "~/app"}, session: remoteTabSessionState{name: "legacy-name", sessionID: "canonical-id"}})
if canonical != "box\x00~/app\x00canonical-id" {
t.Fatalf("canonical topic id = %q, want immutable session identity", canonical)
}
}
+20 -3
View File
@@ -60,6 +60,23 @@ func preflightRemoteSessionTarget(ctx context.Context, client *http.Client, base
return serveSessionEntry{}, fmt.Errorf("remote session %q not found", name)
}
// remoteSessionResumeBody is the single request builder for /resume. The
// identity catalog deliberately leaves Path empty for canonical sessions, so
// callers must carry SessionID through instead of serializing an empty path.
// Name is retained as a compatibility hint for Serve versions that can
// resolve a listed session by name.
func remoteSessionResumeBody(target serveSessionEntry) ([]byte, error) {
if remoteSessionRoute(target) == "" {
return nil, fmt.Errorf("remote session %q has no resumable identity", strings.TrimSpace(target.Name))
}
return json.Marshal(map[string]string{
"path": strings.TrimSpace(target.Path),
"hostId": strings.TrimSpace(target.HostID),
"sessionId": strings.TrimSpace(target.SessionID),
"name": strings.TrimSpace(target.Name),
})
}
func enterRemoteSessionTarget(ctx context.Context, client *http.Client, base string, opts RemoteTabOpenOptions) (serveSessionEntry, error) {
name := strings.TrimSpace(opts.SessionName)
if opts.NewSession {
@@ -70,7 +87,7 @@ func enterRemoteSessionTarget(ctx context.Context, client *http.Client, base str
return serveSessionEntry{Path: identity.Path, SessionID: identity.SessionID, Current: true}, nil
}
if sessionID := strings.TrimSpace(opts.SessionID); sessionID != "" {
body, err := json.Marshal(map[string]string{"sessionId": sessionID})
body, err := remoteSessionResumeBody(serveSessionEntry{Name: name, SessionID: sessionID})
if err != nil {
return serveSessionEntry{}, err
}
@@ -84,7 +101,7 @@ func enterRemoteSessionTarget(ctx context.Context, client *http.Client, base str
return serveSessionEntry{Name: name, SessionID: sessionID, Title: strings.TrimSpace(opts.SessionTitle), Current: true}, nil
}
if sessionPath := strings.TrimSpace(opts.SessionPath); sessionPath != "" {
body, err := json.Marshal(map[string]string{"path": sessionPath})
body, err := remoteSessionResumeBody(serveSessionEntry{Name: name, Path: sessionPath})
if err != nil {
return serveSessionEntry{}, err
}
@@ -111,7 +128,7 @@ func enterRemoteSessionTarget(ctx context.Context, client *http.Client, base str
if session.Name != name {
continue
}
body, err := json.Marshal(map[string]string{"path": session.Path, "hostId": session.HostID, "sessionId": session.SessionID})
body, err := remoteSessionResumeBody(session)
if err != nil {
return serveSessionEntry{}, err
}
+20
View File
@@ -703,3 +703,23 @@ func TestEnterRemoteSessionUnknownName(t *testing.T) {
t.Fatalf("err = %v, want unknown session error", err)
}
}
func TestRemoteSessionResumeBodyUsesCanonicalIdentity(t *testing.T) {
body, err := remoteSessionResumeBody(serveSessionEntry{Name: "chat-title", SessionID: "stable-session"})
if err != nil {
t.Fatal(err)
}
var got map[string]string
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
if got["sessionId"] != "stable-session" || got["path"] != "" || got["name"] != "chat-title" {
t.Fatalf("canonical resume body = %v", got)
}
}
func TestRemoteSessionResumeBodyRejectsMissingIdentity(t *testing.T) {
if _, err := remoteSessionResumeBody(serveSessionEntry{Name: "unresolved"}); err == nil || !strings.Contains(err.Error(), "no resumable identity") {
t.Fatalf("missing identity error = %v", err)
}
}
+5 -3
View File
@@ -9,6 +9,7 @@ import (
// advance until Serve can accept the matching /resume or /new request.
type remoteTabPendingOpenSelection struct {
name string
sessionID string
path string
title string
newSession bool
@@ -21,7 +22,7 @@ type remoteTabPendingOpenSelection struct {
func newRemoteTabPendingOpenSelection(opts RemoteTabOpenOptions) *remoteTabPendingOpenSelection {
return &remoteTabPendingOpenSelection{
name: strings.TrimSpace(opts.SessionName), path: strings.TrimSpace(opts.SessionPath),
name: strings.TrimSpace(opts.SessionName), sessionID: strings.TrimSpace(opts.SessionID), path: strings.TrimSpace(opts.SessionPath),
title: strings.TrimSpace(opts.SessionTitle), newSession: opts.NewSession,
}
}
@@ -102,12 +103,13 @@ func (a *App) applyPendingRemoteTabOpenSelection(tabID string) {
if identityChanged {
current.session.newSession = false
current.session.name = selection.name
current.session.sessionID = selection.sessionID
current.session.path = selection.path
if selection.title != "" {
current.topicTitle = selection.title
}
if selection.path != "" {
commitRemoteTabAttachRoute(current, selection.path, false)
if route := remoteSessionIdentityRoute(selection.path, selection.sessionID); route != "" {
commitRemoteTabAttachRoute(current, route, false)
}
}
selection.deferred = false
+7 -3
View File
@@ -129,6 +129,7 @@ func TestRemoteTabRestoreBuildsDisconnectedShells(t *testing.T) {
RemoteTabs: []desktopRemoteTabEntry{
{ID: "r-1", HostID: "box", Workspace: "~/app", TopicTitle: "Fix bug", SessionName: "s1", SessionPath: "/remote/sessions/s1.jsonl"},
{ID: "r-2", HostID: "box", Workspace: "~/web", SessionPath: "/remote/sessions/blank.jsonl", SessionReset: true},
{ID: "r-3", HostID: "box", Workspace: "~/canonical", TopicTitle: "canonical-session-id", SessionName: "canonical-session-id", SessionID: "canonical-session-id"},
{ID: "", HostID: "box", Workspace: "~/skip"},
{ID: "local-1", HostID: "box", Workspace: "~/dup"},
},
@@ -139,7 +140,7 @@ func TestRemoteTabRestoreBuildsDisconnectedShells(t *testing.T) {
a.remoteTabMu.Lock()
defer a.remoteTabMu.Unlock()
if len(a.remoteTabs) != 2 || a.remoteTabs["r-1"] == nil || a.remoteTabs["r-2"] == nil {
if len(a.remoteTabs) != 3 || a.remoteTabs["r-1"] == nil || a.remoteTabs["r-2"] == nil || a.remoteTabs["r-3"] == nil {
t.Fatalf("restored shells = %+v", a.remoteTabs)
}
for id, tab := range a.remoteTabs {
@@ -153,14 +154,17 @@ func TestRemoteTabRestoreBuildsDisconnectedShells(t *testing.T) {
if got := a.remoteTabs["r-1"].topicTitle; got != "Fix bug" {
t.Fatalf("restored title = %q, want the persisted one", got)
}
if got := a.remoteTabs["r-3"].topicTitle; got != remoteWorkspaceName("~/canonical") {
t.Fatalf("canonical identity leaked into restored title = %q, want workspace fallback", got)
}
if tab := a.remoteTabs["r-1"]; tab.session.newSession || tab.session.name != "s1" || tab.session.path != "/remote/sessions/s1.jsonl" {
t.Fatalf("restored session identity = %+v", tab.session)
}
if tab := a.remoteTabs["r-2"]; !tab.session.newSession || !tab.session.reset || tab.session.path != "/remote/sessions/blank.jsonl" {
t.Fatalf("restored blank session identity = %+v", tab.session)
}
if got := strings.Join(a.remoteTabLayout.order, ","); got != "r-2,r-1" {
t.Fatalf("restored remote order = %q, want r-2,r-1", got)
if got := strings.Join(a.remoteTabLayout.order, ","); got != "r-2,r-1,r-3" {
t.Fatalf("restored remote order = %q, want r-2,r-1,r-3", got)
}
if a.remoteTabLayout.activeID != "" {
t.Fatalf("remoteActiveTabID = %q, want local startup surface", a.remoteTabLayout.activeID)
+4 -1
View File
@@ -234,7 +234,10 @@ func (a *App) publishRemoteTabResumeReadyLocked(tabID string, tab *remoteTab, cl
for _, frame := range frames {
kind, path, _, _ := probeRemoteTabFrame(string(frame))
if path != "" && path != route.targetPath {
return
// The buffer fence only admits the target route; a foreign frame
// here is defensive debris. Dropping it keeps the drain alive —
// aborting would strand the epoch and buffer live frames forever.
continue
}
if !a.publishRemoteTabFrameForRouteLocked(tabID, tab, tab, client, gen, route.targetPath, true, kind, frame) {
return
+9
View File
@@ -681,6 +681,15 @@ func TestRemoteTabCommandRejectsUnknownOrUnreadyTab(t *testing.T) {
if err := a.SubmitRemoteTab("booting", "hi"); err == nil || !strings.Contains(err.Error(), "not connected") {
t.Fatalf("unready tab err = %v, want not connected", err)
}
a.remoteTabMu.Lock()
a.remoteTabs["switching"] = &remoteTab{
id: "switching", state: "ready", client: &http.Client{},
routing: remoteTabSessionRouting{currentPath: "session-id:old", rehydratingPath: "session-id:new"},
}
a.remoteTabMu.Unlock()
if err := a.SubmitRemoteTab("switching", "hi"); err == nil || !strings.Contains(err.Error(), "switching sessions") {
t.Fatalf("session-switching tab err = %v, want switching-session guard", err)
}
}
// TestModelsForTabRemoteUsesDesktopCatalog: a remote tab's model switcher
+23 -5
View File
@@ -21,6 +21,15 @@ type RemoteTranscriptSnapshot struct {
}
func (a *App) remoteTranscriptRead(tabID, route string, request any, destination any) (bool, error) {
return a.remoteTranscriptReadAttempt(tabID, route, request, destination, true)
}
// remoteTranscriptReadAttempt retries one 409 after refreshing the remote
// tab's authoritative session identity. A Serve session can rotate while the
// desktop is hydrating (for example after a model switch); retrying the old
// route just repeats the conflict, while a status refresh updates the route
// to the identity that the next request must use.
func (a *App) remoteTranscriptReadAttempt(tabID, route string, request, destination any, refreshOnConflict bool) (bool, error) {
client, base, err := a.remoteTabCommandClient(tabID)
if err != nil {
return false, err
@@ -42,20 +51,22 @@ func (a *App) remoteTranscriptRead(tabID, route string, request any, destination
query.Set("session", sessionPath)
}
ctx, cancel := commandContext(a)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, serveURL(base, route)+"?"+query.Encode(), nil)
if err != nil {
cancel()
return false, err
}
response, err := client.Do(req)
if err != nil {
cancel()
return false, err
}
defer response.Body.Close()
const maxResponseBytes = transcript.MaxResponseBytes
body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1))
if err != nil {
return false, err
body, readErr := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1))
response.Body.Close()
cancel()
if readErr != nil {
return false, readErr
}
if len(body) > maxResponseBytes {
return false, fmt.Errorf("remote transcript response exceeds limit")
@@ -70,6 +81,13 @@ func (a *App) remoteTranscriptRead(tabID, route string, request any, destination
switch response.StatusCode {
case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented:
return false, nil
case http.StatusConflict:
if refreshOnConflict {
if _, refreshErr := a.RemoteTabStatus(tabID); refreshErr == nil {
return a.remoteTranscriptReadAttempt(tabID, route, request, destination, false)
}
}
return false, fmt.Errorf("remote transcript read failed (HTTP %d)", response.StatusCode)
case http.StatusOK:
default:
return false, fmt.Errorf("remote transcript read failed (HTTP %d)", response.StatusCode)
+67
View File
@@ -128,6 +128,40 @@ func TestRemoteTranscriptRejectsLateSessionResponse(t *testing.T) {
}
}
func TestRemoteTranscriptRefreshesIdentityAfterConflict(t *testing.T) {
var snapshotReads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/transcript/snapshot":
if n := snapshotReads.Add(1); n == 1 {
if got := r.URL.Query().Get("session"); got != "/session.jsonl" {
t.Errorf("first session route = %q", got)
}
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte("active session changed"))
return
}
if got := r.URL.Query().Get("session"); got != "session-id:current" {
t.Errorf("refreshed session route = %q", got)
}
_ = json.NewEncoder(w).Encode(transcript.Snapshot{Boundary: transcript.Boundary{ProtocolVersion: transcript.ProtocolVersion, SnapshotID: "current"}})
case "/status":
_ = json.NewEncoder(w).Encode(map[string]any{"sessionPath": "", "sessionId": "current", "running": false})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
app, tab := remoteTranscriptFixture(server)
result, err := app.RemoteTranscriptSnapshotForTab(tab.id, transcript.PageRequest{})
if err != nil || !result.Supported || result.Snapshot == nil || snapshotReads.Load() != 2 {
t.Fatalf("conflict retry = %+v, %v, reads=%d", result, err, snapshotReads.Load())
}
if got := tab.routing.currentPath; got != "session-id:current" {
t.Fatalf("refreshed tab route = %q", got)
}
}
func TestRemoteTabMetadataDoesNotRequestHistory(t *testing.T) {
var historyReads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -359,3 +393,36 @@ func TestSessionHistoryWindowRequiresCanonicalBinding(t *testing.T) {
t.Fatal("field read without a bound session")
}
}
func TestRemoteCanonicalSessionHistoryPageDoesNotRequireContentCapability(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/session-history/page" {
t.Fatalf("unexpected route %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(session.MessageHistoryPage{SnapshotSequence: 4})
}))
defer server.Close()
app, tab := remoteTranscriptFixture(server)
tab.capabilities = map[string]bool{serveCapabilitySessions: true}
tab.session.sessionID = "canonical"
page, err := app.RemoteSessionHistoryPageForTab(tab.id, "", 0)
if err != nil || page.SnapshotSequence != 4 {
t.Fatalf("page = %+v, %v", page, err)
}
}
func TestRemoteCanonicalSessionHistoryContentRequiresContentCapability(t *testing.T) {
var reads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reads.Add(1) }))
defer server.Close()
app, tab := remoteTranscriptFixture(server)
tab.capabilities = map[string]bool{serveCapabilitySessions: true}
tab.session.sessionID = "canonical"
ref := sessioncontent.Ref{Digest: strings.Repeat("b", 64), Bytes: 1, MediaType: "text/plain"}
if _, err := app.RemoteSessionHistoryContentForTab(tab.id, ref, 0); err == nil {
t.Fatal("content unexpectedly enabled without content capability")
}
if reads.Load() != 0 {
t.Fatalf("network reads = %d", reads.Load())
}
}
+29 -13
View File
@@ -69,7 +69,7 @@ func TestOrderResumeSessionsGroupsRecoveryCopiesAndPrefersNewestLeaf(t *testing.
}
}
func TestMostRecentSessionIgnoresRecoveryPickerLeafPreference(t *testing.T) {
func TestNewestResumeTargetIgnoresRecoveryPickerLeafPreference(t *testing.T) {
dir := t.TempDir()
rootPath := filepath.Join(dir, "root.jsonl")
recoveryPath := filepath.Join(dir, "recovery.jsonl")
@@ -91,16 +91,16 @@ func TestMostRecentSessionIgnoresRecoveryPickerLeafPreference(t *testing.T) {
t.Fatalf("save recovery meta: %v", err)
}
grouped := recentSessions(dir)
grouped := mergedResumeSessions(dir)
if len(grouped) != 2 || grouped[0].Path != recoveryPath {
t.Fatalf("interactive resume order = %+v, want recovery leaf grouped first", grouped)
}
latest, ok := mostRecentSession(dir)
latest, ok := newestResumeTarget(dir)
if !ok {
t.Fatal("mostRecentSession found no session")
t.Fatal("newestResumeTarget found no session")
}
if latest.Path != rootPath {
t.Fatalf("--continue session = %q, want chronologically newest %q", latest.Path, rootPath)
if latest.path != rootPath {
t.Fatalf("--continue session = %q, want chronologically newest %q", latest.path, rootPath)
}
}
@@ -153,7 +153,7 @@ func TestRunResumeKeepsCompletedIndexStableAcrossRecoveryGC(t *testing.T) {
if err != nil || len(candidates) != 1 || candidates[0] != recovery.Path {
t.Fatalf("recovery GC precondition = %v err=%v, want %q", candidates, err, recovery.Path)
}
sessions := recentSessions(dir)
sessions := mergedResumeSessions(dir)
targetIndex := 0
for i, session := range sessions {
if session.Path == targetPath {
@@ -198,12 +198,16 @@ func TestCapResumeSessionGroupsDoesNotSplitRecoveryFamily(t *testing.T) {
agent.SessionInfo{Path: "/sessions/recovery-b.jsonl", ModTime: base.Add(time.Minute), Recovered: true, ParentID: "root"},
)
got := capResumeSessionGroups(orderResumeSessions(sessions), resumeListCap)
entries := make([]resumeEntry, 0, len(sessions))
for _, session := range orderResumeSessions(sessions) {
entries = append(entries, resumeEntry{session: session, target: cliResumeTarget{path: session.Path}})
}
got := capResumeEntries(entries, resumeListCap)
if len(got) != 9 {
t.Fatalf("capped sessions len = %d, want 9 complete standalone groups", len(got))
}
for _, session := range got {
if session.Recovered || agent.BranchID(session.Path) == "root" {
for _, entry := range got {
if entry.session.Recovered || agent.BranchID(entry.session.Path) == "root" {
t.Fatalf("cap split recovery family instead of omitting it: %+v", got)
}
}
@@ -316,7 +320,7 @@ func TestResumeDispatchSwitchesAndReplays(t *testing.T) {
m.ctrl = ctrl
target := 0
for i, s := range recentSessions(dir) {
for i, s := range mergedResumeSessions(dir) {
if s.Path == otherPath {
target = i + 1
}
@@ -356,7 +360,7 @@ func TestResumeWhileScrolledUpPinsViewportToBottom(t *testing.T) {
saveTestSession(t, otherPath, "OTHER-SESSION-PROMPT")
target := 0
for i, s := range recentSessions(dir) {
for i, s := range mergedResumeSessions(dir) {
if s.Path == otherPath {
target = i + 1
}
@@ -398,6 +402,18 @@ func TestResumeWhileScrolledUpPinsViewportToBottom(t *testing.T) {
}
}
// mergedResumeSessions projects the merged picker rows onto the legacy row
// shape the older listing tests assert against.
func mergedResumeSessions(dir string) []agent.SessionInfo {
entries := mergedResumeEntries(dir, resumeListCap)
out := make([]agent.SessionInfo, 0, len(entries))
for _, entry := range entries {
out = append(out, entry.session)
}
return out
}
func saveTestSession(t *testing.T, path, prompt string) {
t.Helper()
s := agent.NewSession("sys")
@@ -473,7 +489,7 @@ func TestRunResumeSwitchesSession(t *testing.T) {
m.ctrl = ctrl
target := 0
for i, s := range recentSessions(dir) {
for i, s := range mergedResumeSessions(dir) {
if s.Path == otherPath {
target = i + 1
}
+8 -1
View File
@@ -53,7 +53,14 @@ func setupCLIMultiSessionProfile(ctx context.Context, model string, maxSteps int
}
func newCLIMultiSessionServer(ctrl *control.Controller, bc *serve.Broadcaster, tag *serve.SessionTagSink, cfg config.ServeConfig, leases *control.SessionLeaseKeeper, buildOpts boot.Options) *serve.Server {
tag.SetPath(ctrl.SessionPath())
// Exclusive identities have no legacy path; without the session id the
// boot tag stamps live frames path-less and identity-routed subscribers
// cannot attribute them after the first rebind.
if ref, bound := ctrl.SessionRef(); bound {
tag.SetIdentity("", ref.SessionID)
} else {
tag.SetPath(ctrl.SessionPath())
}
srv := serve.New(ctrl, bc, cfg)
srv.SetControllerBuildOptions(buildOpts)
srv.RegisterSessionTag(ctrl, tag)
+1 -1
View File
@@ -653,7 +653,7 @@ func assertLeaseHeldRecoveryPathAtBuild(t *testing.T, m *chatTUI, active, heldAt
func resumeIndexForPath(t *testing.T, dir, path string) int {
t.Helper()
for i, session := range recentSessions(dir) {
for i, session := range mergedResumeSessions(dir) {
if session.Path == path {
return i + 1
}
+4 -1
View File
@@ -2,6 +2,7 @@ package serve
import (
"encoding/json"
"strings"
"sync"
"time"
@@ -303,7 +304,9 @@ func wireFrameMustReachSubscriber(data []byte) bool {
// Serve so the remote tab keeps rendering the conversation live without any
// change to its own pipeline.
func (b *Broadcaster) EmitWire(wired eventwire.Event) {
if wired.SessionPath != "" {
// Identity routes are not filesystem paths: canonicalizing them would
// mangle the route into an absolute pseudo-path that no subscriber matches.
if wired.SessionPath != "" && !strings.HasPrefix(wired.SessionPath, remoteSessionIDQueryPrefix) {
wired.SessionPath = agent.CanonicalSessionPath(wired.SessionPath)
}
b.mu.Lock()
+28 -1
View File
@@ -70,6 +70,17 @@ func (s *sessionTagSink) PrimePath(path string) {
s.mu.Unlock()
}
// PrimeIdentity assigns a replacement controller's complete route without
// publishing its buffered boot events. Canonical v3 sessions have no legacy
// path, so PrimePath alone would drop the session ID from those events.
func (s *sessionTagSink) PrimeIdentity(path, sessionID string) {
s.mu.Lock()
s.path = canonicalSessionPath(path)
s.sessionID = strings.TrimSpace(sessionID)
s.runtimeActive = s.bc.CurrentSession() == s.path
s.mu.Unlock()
}
// BufferPath retags synchronous in-place Resume events but withholds them until
// Serve publishes the matching foreground route. Unlike PrimePath, it also
// pauses a sink that was already active for the previous session.
@@ -230,6 +241,15 @@ func (s *Server) buildTagged(ctx context.Context, ref string, inheritTemp bool)
opts.WorkspaceRoot = cur.WorkspaceRoot()
if inheritTemp {
opts.SessionTemp = cur.SessionTemp()
// Model/effort switches rebuild the Agent, not the logical session.
// Passing only SessionService creates a lazy controller with no bound
// runtime; its first submit then allocates a new ID while the desktop
// still fences the old one, producing HTTP 409.
if service, runtime, bound := cur.SessionBinding(); bound {
opts.SessionService = service
opts.SessionRuntime = runtime
opts.SessionHostID = runtime.Ref().HostID
}
}
}
@@ -461,7 +481,14 @@ func (s *Server) busyDetach(ctx context.Context, cur *control.Controller, target
return err
}
}
tag.PrimePath(targetPath)
// PrimeIdentity keeps the session id alive across the swap: exclusive
// sessions have no path, and PrimePath alone would strip the id from every
// frame the replacement controller emits.
if ref, ok := newCtrl.SessionRef(); ok {
tag.PrimeIdentity(targetPath, ref.SessionID)
} else {
tag.PrimePath(targetPath)
}
newCtrl.EnableInteractiveApproval()
newCtrl.SetOnSessionRecovered(s.sessionRecoveryHandler(newCtrl, s.leases))
if s.leases != nil {
+12 -1
View File
@@ -78,7 +78,18 @@ func (s *Server) runtimeStatesSnapshot() runtimeStatesView {
if ctrl == nil {
continue
}
result.Sessions = append(result.Sessions, runtimeSessionView{SessionPath: agent.CanonicalSessionPath(ctrl.SessionPath()), Current: ctrl == current, State: runtimeStateOf(ctrl)})
// Identity sessions carry no legacy path; projecting their route
// reference keeps the desktop's path-keyed reconciliation working
// while hosts complete the catalog transition.
path := agent.CanonicalSessionPath(ctrl.SessionPath())
if path == "" {
if identity, ok := ctrl.(control.IdentityLifecycle); ok {
if ref, bound := identity.SessionRef(); bound {
path = remoteSessionIDQueryPrefix + ref.SessionID
}
}
}
result.Sessions = append(result.Sessions, runtimeSessionView{SessionPath: path, Current: ctrl == current, State: runtimeStateOf(ctrl)})
}
s.bindMu.Unlock()
sort.Slice(result.Sessions, func(i, j int) bool {
+171 -3
View File
@@ -285,6 +285,15 @@ func (s *Server) switchModelLocked(ctx context.Context, ref string) error {
// this session, or re-trust Plan-mode read-only commands already trusted
// this session.
if prev, ok := cur.(*control.Controller); ok {
// A model switch must preserve all session axes. In particular, the
// remote composer reads these immediately after the rebuild; resetting
// them to a freshly constructed controller's defaults makes the three
// mode controls appear to work while the next submit uses other values.
newCtrl.SetToolApprovalMode(prev.ToolApprovalMode())
newCtrl.SetPlanMode(prev.PlanMode())
if goal := prev.Goal(); goal != "" && newCtrl.Goal() == "" {
newCtrl.SetGoal(goal)
}
newCtrl.RestoreSessionAuthorizations(prev.SessionAuthorizations())
if err := newCtrl.InheritLifecycleFrom(prev); err != nil {
s.closeTaggedController(newCtrl)
@@ -312,7 +321,11 @@ func (s *Server) switchModelLocked(ctx context.Context, ref string) error {
}
}
activePath := newCtrl.SessionPath()
tag.PrimePath(activePath)
activeSessionID := ""
if ref, ok := newCtrl.SessionRef(); ok {
activeSessionID = ref.SessionID
}
tag.PrimeIdentity(activePath, activeSessionID)
if err := s.rebindSessionLeaseFor(activePath, newCtrl); err != nil {
s.closeTaggedController(newCtrl)
if errors.Is(err, agent.ErrSessionLeaseHeld) {
@@ -873,6 +886,26 @@ func (s *Server) approve(w http.ResponseWriter, r *http.Request) {
// if the client sends If-None-Match with the current ETag, the server returns
// 304 Not Modified with no body, saving bandwidth on reconnects.
func (s *Server) history(w http.ResponseWriter, r *http.Request) {
if raw := strings.TrimSpace(r.URL.Query().Get("session")); strings.HasPrefix(raw, remoteSessionIDQueryPrefix) {
// Canonical identity routes have no legacy transcript path to resolve.
// Select the exact foreground or detached controller so compatibility
// clients cannot silently render the wrong session after a resume.
s.bindMu.Lock()
defer s.bindMu.Unlock()
ctrl := s.resolveReadControllerLocked(raw)
if ctrl == nil {
http.Error(w, "transcript session is not bound to this runtime", http.StatusConflict)
return
}
if path := agent.CanonicalSessionPath(ctrl.SessionPath()); path != "" && s.sessionMirrored(path) {
if msgs, ok := s.mirroredHistory(path); ok {
writeJSONCached(w, r, historyMessages(msgs))
return
}
}
writeJSONCached(w, r, historyMessages(ctrl.History()))
return
}
// A read-only surface can select a specific session a local runtime owns
// (spectator attach): serve the local writer's transcript from the file.
if raw := r.URL.Query().Get("session"); raw != "" {
@@ -1205,12 +1238,27 @@ func (s *Server) resume(w http.ResponseWriter, r *http.Request) {
Path string `json:"path"`
HostID string `json:"hostId"`
SessionID string `json:"sessionId"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
if strings.TrimSpace(body.SessionID) != "" {
body.Path = strings.TrimSpace(body.Path)
body.HostID = strings.TrimSpace(body.HostID)
body.SessionID = strings.TrimSpace(body.SessionID)
body.Name = strings.TrimSpace(body.Name)
if body.SessionID == "" && body.Path == "" && body.Name != "" {
// Canonical /sessions rows intentionally expose identity in sessionId and
// leave the legacy path empty. Accept the name as a compatibility
// fallback for older clients that know the row but omit sessionId.
if identity, ok := s.ctl().(control.IdentityLifecycle); ok && identity.UsesExclusiveSession() {
body.SessionID = body.Name
} else if filepath.Base(body.Name) == body.Name && !strings.ContainsAny(body.Name, `/\\`) {
body.Path = filepath.Join(s.ctl().SessionDir(), body.Name+".jsonl")
}
}
if body.SessionID != "" {
s.resumeIdentitySession(w, r, body.HostID, body.SessionID)
return
}
@@ -1655,13 +1703,18 @@ var deleteSessionBeforeOwnershipLockHookForTest func()
// deleteSession removes a saved session by the session name returned from /sessions.
func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Name string `json:"name"`
SessionID string `json:"sessionId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := strings.TrimSpace(req.Name)
sessionID := strings.TrimSpace(req.SessionID)
if name == "" {
name = sessionID
}
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
@@ -1683,6 +1736,26 @@ func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
s.bindMu.Lock()
defer s.bindMu.Unlock()
dir := s.ctl().SessionDir()
if sessionID != "" && s.canonicalSessionIsCurrent(sessionID) {
// Never let a colliding legacy basename take precedence over the active
// canonical identity.
s.deleteCanonicalSession(w, r, sessionID)
return
}
// A canonical session is stored as <sessions-v4>/<sessionId>/ rather than
// <legacy-dir>/<name>.jsonl. Prefer the legacy file when it exists so the
// old endpoint remains backward compatible, then use the immutable identity
// for canonical rows returned by /sessions.
legacyExists := false
if dir != "" {
_, statErr := os.Stat(filepath.Join(dir, name+".jsonl"))
legacyExists = statErr == nil
}
if sessionID != "" && !legacyExists {
if s.deleteCanonicalSession(w, r, sessionID) {
return
}
}
if dir == "" {
http.Error(w, "sessions disabled", http.StatusBadRequest)
return
@@ -1735,6 +1808,101 @@ func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// deleteCanonicalSession deletes one canonical identity and reports whether
// the request was handled. It is called with bindMu held so a concurrent
// /resume or /new cannot change the foreground identity between the active
// check and the filesystem tombstone.
func (s *Server) deleteCanonicalSession(w http.ResponseWriter, r *http.Request, sessionID string) bool {
identity, ok := s.ctl().(control.IdentityLifecycle)
if !ok || !identity.UsesExclusiveSession() {
return false
}
service := identity.SessionService()
if service == nil {
http.Error(w, "canonical sessions disabled", http.StatusBadRequest)
return true
}
ref := session.SessionRef{HostID: service.HostID(), SessionID: sessionID}
if current, bound := identity.SessionRef(); bound && current == ref {
http.Error(w, "cannot delete active session", http.StatusConflict)
return true
}
if err := service.Delete(r.Context(), ref); err != nil {
switch {
case errors.Is(err, session.ErrSessionNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
case errors.Is(err, session.ErrRuntimeBusy), errors.Is(err, session.ErrRuntimeBound):
http.Error(w, err.Error(), http.StatusConflict)
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return true
}
// The migration map is keyed by the canonical row; once the catalog row
// is gone the legacy source stops counting as migrated and would resurface
// in /sessions as a fresh legacy row. Remove the frozen source too.
s.removeMigratedLegacySource(service, sessionID)
w.WriteHeader(http.StatusNoContent)
return true
}
// removeMigratedLegacySource deletes the legacy transcript a canonical
// session was migrated from, so removing the canonical row does not resurrect
// the source in /sessions. Failures are best-effort: the canonical delete has
// already succeeded, and a leftover source only degrades to the pre-fix
// behavior.
func (s *Server) removeMigratedLegacySource(service *session.Service, sessionID string) {
q := service.Query()
defer q.Close()
page, err := q.List(context.Background(), "", 1000)
if err != nil {
return
}
roots := make(map[string]struct{})
for _, info := range page.Sessions {
if path := strings.TrimSpace(info.Path); path != "" {
roots[filepath.Dir(filepath.Clean(path))] = struct{}{}
}
}
if source, ok := migrationSourceForTarget(roots, sessionID); ok {
_ = removeSessionFiles(filepath.Dir(source), source)
}
}
// migrationSourceForTarget reads the migration maps under roots and returns
// the legacy source path recorded for sessionID.
func migrationSourceForTarget(roots map[string]struct{}, sessionID string) (string, bool) {
for root := range roots {
data, err := os.ReadFile(filepath.Join(root, "migration-map.json"))
if err != nil {
continue
}
var mapping session.MigrationMapping
if json.Unmarshal(data, &mapping) != nil || mapping.SchemaVersion != session.SchemaVersion {
continue
}
for _, entry := range mapping.Entries {
if entry.TargetID == sessionID {
return entry.SourcePath, true
}
}
}
return "", false
}
func (s *Server) canonicalSessionIsCurrent(sessionID string) bool {
identity, ok := s.ctl().(control.IdentityLifecycle)
if !ok || !identity.UsesExclusiveSession() {
return false
}
service := identity.SessionService()
if service == nil {
return false
}
current, bound := identity.SessionRef()
return bound && current == (session.SessionRef{HostID: service.HostID(), SessionID: sessionID})
}
func finishSessionDestroy(destroy control.SessionDestroyHandle) jobs.TeardownResult {
if destroy.Wait != nil {
result := destroy.Wait()
+17 -2
View File
@@ -52,12 +52,27 @@ func (s *Server) expectedSessionErrorLocked(r *http.Request) error {
}
func (s *Server) expectedSessionPathErrorLocked(rawExpected string) error {
expected := agent.CanonicalSessionPath(strings.TrimSpace(rawExpected))
expected := strings.TrimSpace(rawExpected)
if expected == "" {
return nil
}
// Identity sessions carry no legacy path: their session-id reference is
// compared against the bound controller's exclusive identity, matching
// the Expected-Session-ID header protocol.
if id, ok := strings.CutPrefix(expected, remoteSessionIDQueryPrefix); ok {
identity, hasIdentity := s.ctl().(control.IdentityLifecycle)
if !hasIdentity {
return errExpectedSessionChanged
}
ref, bound := identity.SessionRef()
if !bound || ref.SessionID != id {
return errExpectedSessionChanged
}
return nil
}
canonical := agent.CanonicalSessionPath(expected)
actual := agent.CanonicalSessionPath(strings.TrimSpace(s.ctl().SessionPath()))
if actual != expected {
if actual != canonical {
return errExpectedSessionChanged
}
return nil
+135 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@@ -15,6 +16,106 @@ import (
"reasonix/internal/session"
)
func TestSessionsDeduplicatesMigratedLegacySource(t *testing.T) {
legacyDir := t.TempDir()
v4Root := filepath.Join(t.TempDir(), "sessions-v4")
service, err := session.NewService("serve-test", session.NewFilesystemPersistence(v4Root))
if err != nil {
t.Fatal(err)
}
exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: exec, SessionDir: legacyDir, SessionService: service, ExclusiveSession: true})
if _, err := ctrl.BindFreshSession(t.Context(), "current"); err != nil {
t.Fatal(err)
}
target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "canonical-target"})
if err != nil {
t.Fatal(err)
}
if err := service.Close(t.Context(), target.Ref()); err != nil {
t.Fatal(err)
}
legacy := filepath.Join(legacyDir, "old.jsonl")
if err := os.WriteFile(legacy, []byte(`{"role":"user","content":"old"}`+"\n"), 0o600); err != nil {
t.Fatal(err)
}
mapping := session.MigrationMapping{
SchemaVersion: session.SchemaVersion,
Entries: []session.MigrationEntry{{SourcePath: agent.CanonicalSessionPath(legacy), TargetID: "canonical-target"}},
}
data, err := json.Marshal(mapping)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(v4Root, "migration-map.json"), data, 0o600); err != nil {
t.Fatal(err)
}
t.Cleanup(ctrl.Close)
srv := newLifecycleTestServer(t, ctrl, NewBroadcaster(), config.ServeConfig{})
recorder := httptest.NewRecorder()
srv.sessions(recorder, httptest.NewRequest(http.MethodGet, "/sessions", nil))
var rows []sessionListEntry
if err := json.Unmarshal(recorder.Body.Bytes(), &rows); err != nil {
t.Fatal(err)
}
for _, row := range rows {
if row.Name == "old" || agent.CanonicalSessionPath(row.Path) == agent.CanonicalSessionPath(legacy) {
t.Fatalf("migrated legacy row was not deduplicated: %+v", rows)
}
}
found := false
for _, row := range rows {
if row.SessionID == "canonical-target" {
found = true
}
}
if !found {
t.Fatalf("canonical target missing from sessions: %+v", rows)
}
}
func TestDeleteSessionDeletesCanonicalIdentity(t *testing.T) {
legacyDir := t.TempDir()
v4Root := filepath.Join(t.TempDir(), "sessions-v4")
service, err := session.NewService("serve-test", session.NewFilesystemPersistence(v4Root))
if err != nil {
t.Fatal(err)
}
exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: exec, SessionDir: legacyDir, SessionService: service, ExclusiveSession: true})
current, err := ctrl.BindFreshSession(t.Context(), "current")
if err != nil {
t.Fatal(err)
}
target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "canonical-target"})
if err != nil {
t.Fatal(err)
}
if err := service.Close(t.Context(), target.Ref()); err != nil {
t.Fatal(err)
}
t.Cleanup(ctrl.Close)
srv := httptest.NewServer(newLifecycleTestServer(t, ctrl, NewBroadcaster(), config.ServeConfig{}).Handler())
defer srv.Close()
post := func(body string) int {
resp, err := http.Post(srv.URL+"/delete-session", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
return resp.StatusCode
}
if got := post(`{"name":"` + current.SessionID + `","sessionId":"` + current.SessionID + `"}`); got != http.StatusConflict {
t.Fatalf("active canonical delete status = %d, want 409", got)
}
if got := post(`{"name":"canonical-target","sessionId":"canonical-target"}`); got != http.StatusNoContent {
t.Fatalf("canonical delete status = %d, want 204", got)
}
if _, err := os.Stat(filepath.Join(v4Root, "canonical-target")); !os.IsNotExist(err) {
t.Fatalf("canonical session still exists or stat failed unexpectedly: %v", err)
}
}
func newExclusiveSessionServe(t *testing.T) (*Server, *control.Controller, *session.Service, session.SessionRef) {
t.Helper()
root := filepath.Join(t.TempDir(), "sessions-v4")
@@ -31,7 +132,16 @@ func newExclusiveSessionServe(t *testing.T) (*Server, *control.Controller, *sess
t.Fatal(err)
}
t.Cleanup(ctrl.Close)
return New(ctrl, NewBroadcaster(), config.ServeConfig{}), ctrl, service, ref
bc := NewBroadcaster()
srv := New(ctrl, bc, config.ServeConfig{})
// Production serve hosts register a frame tag per controller; the takeover
// tests assert its identity, so wire the sink the CLI server would.
tag := newSessionTagSink(bc)
if id, bound := ctrl.SessionRef(); bound {
tag.SetIdentity("", id.SessionID)
}
srv.RegisterSessionTag(ctrl, tag)
return srv, ctrl, service, ref
}
func TestExclusiveV3SessionsAndResumeUseImmutableIdentity(t *testing.T) {
@@ -123,6 +233,30 @@ func TestExclusiveV3MissingResumeDoesNotCreateOrReplaceCurrent(t *testing.T) {
}
}
func TestExclusiveV3ResumeNameFallbackUsesCanonicalIdentity(t *testing.T) {
srv, ctrl, service, current := newExclusiveSessionServe(t)
target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "named-target"})
if err != nil {
t.Fatal(err)
}
if err := service.Close(t.Context(), target.Ref()); err != nil {
t.Fatal(err)
}
resume := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/resume", strings.NewReader(`{"name":"named-target"}`))
srv.resume(resume, req)
if resume.Code != http.StatusNoContent {
t.Fatalf("name fallback status = %d: %s", resume.Code, resume.Body.String())
}
if got := resume.Header().Get(sessionIDHeader); got != "named-target" {
t.Fatalf("name fallback session id = %q", got)
}
if got, ok := ctrl.SessionRef(); !ok || got.HostID != current.HostID || got.SessionID != "named-target" {
t.Fatalf("controller identity after name fallback = %+v, bound=%v", got, ok)
}
}
func TestExclusiveV3RotationAndMutationFenceReturnSessionID(t *testing.T) {
srv, ctrl, _, current := newExclusiveSessionServe(t)
stale := httptest.NewRequest(http.MethodPost, "/cancel", nil)
+5
View File
@@ -43,6 +43,11 @@ func (s *Server) commitLoadedResume(w http.ResponseWriter, cur control.SessionAP
return false
}
w.Header().Set(sessionIDHeader, ref.SessionID)
// The legacy transcript is now an import artifact and the identity is
// the live route; re-point the frame tag or every subsequent turn
// would still be stamped with the frozen legacy path and dropped by
// identity-routed subscribers.
s.setControllerPath(ctrl, "")
if s.leases != nil {
// Migration has frozen and published the source. It is now a
// read-only legacy artifact, so the Serve must release that lease.
+10 -2
View File
@@ -39,7 +39,11 @@ func (s *Server) sessionTransitionHandler(ctrl *control.Controller, k *control.S
path := agent.CanonicalSessionPath(info.TargetPath)
info.OnCommit(func() {
if tag := s.tagFor(ctrl); tag != nil {
tag.PrimePath(path)
if ref, bound := ctrl.SessionRef(); bound {
tag.PrimeIdentity(path, ref.SessionID)
} else {
tag.PrimePath(path)
}
}
if s.publishControllerPathIfCurrent(ctrl, path) && branchTransitionNeedsRouteEvent(info.Reason) {
s.announceSessionChanged(path, false)
@@ -78,7 +82,11 @@ func (s *Server) sessionRecoveryHandler(ctrl *control.Controller, k *control.Ses
func (s *Server) publishRecoveredControllerRoute(ctrl *control.Controller, path string) {
if tag := s.tagFor(ctrl); tag != nil {
tag.PrimePath(path)
if ref, bound := ctrl.SessionRef(); bound {
tag.PrimeIdentity(path, ref.SessionID)
} else {
tag.PrimePath(path)
}
}
if s.publishControllerPathIfCurrent(ctrl, path) {
// Recovery changes foreground identity outside the ordinary transition
+131 -14
View File
@@ -1,6 +1,7 @@
package serve
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
@@ -9,6 +10,7 @@ import (
"reasonix/internal/agent"
"reasonix/internal/control"
"reasonix/internal/session"
"reasonix/internal/store"
)
@@ -23,22 +25,25 @@ type sessionListEntry struct {
Running bool `json:"running,omitempty"`
TakenOver bool `json:"takenOver,omitempty"`
MtimeMilli int64 `json:"mtimeMilli"`
Preview string `json:"preview,omitempty"`
MetadataReady bool `json:"metadataReady,omitempty"`
}
// sessions lists saved sessions with event-log-aware titles and turn counts.
func (s *Server) sessions(w http.ResponseWriter, r *http.Request) {
ctrl := s.ctl()
dir := ctrl.SessionDir()
if dir == "" {
writeJSON(w, []any{})
return
}
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
entries = nil
} else if err != nil {
writeJSON(w, []any{})
return
var entries []os.DirEntry
if dir != "" {
var err error
entries, err = os.ReadDir(dir)
if os.IsNotExist(err) {
entries = nil
} else if err != nil {
writeJSON(w, []any{})
return
}
}
current := agent.CanonicalSessionPath(ctrl.SessionPath())
running := map[string]bool{}
@@ -47,7 +52,8 @@ func (s *Server) sessions(w http.ResponseWriter, r *http.Request) {
running[filepath.Clean(path)] = controllerHasActiveRuntimeWork(detached.ctrl)
}
s.detachedMu.Unlock()
out := make([]sessionListEntry, 0, len(entries))
legacyRows := make([]sessionListEntry, 0, len(entries))
legacyByPath := make(map[string]sessionListEntry, len(entries))
for _, entry := range entries {
if entry.IsDir() || !store.IsSessionTranscriptName(entry.Name()) {
continue
@@ -77,28 +83,139 @@ func (s *Server) sessions(w http.ResponseWriter, r *http.Request) {
row.Turns = turns
row.Title = s.sessionTitle(r.Context(), entry.Name(), first, mtime.UnixNano())
}
out = append(out, row)
legacyRows = append(legacyRows, row)
legacyByPath[cleanPath] = row
}
canonicalRows := make([]canonicalSessionRow, 0)
if concrete, ok := ctrl.(*control.Controller); ok {
if service := concrete.SessionService(); service != nil {
_, runtime, bound := concrete.SessionBinding()
page, listErr := service.Query().List(r.Context(), "", 100)
if listErr == nil {
canonicalRows = make([]canonicalSessionRow, 0, len(page.Sessions))
for _, info := range page.Sessions {
row := sessionListEntry{
HostID: info.Ref.HostID, SessionID: info.Ref.SessionID, Name: info.SessionID,
Title: info.Title, Turns: info.Turns, MtimeMilli: info.CreatedAt.UnixMilli(),
Current: bound && info.Ref == runtime.Ref(),
Current: bound && info.Ref == runtime.Ref(),
Preview: info.Preview,
MetadataReady: info.MetadataStatus == session.MetadataReady,
}
// Canonical rows carry no legacy preview fallback; without
// one a chatted session lists as an untitled blank until
// the model renames it. Fall back to the first user
// message, mirroring the legacy row's previewTitle.
if strings.TrimSpace(row.Title) == "" && strings.TrimSpace(info.Preview) != "" {
preview := []rune(strings.TrimSpace(info.Preview))
if len(preview) > 50 {
row.Title = string(preview[:47]) + "..."
} else {
row.Title = string(preview)
}
}
if live, exists := service.Runtime(info.Ref); exists {
phase := live.Snapshot().Phase
row.Running = phase.Busy()
}
out = append(out, row)
canonicalRows = append(canonicalRows, canonicalSessionRow{row: row, info: info})
}
}
}
}
// Migration deliberately preserves the old transcript, so a host can
// contain both the source .jsonl and its canonical session directory. The
// source is not a second user-visible session once the migration map proves
// there is exactly one canonical target for it. Keep the canonical row as
// the authoritative open/delete identity, while borrowing the old preview
// title until its asynchronous catalog metadata is ready.
migration := loadMigrationIndex(canonicalRows)
out := make([]sessionListEntry, 0, len(legacyRows)+len(canonicalRows))
for _, row := range legacyRows {
if _, migrated := migration.bySource[agent.CanonicalSessionPath(row.Path)]; migrated {
continue
}
out = append(out, row)
}
for i := range canonicalRows {
row := &canonicalRows[i].row
if source, ok := migration.byTarget[row.SessionID]; ok {
if legacy, exists := legacyByPath[source]; exists {
if row.Title == "" {
row.Title = legacy.Title
}
if row.Turns == 0 {
row.Turns = legacy.Turns
}
if row.MtimeMilli < legacy.MtimeMilli {
row.MtimeMilli = legacy.MtimeMilli
}
}
}
out = append(out, *row)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].MtimeMilli > out[j].MtimeMilli })
writeJSON(w, out)
}
type canonicalSessionRow struct {
row sessionListEntry
info session.SessionInfo
}
type migrationSourceIndex struct {
bySource map[string]struct{}
byTarget map[string]string
}
// loadMigrationIndex returns only unambiguous source->target mappings.
// Multiple canonical targets can legitimately be produced from one legacy DAG
// head, in which case hiding the source would remove a still-distinct view.
func loadMigrationIndex(rows []canonicalSessionRow) migrationSourceIndex {
index := migrationSourceIndex{bySource: map[string]struct{}{}, byTarget: map[string]string{}}
canonicalIDs := make(map[string]struct{}, len(rows))
roots := make(map[string]struct{})
for _, row := range rows {
if row.row.SessionID != "" {
canonicalIDs[row.row.SessionID] = struct{}{}
}
if path := strings.TrimSpace(row.info.Path); path != "" {
roots[filepath.Dir(filepath.Clean(path))] = struct{}{}
}
}
targetsBySource := make(map[string][]string)
for root := range roots {
data, err := os.ReadFile(filepath.Join(root, "migration-map.json"))
if err != nil {
continue
}
var mapping session.MigrationMapping
if json.Unmarshal(data, &mapping) != nil || mapping.SchemaVersion != session.SchemaVersion {
continue
}
for _, entry := range mapping.Entries {
source := agent.CanonicalSessionPath(entry.SourcePath)
target := strings.TrimSpace(entry.TargetID)
if source == "" || target == "" {
continue
}
if _, exists := canonicalIDs[target]; !exists {
continue
}
seen := false
for _, existing := range targetsBySource[source] {
seen = seen || existing == target
}
if !seen {
targetsBySource[source] = append(targetsBySource[source], target)
}
}
}
for source, targets := range targetsBySource {
if len(targets) != 1 {
continue
}
index.bySource[source] = struct{}{}
index.byTarget[targets[0]] = source
}
return index
}
+69 -10
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"reasonix/internal/agent"
"reasonix/internal/control"
@@ -256,8 +257,10 @@ func (s *Server) sessionHistoryLocate(w http.ResponseWriter, r *http.Request) {
// without colliding with a genuine read failure, which must stay a conflict.
var errTranscriptCapabilityMissing = errors.New("transcript capability is missing")
// transcriptRead binds each read to the selected controller. A file mirror
// cannot claim a live event cursor and explicitly declines this protocol.
// transcriptRead binds each read to the controller that owns the referenced
// session: the foreground when the reference is absent or matches it, else
// the detached session holding that identity or path. A file mirror cannot
// claim a live event cursor and explicitly declines this protocol.
func (s *Server) transcriptRead(w http.ResponseWriter, r *http.Request, read func(control.TranscriptProjectionAPI) (any, error)) {
s.transcriptBoundRead(w, r, func(ctrl control.SessionAPI) (any, error) {
api, ok := ctrl.(control.TranscriptProjectionAPI)
@@ -275,17 +278,16 @@ func (s *Server) transcriptRead(w http.ResponseWriter, r *http.Request, read fun
func (s *Server) transcriptBoundRead(w http.ResponseWriter, r *http.Request, read func(control.SessionAPI) (any, error)) {
s.bindMu.Lock()
defer s.bindMu.Unlock()
ctrl := s.ctl()
path := agent.CanonicalSessionPath(ctrl.SessionPath())
if raw := r.URL.Query().Get("session"); raw != "" {
requested, err := s.resolveSessionPath(raw)
if err != nil || agent.CanonicalSessionPath(requested) != path {
http.Error(w, "transcript session is not bound to this runtime", http.StatusConflict)
raw := strings.TrimSpace(r.URL.Query().Get("session"))
if raw != "" && !strings.HasPrefix(raw, remoteSessionIDQueryPrefix) {
if resolved, err := s.resolveSessionPath(raw); err == nil && s.sessionMirrored(agent.CanonicalSessionPath(resolved)) {
http.Error(w, "transcript projection is unavailable", http.StatusNotImplemented)
return
}
}
if s.sessionMirrored(path) {
http.Error(w, "transcript projection is unavailable", http.StatusNotImplemented)
ctrl := s.resolveReadControllerLocked(raw)
if ctrl == nil {
http.Error(w, "transcript session is not bound to this runtime", http.StatusConflict)
return
}
value, err := read(ctrl)
@@ -302,6 +304,63 @@ func (s *Server) transcriptBoundRead(w http.ResponseWriter, r *http.Request, rea
_ = json.NewEncoder(w).Encode(value)
}
// resolveReadControllerLocked returns the controller a transcript read
// targets. An empty reference selects the foreground; an identity or path
// reference selects the foreground when it matches, otherwise the detached
// session holding it. bindMu must be held; detachedMu nests inside it.
func (s *Server) resolveReadControllerLocked(raw string) control.SessionAPI {
foreground := s.ctl()
if raw == "" {
return foreground
}
if id, ok := strings.CutPrefix(raw, remoteSessionIDQueryPrefix); ok {
if controllerBoundToIdentity(foreground, id) {
return foreground
}
s.detachedMu.Lock()
defer s.detachedMu.Unlock()
for _, detached := range s.detached {
if controllerBoundToIdentity(detached.ctrl, id) {
return detached.ctrl
}
}
return nil
}
path := agent.CanonicalSessionPath(raw)
if resolved, err := s.resolveSessionPath(raw); err == nil {
path = agent.CanonicalSessionPath(resolved)
}
if agent.CanonicalSessionPath(foreground.SessionPath()) == path {
return foreground
}
s.detachedMu.Lock()
defer s.detachedMu.Unlock()
for _, detached := range s.detached {
if agent.CanonicalSessionPath(detached.ctrl.SessionPath()) == path {
return detached.ctrl
}
}
return nil
}
// remoteSessionIDQueryPrefix marks a session reference as an identity ID
// rather than a legacy transcript path; it matches the desktop's routing
// prefix for exclusive identity sessions.
const remoteSessionIDQueryPrefix = "session-id:"
// controllerBoundToIdentity reports whether ctrl currently runs the exclusive
// identity session the caller referenced.
func controllerBoundToIdentity(ctrl control.SessionAPI, id string) bool {
ref, ok := ctrl.(interface {
SessionRef() (session.SessionRef, bool)
})
if !ok {
return false
}
bound, has := ref.SessionRef()
return has && bound.SessionID == id
}
func transcriptRequest(w http.ResponseWriter, r *http.Request, dst any) bool {
encoded := r.URL.Query().Get("request")
if encoded == "" {
+74 -6
View File
@@ -44,6 +44,79 @@ func catalogMetadataPath(cacheDir string) string {
return filepath.Join(cacheDir, "catalog-metadata.json")
}
// firstUserPreview returns the first user-authored text suitable as a list
// preview. Host-injected directives (session-context snapshot, reasoning
// language) ride the first user turn: Origin is the authoritative
// provenance, with text recognition only for legacy records recorded before
// the field existed. Injections are also prepended to real user turns, so
// RawContent - the user's exact text - is preferred over Content.
func firstUserPreview(messages []provider.Message) string {
for _, message := range messages {
if message.Role != provider.RoleUser {
continue
}
if message.Origin == provider.MessageOriginHost {
continue
}
text := strings.TrimSpace(message.RawContent)
if text == "" {
text = strings.TrimSpace(message.Content)
}
text = stripLeadingInjectedBlocks(text)
if text == "" {
continue
}
return messagePreview(provider.Message{Content: text})
}
return ""
}
// previewInjectedTags lists the leading block tags hosts prepend to user
// turns. Keep in sync with agent.TransientUserBlockTags; session cannot
// import agent (agent imports session), so the list is duplicated with this
// pointer back.
var previewInjectedTags = []string{"session-context", "reasoning-language", "response-language", "memory-update", "background-jobs", "active-goal", "hook-context", "capability-route", "interrupted-turn-recovery", "execution-policy"}
// stripLeadingInjectedBlocks removes well-formed leading <tag>...</tag>
// blocks the host injected before the user's own words, so a preview never
// leaks host directives regardless of how they were recorded.
func stripLeadingInjectedBlocks(text string) string {
for {
rest, stripped := stripOneLeadingInjectedBlock(text)
if !stripped {
return text
}
text = rest
}
}
func stripOneLeadingInjectedBlock(text string) (string, bool) {
s := strings.TrimLeft(text, " \t\r\n")
for _, tag := range previewInjectedTags {
open := "<" + tag + ">"
openAttr := "<" + tag + " "
var start int
if strings.HasPrefix(s, open) {
start = len(open)
} else if strings.HasPrefix(s, openAttr) {
if i := strings.Index(s, ">"); i >= 0 {
start = i + 1
} else {
continue
}
} else {
continue
}
close := "</" + tag + ">"
end := strings.Index(s[start:], close)
if end < 0 {
continue
}
return strings.TrimLeft(s[start+end+len(close):], " \t\r\n"), true
}
return text, false
}
func metadataFromProjection(manifest Manifest, sequence uint64, projection Projection) catalogMetadata {
metadata := catalogMetadata{
Version: catalogMetadataVersion, Codec: Codec, SessionID: manifest.SessionID,
@@ -55,12 +128,7 @@ func metadataFromProjection(manifest Manifest, sequence uint64, projection Proje
metadata.Turns++
}
}
for _, message := range projection.Messages {
if message.Role == provider.RoleUser && strings.TrimSpace(message.Content) != "" {
metadata.Preview = messagePreview(message)
break
}
}
metadata.Preview = firstUserPreview(projection.Messages)
return metadata
}
+3 -1
View File
@@ -486,7 +486,9 @@ func loadStartupSessionState(ctx context.Context, dir, eventsPath string, extern
if externalHistory {
for _, message := range state.projection.Messages {
if message.Role == provider.RoleUser && state.catalogPreview == "" {
state.catalogPreview = messagePreview(message)
if preview := firstUserPreview([]provider.Message{message}); preview != "" {
state.catalogPreview = preview
}
}
}
state.projection.Messages = nil
@@ -384,3 +384,9 @@ func sectionStat(value string) SectionStat {
func digestOf(body string) string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(body)))
}
// IsSnapshot reports whether content is a host-injected session-context
// snapshot rather than something the user typed.
func IsSnapshot(content string) bool {
return strings.HasPrefix(content, openTag)
}