mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user