mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-26 09:01:15 +00:00
feat(chat): switch between session transcript branches from the chat header (#110857)
* feat(chat): switch between session transcript branches from the chat header * fix(chat): type fixes and protocol regen for branch switcher CI
This commit is contained in:
committed by
GitHub
parent
64019bc0c2
commit
5fdb0cd24e
@@ -303,6 +303,8 @@ enum class GatewayMethod(
|
||||
SessionsCompactionGet("sessions.compaction.get"),
|
||||
SessionsCompactionBranch("sessions.compaction.branch"),
|
||||
SessionsCompactionRestore("sessions.compaction.restore"),
|
||||
SessionsBranchesList("sessions.branches.list"),
|
||||
SessionsBranchesSwitch("sessions.branches.switch"),
|
||||
SessionsRewind("sessions.rewind"),
|
||||
SessionsFork("sessions.fork"),
|
||||
SessionsCreate("sessions.create"),
|
||||
|
||||
@@ -392,7 +392,7 @@ The macOS app keeps its native link-browser sidebar for links clicked in the das
|
||||
- In the macOS app, the OpenClaw mark uses the otherwise-empty native titlebar strip next to the window controls instead of consuming a sidebar row.
|
||||
- On desktop widths, chat controls stay on one compact row and collapse while scrolling down the transcript; scrolling up, returning to the top, or reaching the bottom restores the controls.
|
||||
- Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed.
|
||||
- User-message bubbles carry transcript actions: a hover rewind button (confirm popover with a "Don't ask again" option) plus right-click **Rewind to here** and **Fork from here**. Rewind repoints the session to the state just before that message and returns its text to the composer for edit and resend (`sessions.rewind`, `operator.admin`); fork creates a new session from the active-path prefix before the message, opens it, and seeds its composer with the same text (`sessions.fork`, `operator.write`). Both actions disable with an explanatory tooltip while the agent is working, apply only to persisted user messages, and are rejected for sessions whose conversation is owned by an external agent harness. Rewind moves chat context only — files and other tool side effects are not reverted — and the pre-rewind transcript remains preserved in the append-only session store. The separate hide action on user bubbles hides a message in the current browser only; the message stays in the transcript and the agent still sees it.
|
||||
- User-message bubbles carry transcript actions: a hover rewind button (confirm popover with a "Don't ask again" option) plus right-click **Rewind to here** and **Fork from here**. Rewind repoints the session to the state just before that message and returns its text to the composer for edit and resend (`sessions.rewind`, `operator.admin`); fork creates a new session from the active-path prefix before the message, opens it, and seeds its composer with the same text (`sessions.fork`, `operator.write`). Both actions disable with an explanatory tooltip while the agent is working, apply only to persisted user messages, and are rejected for sessions whose conversation is owned by an external agent harness. Rewind moves chat context only — files and other tool side effects are not reverted — and the pre-rewind transcript remains preserved in the append-only session store. When that store contains multiple transcript branches, the chat title bar shows a branch menu with each branch's latest message, message count, and recency; selecting an inactive branch switches the current session back to that preserved path (`sessions.branches.list`, `operator.read`; `sessions.branches.switch`, `operator.admin`). Branch switching is also unavailable while the agent is working, and selecting the already-active branch is a typed no-op error at the RPC boundary. The separate hide action on user bubbles hides a message in the current browser only; the message stays in the transcript and the agent still sees it.
|
||||
- When a session's checkout sits on a non-default branch of a GitHub repository, the chat view pins pull request chips above the composer: PR number, repo, branch, diff counts, a CI pill, and draft/merged/closed state, each linking to the PR. The row shows at most two chips — live (open/draft) PRs first — and a "Show more" button reveals collapsed merged/closed history. The CI pill opens a small CI monitoring popover with passed/failed/running/skipped check counts and a link to the PR's checks page. Detection runs server-side through `controlUi.sessionPullRequests`, which reuses the Gateway's `GH_TOKEN`/`GITHUB_TOKEN` when set. When the GitHub API rate limit is hit, chips keep the last known status and show a warning that the status may be out of date; dismissing a chip hides it for that session in the current browser profile. Before any PR exists, the row shows the branch itself — repo, branch name, and the +/− size of the diff against the default-branch merge base (committed and uncommitted work). Once the pushed branch has commits to compare, the row adds a Create PR button that opens GitHub's new-pull-request page; before that, a session with changed files (committed, uncommitted, or untracked) still gets the row without the button. The row hides itself while an open or draft PR exists. The branch row comes from local git only, so it stays available while GitHub is rate limited and carries the same stale-status warning, since "no PR found" cannot be trusted until the limit resets.
|
||||
- The session diff panel shows what a session's checkout actually changed: the branch button in the workspace rail or chat title bar opens the detail panel with a per-file diff of branch, uncommitted, and untracked work against the checkout's default-branch merge base — status dot, rename arrow, per-file +/− counts, collapsible files, and "N unmodified lines" markers between hunks. Diffs are computed server-side through the `sessions.diff` Gateway method (`operator.read` scope); binary and oversized files degrade to stats-only entries, and the button only appears when the connected Gateway advertises `sessions.diff`.
|
||||
- Every Chat pane has a title bar. Click the session title to rename it; the workspace chip copies the checkout path or branch and can reveal local Gateway workspaces in the host file manager. Remote and exec-node sessions keep copy actions but hide reveal.
|
||||
|
||||
@@ -358,6 +358,7 @@ import {
|
||||
SendParamsSchema,
|
||||
SecretsResolveParamsSchema,
|
||||
SecretsResolveResultSchema,
|
||||
SessionBranchSchema,
|
||||
SessionsAbortParamsSchema,
|
||||
SessionsCompactParamsSchema,
|
||||
SessionsCleanupParamsSchema,
|
||||
@@ -365,6 +366,10 @@ import {
|
||||
SessionsCompactionGetParamsSchema,
|
||||
SessionsCompactionListParamsSchema,
|
||||
SessionsCompactionRestoreParamsSchema,
|
||||
SessionsBranchesListParamsSchema,
|
||||
SessionsBranchesListResultSchema,
|
||||
SessionsBranchesSwitchParamsSchema,
|
||||
SessionsBranchesSwitchResultSchema,
|
||||
SessionsForkParamsSchema,
|
||||
SessionsForkResultSchema,
|
||||
SessionsRewindParamsSchema,
|
||||
@@ -725,6 +730,8 @@ export const validateSessionsCompactionBranchParams = lazyCompile(
|
||||
export const validateSessionsCompactionRestoreParams = lazyCompile(
|
||||
SessionsCompactionRestoreParamsSchema,
|
||||
);
|
||||
export const validateSessionsBranchesListParams = lazyCompile(SessionsBranchesListParamsSchema);
|
||||
export const validateSessionsBranchesSwitchParams = lazyCompile(SessionsBranchesSwitchParamsSchema);
|
||||
export const validateSessionsRewindParams = lazyCompile(SessionsRewindParamsSchema);
|
||||
export const validateSessionsForkParams = lazyCompile(SessionsForkParamsSchema);
|
||||
export const validateSessionsUsageParams = lazyCompile(SessionsUsageParamsSchema);
|
||||
@@ -1078,6 +1085,11 @@ export {
|
||||
SessionsCompactionGetParamsSchema,
|
||||
SessionsCompactionBranchParamsSchema,
|
||||
SessionsCompactionRestoreParamsSchema,
|
||||
SessionBranchSchema,
|
||||
SessionsBranchesListParamsSchema,
|
||||
SessionsBranchesListResultSchema,
|
||||
SessionsBranchesSwitchParamsSchema,
|
||||
SessionsBranchesSwitchResultSchema,
|
||||
SessionsForkParamsSchema,
|
||||
SessionsForkResultSchema,
|
||||
SessionsRewindParamsSchema,
|
||||
@@ -1687,6 +1699,11 @@ export type {
|
||||
SessionsReclaimParams,
|
||||
SessionsReclaimResult,
|
||||
SessionsCreateResult,
|
||||
SessionBranch,
|
||||
SessionsBranchesListParams,
|
||||
SessionsBranchesListResult,
|
||||
SessionsBranchesSwitchParams,
|
||||
SessionsBranchesSwitchResult,
|
||||
SessionsForkParams,
|
||||
SessionsForkResult,
|
||||
SessionsRewindParams,
|
||||
|
||||
@@ -548,6 +548,33 @@ export const SessionsForkResultSchema = closedObject({
|
||||
editorText: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
export const SessionBranchSchema = closedObject({
|
||||
leafEntryId: NonEmptyString,
|
||||
headline: Type.String(),
|
||||
messageCount: Type.Integer({ minimum: 0 }),
|
||||
updatedAt: Type.Optional(NonEmptyString),
|
||||
active: Type.Boolean(),
|
||||
});
|
||||
|
||||
/** Lists transcript DAG tips available for branch switching. */
|
||||
export const SessionsBranchesListParamsSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
});
|
||||
|
||||
export const SessionsBranchesListResultSchema = closedObject({
|
||||
branches: Type.Array(SessionBranchSchema),
|
||||
});
|
||||
|
||||
/** Repoints the active transcript path to one existing DAG tip. */
|
||||
export const SessionsBranchesSwitchParamsSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
leafEntryId: NonEmptyString,
|
||||
});
|
||||
|
||||
export const SessionsBranchesSwitchResultSchema = closedObject({});
|
||||
|
||||
/** List response for session compaction checkpoints. */
|
||||
export const SessionsCompactionListResultSchema = closedObject({
|
||||
ok: Type.Literal(true),
|
||||
@@ -657,6 +684,11 @@ export type SessionsRewindParams = Static<typeof SessionsRewindParamsSchema>;
|
||||
export type SessionsForkParams = Static<typeof SessionsForkParamsSchema>;
|
||||
export type SessionsRewindResult = Static<typeof SessionsRewindResultSchema>;
|
||||
export type SessionsForkResult = Static<typeof SessionsForkResultSchema>;
|
||||
export type SessionBranch = Static<typeof SessionBranchSchema>;
|
||||
export type SessionsBranchesListParams = Static<typeof SessionsBranchesListParamsSchema>;
|
||||
export type SessionsBranchesListResult = Static<typeof SessionsBranchesListResultSchema>;
|
||||
export type SessionsBranchesSwitchParams = Static<typeof SessionsBranchesSwitchParamsSchema>;
|
||||
export type SessionsBranchesSwitchResult = Static<typeof SessionsBranchesSwitchResultSchema>;
|
||||
export type SessionWorktreeInfo = Static<typeof SessionWorktreeInfoSchema>;
|
||||
export type SessionsCreateParams = Static<typeof SessionsCreateParamsSchema>;
|
||||
export type SessionsCreateResult = Static<typeof SessionsCreateResultSchema>;
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import {
|
||||
forkSqliteSessionAtMessage,
|
||||
listSqliteSessionBranches,
|
||||
rewindSqliteSessionToMessage,
|
||||
switchSqliteSessionBranch,
|
||||
} from "./session-accessor.sqlite.js";
|
||||
import type {
|
||||
SessionBranchListParams,
|
||||
SessionBranchListResult,
|
||||
SessionBranchSwitchMutationParams,
|
||||
SessionBranchSwitchMutationResult,
|
||||
SessionMessageCutMutationParams,
|
||||
SessionMessageCutMutationResult,
|
||||
} from "./session-accessor.types.js";
|
||||
|
||||
export async function listSessionBranches(
|
||||
params: SessionBranchListParams,
|
||||
): Promise<SessionBranchListResult> {
|
||||
return await listSqliteSessionBranches(params);
|
||||
}
|
||||
|
||||
export async function rewindSessionToMessage(
|
||||
params: SessionMessageCutMutationParams,
|
||||
): Promise<SessionMessageCutMutationResult> {
|
||||
@@ -18,3 +30,9 @@ export async function forkSessionAtMessage(
|
||||
): Promise<SessionMessageCutMutationResult> {
|
||||
return await forkSqliteSessionAtMessage(params);
|
||||
}
|
||||
|
||||
export async function switchSessionBranch(
|
||||
params: SessionBranchSwitchMutationParams,
|
||||
): Promise<SessionBranchSwitchMutationResult> {
|
||||
return await switchSqliteSessionBranch(params);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
appendTranscriptEvent,
|
||||
appendTranscriptMessage,
|
||||
forkSessionAtMessage,
|
||||
listSessionBranches,
|
||||
loadSessionEntry,
|
||||
loadTranscriptEvents,
|
||||
readSessionTranscriptMessageEventCount,
|
||||
readSessionTranscriptMessageEvents,
|
||||
rewindSessionToMessage,
|
||||
switchSessionBranch,
|
||||
upsertSessionEntry,
|
||||
} from "./session-accessor.js";
|
||||
|
||||
@@ -22,7 +25,7 @@ afterEach(() => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
});
|
||||
|
||||
async function createSession() {
|
||||
async function createSession(options: { activeLeafTarget?: string } = {}) {
|
||||
const stateDir = tempDirs.make("openclaw-message-cut-");
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
const sessionId = "message-cut-source";
|
||||
@@ -86,13 +89,14 @@ async function createSession() {
|
||||
id: "active-leaf",
|
||||
parentId: "off-path-user",
|
||||
timestamp: "2026-07-18T00:00:06.000Z",
|
||||
targetId: "assistant-2",
|
||||
targetId: options.activeLeafTarget ?? "assistant-2",
|
||||
},
|
||||
]) {
|
||||
if (event.type === "message") {
|
||||
await appendTranscriptMessage(scope, {
|
||||
eventId: event.id,
|
||||
message: event.message,
|
||||
now: Date.parse(event.timestamp),
|
||||
parentId: event.parentId,
|
||||
});
|
||||
} else {
|
||||
@@ -103,6 +107,80 @@ async function createSession() {
|
||||
}
|
||||
|
||||
describe("SQLite session message cuts", () => {
|
||||
it("lists every DAG tip with active state, headline, count, and timestamp", async () => {
|
||||
const { env } = await createSession({ activeLeafTarget: "assistant-1" });
|
||||
|
||||
await expect(listSessionBranches({ agentId, env, sessionKey })).resolves.toEqual({
|
||||
status: "ok",
|
||||
branches: [
|
||||
{
|
||||
leafEntryId: "assistant-1",
|
||||
headline: "first answer",
|
||||
messageCount: 2,
|
||||
updatedAt: "2026-07-18T00:00:02.000Z",
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
leafEntryId: "off-path-user",
|
||||
headline: "inactive prompt",
|
||||
messageCount: 2,
|
||||
updatedAt: "2026-07-18T00:00:05.000Z",
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
leafEntryId: "assistant-2",
|
||||
headline: "second answer",
|
||||
messageCount: 4,
|
||||
updatedAt: "2026-07-18T00:00:04.000Z",
|
||||
active: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("switches to another tip and rebuilds the active-path projection", async () => {
|
||||
const { env } = await createSession();
|
||||
|
||||
const result = await switchSessionBranch({
|
||||
agentId,
|
||||
env,
|
||||
leafEntryId: "off-path-user",
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ status: "created", key: sessionKey });
|
||||
if (result.status !== "created") {
|
||||
throw new Error("expected branch switch result");
|
||||
}
|
||||
const activeEventIds = readSessionTranscriptMessageEvents({
|
||||
agentId,
|
||||
env,
|
||||
sessionId: result.entry.sessionId,
|
||||
sessionKey,
|
||||
}).map(({ event }) =>
|
||||
event && typeof event === "object" && "id" in event ? event.id : undefined,
|
||||
);
|
||||
expect(activeEventIds).toEqual(["user-1", "off-path-user"]);
|
||||
expect(result.entry).toMatchObject({
|
||||
agentHarnessId: undefined,
|
||||
claudeCliSessionId: undefined,
|
||||
cliSessionBindings: undefined,
|
||||
cliSessionIds: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["unknown", "missing-entry"],
|
||||
["user-1", "not-branch-tip"],
|
||||
["assistant-2", "already-active"],
|
||||
])("rejects branch switch target %s with %s", async (leafEntryId, status) => {
|
||||
const { env } = await createSession();
|
||||
|
||||
await expect(
|
||||
switchSessionBranch({ agentId, env, leafEntryId, sessionKey }),
|
||||
).resolves.toMatchObject({ status });
|
||||
});
|
||||
|
||||
it("rewinds by repointing the active leaf and returns the editor text", async () => {
|
||||
const { env } = await createSession();
|
||||
|
||||
@@ -228,5 +306,11 @@ describe("SQLite session message cuts", () => {
|
||||
await expect(
|
||||
rewindSessionToMessage({ agentId, env, entryId: "user-2", sessionKey }),
|
||||
).resolves.toMatchObject({ status: "unsupported-storage" });
|
||||
await expect(listSessionBranches({ agentId, env, sessionKey })).resolves.toMatchObject({
|
||||
status: "unsupported-storage",
|
||||
});
|
||||
await expect(
|
||||
switchSessionBranch({ agentId, env, leafEntryId: "assistant-2", sessionKey }),
|
||||
).resolves.toMatchObject({ status: "unsupported-storage" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { extractAssistantVisibleText } from "../../shared/chat-message-content.js";
|
||||
import {
|
||||
openOpenClawAgentDatabase,
|
||||
runOpenClawAgentWriteTransaction,
|
||||
type OpenClawAgentDatabase,
|
||||
} from "../../state/openclaw-agent-db.js";
|
||||
@@ -23,6 +25,11 @@ import {
|
||||
} from "./session-accessor.sqlite-scope.js";
|
||||
import { appendTranscriptEventsInTransaction } from "./session-accessor.sqlite-transcript-store.js";
|
||||
import type {
|
||||
SessionBranchListParams,
|
||||
SessionBranchListResult,
|
||||
SessionBranchSummary,
|
||||
SessionBranchSwitchMutationParams,
|
||||
SessionBranchSwitchMutationResult,
|
||||
SessionMessageCutMutationParams,
|
||||
SessionMessageCutMutationResult,
|
||||
} from "./session-accessor.types.js";
|
||||
@@ -31,8 +38,10 @@ import { reconcileSessionTranscriptIndexInTransaction } from "./session-transcri
|
||||
import { parseSqliteSessionFileMarker } from "./sqlite-marker.js";
|
||||
import { createSessionTranscriptHeader } from "./transcript-header.js";
|
||||
import {
|
||||
isSessionTranscriptLeafControl,
|
||||
scanSessionTranscriptTree,
|
||||
selectSessionTranscriptTreePathNodes,
|
||||
type SessionTranscriptTree,
|
||||
} from "./transcript-tree.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
@@ -42,6 +51,43 @@ type MessageCut = {
|
||||
prefix: TranscriptEvent[];
|
||||
};
|
||||
|
||||
type SessionTranscriptMutationResult =
|
||||
| SessionMessageCutMutationResult
|
||||
| SessionBranchSwitchMutationResult;
|
||||
|
||||
type SessionTranscriptMutationMode = "fork" | "rewind" | "switch";
|
||||
|
||||
const BRANCH_HEADLINE_MAX_CHARS = 120;
|
||||
|
||||
export async function listSqliteSessionBranches(
|
||||
params: SessionBranchListParams,
|
||||
): Promise<SessionBranchListResult> {
|
||||
const sourceKey = normalizeSqliteSessionKey(params.sessionStoreKey ?? params.sessionKey);
|
||||
const resolved = resolveSqliteScope({
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
sessionKey: sourceKey,
|
||||
...(params.storePath ? { storePath: params.storePath } : {}),
|
||||
});
|
||||
try {
|
||||
const database = openOpenClawAgentDatabase(toDatabaseOptions(resolved));
|
||||
const currentEntry = readSessionEntryRow(database, sourceKey)?.entry;
|
||||
if (!currentEntry?.sessionId) {
|
||||
return { status: "missing-session" };
|
||||
}
|
||||
if (
|
||||
currentEntry.sessionFile?.trim() &&
|
||||
!parseSqliteSessionFileMarker(currentEntry.sessionFile)
|
||||
) {
|
||||
return { status: "unsupported-storage" };
|
||||
}
|
||||
const events = loadSqliteTranscriptEventsFromDatabase(database, currentEntry.sessionId);
|
||||
return { status: "ok", branches: summarizeSessionBranches(events) };
|
||||
} catch {
|
||||
return { status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function rewindSqliteSessionToMessage(
|
||||
params: SessionMessageCutMutationParams,
|
||||
): Promise<SessionMessageCutMutationResult> {
|
||||
@@ -54,16 +100,29 @@ export async function forkSqliteSessionAtMessage(
|
||||
return await mutateSqliteSessionAtMessage(params, "fork");
|
||||
}
|
||||
|
||||
async function mutateSqliteSessionAtMessage(
|
||||
export async function switchSqliteSessionBranch(
|
||||
params: SessionBranchSwitchMutationParams,
|
||||
): Promise<SessionBranchSwitchMutationResult> {
|
||||
return await mutateSqliteSessionAtMessage({ ...params, entryId: params.leafEntryId }, "switch");
|
||||
}
|
||||
|
||||
function mutateSqliteSessionAtMessage(
|
||||
params: SessionMessageCutMutationParams,
|
||||
mode: "fork" | "rewind",
|
||||
): Promise<SessionMessageCutMutationResult> {
|
||||
): Promise<SessionMessageCutMutationResult>;
|
||||
function mutateSqliteSessionAtMessage(
|
||||
params: SessionMessageCutMutationParams,
|
||||
mode: "switch",
|
||||
): Promise<SessionBranchSwitchMutationResult>;
|
||||
|
||||
async function mutateSqliteSessionAtMessage(
|
||||
params: SessionMessageCutMutationParams,
|
||||
mode: SessionTranscriptMutationMode,
|
||||
): Promise<SessionTranscriptMutationResult> {
|
||||
const canonicalSourceKey = normalizeSqliteSessionKey(params.sessionKey);
|
||||
const sourceKey = normalizeSqliteSessionKey(params.sessionStoreKey ?? params.sessionKey);
|
||||
const targetKey =
|
||||
mode === "rewind"
|
||||
? sourceKey
|
||||
: normalizeSqliteSessionKey(params.targetKey ?? params.sessionKey);
|
||||
mode === "fork" ? normalizeSqliteSessionKey(params.targetKey ?? params.sessionKey) : sourceKey;
|
||||
const resolved = resolveSqliteScope({
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
@@ -71,7 +130,7 @@ async function mutateSqliteSessionAtMessage(
|
||||
...(params.storePath ? { storePath: params.storePath } : {}),
|
||||
});
|
||||
return await runExclusiveSqliteSessionWrite(resolved, async () => {
|
||||
let result: SessionMessageCutMutationResult = { status: "failed" };
|
||||
let result: SessionTranscriptMutationResult = { status: "failed" };
|
||||
let previousIdentity = new Map<string, SessionEntry>();
|
||||
let currentIdentity = new Map<string, SessionEntry>();
|
||||
runOpenClawAgentWriteTransaction((database) => {
|
||||
@@ -100,11 +159,11 @@ function mutateSqliteSessionAtMessageInTransaction(
|
||||
params: {
|
||||
canonicalSourceKey: string;
|
||||
entryId: string;
|
||||
mode: "fork" | "rewind";
|
||||
mode: SessionTranscriptMutationMode;
|
||||
sourceKey: string;
|
||||
targetKey: string;
|
||||
},
|
||||
): SessionMessageCutMutationResult {
|
||||
): SessionTranscriptMutationResult {
|
||||
const currentEntry = readSessionEntryRow(database, params.sourceKey)?.entry;
|
||||
if (!currentEntry?.sessionId) {
|
||||
return { status: "missing-session" };
|
||||
@@ -113,10 +172,16 @@ function mutateSqliteSessionAtMessageInTransaction(
|
||||
return { status: "unsupported-storage" };
|
||||
}
|
||||
const events = loadSqliteTranscriptEventsFromDatabase(database, currentEntry.sessionId);
|
||||
const cut = resolveMessageCut(events, params.entryId);
|
||||
if ("status" in cut) {
|
||||
const cut = params.mode === "switch" ? undefined : resolveMessageCut(events, params.entryId);
|
||||
if (cut && "status" in cut) {
|
||||
return cut;
|
||||
}
|
||||
if (params.mode === "switch") {
|
||||
const tipStatus = validateBranchTip(events, params.entryId);
|
||||
if (tipStatus) {
|
||||
return { status: tipStatus };
|
||||
}
|
||||
}
|
||||
|
||||
const nextSessionId = randomUUID();
|
||||
const targetScope = {
|
||||
@@ -130,7 +195,7 @@ function mutateSqliteSessionAtMessageInTransaction(
|
||||
sessionId: nextSessionId,
|
||||
});
|
||||
const nextEvents =
|
||||
params.mode === "fork"
|
||||
params.mode === "fork" && cut && !("status" in cut)
|
||||
? [header, ...cut.prefix]
|
||||
: [
|
||||
header,
|
||||
@@ -140,11 +205,11 @@ function mutateSqliteSessionAtMessageInTransaction(
|
||||
id: uniqueEntryId(events),
|
||||
parentId: readLastEventId(events),
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId: cut.parentId,
|
||||
targetId: params.mode === "switch" ? params.entryId : (cut?.parentId ?? null),
|
||||
},
|
||||
];
|
||||
appendTranscriptEventsInTransaction(database, targetScope, nextEvents);
|
||||
if (params.mode === "rewind") {
|
||||
if (params.mode !== "fork") {
|
||||
reconcileSessionTranscriptIndexInTransaction(database.db, nextSessionId);
|
||||
}
|
||||
|
||||
@@ -162,10 +227,95 @@ function mutateSqliteSessionAtMessageInTransaction(
|
||||
status: "created",
|
||||
key: params.targetKey,
|
||||
entry: nextEntry,
|
||||
...(cut.editorText ? { editorText: cut.editorText } : {}),
|
||||
...(cut && !("status" in cut) && cut.editorText ? { editorText: cut.editorText } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateBranchTip(
|
||||
events: readonly TranscriptEvent[],
|
||||
entryId: string,
|
||||
): "missing-entry" | "not-branch-tip" | "already-active" | undefined {
|
||||
const tree = scanSessionTranscriptTree(events);
|
||||
const target = tree.byId.get(entryId);
|
||||
if (!target) {
|
||||
return "missing-entry";
|
||||
}
|
||||
if (isSessionTranscriptLeafControl(target.entry)) {
|
||||
return "not-branch-tip";
|
||||
}
|
||||
if (!sessionBranchTipNodes(tree).some((node) => node.id === entryId)) {
|
||||
return "not-branch-tip";
|
||||
}
|
||||
return tree.leafId === entryId ? "already-active" : undefined;
|
||||
}
|
||||
|
||||
function summarizeSessionBranches(events: readonly TranscriptEvent[]): SessionBranchSummary[] {
|
||||
const tree = scanSessionTranscriptTree(events);
|
||||
return sessionBranchTipNodes(tree)
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
Number(right.id === tree.leafId) - Number(left.id === tree.leafId) ||
|
||||
right.index - left.index,
|
||||
)
|
||||
.map((node) => summarizeSessionBranch(tree, node.id));
|
||||
}
|
||||
|
||||
function sessionBranchTipNodes(tree: SessionTranscriptTree<TranscriptEvent>) {
|
||||
const referencedParents = new Set(
|
||||
tree.nodes.flatMap((node) =>
|
||||
isSessionTranscriptLeafControl(node.entry) || node.parentId === null ? [] : [node.parentId],
|
||||
),
|
||||
);
|
||||
return tree.nodes.filter(
|
||||
(node) =>
|
||||
!isSessionTranscriptLeafControl(node.entry) &&
|
||||
(node.id === tree.leafId || !referencedParents.has(node.id)),
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeSessionBranch(
|
||||
tree: SessionTranscriptTree<TranscriptEvent>,
|
||||
leafEntryId: string,
|
||||
): SessionBranchSummary {
|
||||
const path = selectSessionTranscriptTreePathNodes(tree, leafEntryId);
|
||||
const messages = path.flatMap((node) => {
|
||||
const record = asRecord(node.entry);
|
||||
return record?.type === "message" ? [record] : [];
|
||||
});
|
||||
const headline = messages
|
||||
.toReversed()
|
||||
.map((record) => extractHeadlineText(record.message))
|
||||
.find((value): value is string => value !== undefined);
|
||||
const timestamp = asRecord(tree.byId.get(leafEntryId)?.entry)?.timestamp;
|
||||
return {
|
||||
leafEntryId,
|
||||
headline: truncateBranchHeadline(headline ?? ""),
|
||||
messageCount: messages.length,
|
||||
...(typeof timestamp === "string" && timestamp.trim() ? { updatedAt: timestamp } : {}),
|
||||
active: tree.leafId === leafEntryId,
|
||||
};
|
||||
}
|
||||
|
||||
function extractHeadlineText(messageValue: unknown): string | undefined {
|
||||
const message = asRecord(messageValue);
|
||||
if (message?.role !== "user" && message?.role !== "assistant") {
|
||||
return undefined;
|
||||
}
|
||||
const text =
|
||||
message.role === "assistant"
|
||||
? extractAssistantVisibleText(message)
|
||||
: extractEditorText(message.content ?? message.text);
|
||||
const normalized = text?.replace(/\s+/g, " ").trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function truncateBranchHeadline(value: string): string {
|
||||
const characters = Array.from(value);
|
||||
return characters.length <= BRANCH_HEADLINE_MAX_CHARS
|
||||
? value
|
||||
: `${characters.slice(0, BRANCH_HEADLINE_MAX_CHARS - 1).join("")}…`;
|
||||
}
|
||||
|
||||
function resolveMessageCut(
|
||||
events: readonly TranscriptEvent[],
|
||||
entryId: string,
|
||||
|
||||
@@ -40,7 +40,9 @@ export {
|
||||
} from "./session-accessor.sqlite-checkpoint.js";
|
||||
export {
|
||||
forkSqliteSessionAtMessage,
|
||||
listSqliteSessionBranches,
|
||||
rewindSqliteSessionToMessage,
|
||||
switchSqliteSessionBranch,
|
||||
} from "./session-accessor.sqlite-message-cut.js";
|
||||
export {
|
||||
appendSqliteExpectedSessionTranscriptTurn,
|
||||
|
||||
@@ -41,6 +41,11 @@ export type {
|
||||
SessionCompactionCheckpointMutationResult,
|
||||
SessionMessageCutMutationParams,
|
||||
SessionMessageCutMutationResult,
|
||||
SessionBranchListParams,
|
||||
SessionBranchListResult,
|
||||
SessionBranchSummary,
|
||||
SessionBranchSwitchMutationParams,
|
||||
SessionBranchSwitchMutationResult,
|
||||
SessionCompactionCheckpointTranscriptForkResult,
|
||||
SessionCompactionCheckpointTranscriptForker,
|
||||
SessionEntryCandidateAccessScope,
|
||||
@@ -161,7 +166,12 @@ export {
|
||||
rollbackAgentHarnessSessionEntryLifecycle,
|
||||
rollbackPluginOwnedSessionEntryLifecycle,
|
||||
} from "./session-accessor.lifecycle.js";
|
||||
export { forkSessionAtMessage, rewindSessionToMessage } from "./session-accessor.message-cut.js";
|
||||
export {
|
||||
forkSessionAtMessage,
|
||||
listSessionBranches,
|
||||
rewindSessionToMessage,
|
||||
switchSessionBranch,
|
||||
} from "./session-accessor.message-cut.js";
|
||||
export {
|
||||
commitReplySessionInitialization,
|
||||
loadReplySessionInitializationSnapshot,
|
||||
|
||||
@@ -673,6 +673,45 @@ export type SessionMessageCutMutationParams = {
|
||||
targetKey?: string;
|
||||
};
|
||||
|
||||
export type SessionBranchSummary = {
|
||||
leafEntryId: string;
|
||||
headline: string;
|
||||
messageCount: number;
|
||||
updatedAt?: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type SessionBranchListResult =
|
||||
| { status: "ok"; branches: SessionBranchSummary[] }
|
||||
| { status: "missing-session" }
|
||||
| { status: "unsupported-storage" }
|
||||
| { status: "failed" };
|
||||
|
||||
export type SessionBranchListParams = Pick<
|
||||
SessionMessageCutMutationParams,
|
||||
"agentId" | "env" | "sessionKey" | "sessionStoreKey" | "storePath"
|
||||
>;
|
||||
|
||||
export type SessionBranchSwitchMutationResult =
|
||||
| {
|
||||
status: "created";
|
||||
key: string;
|
||||
entry: SessionEntry;
|
||||
}
|
||||
| { status: "missing-session" }
|
||||
| { status: "missing-entry" }
|
||||
| { status: "not-branch-tip" }
|
||||
| { status: "already-active" }
|
||||
| { status: "unsupported-storage" }
|
||||
| { status: "failed" };
|
||||
|
||||
export type SessionBranchSwitchMutationParams = Omit<
|
||||
SessionMessageCutMutationParams,
|
||||
"entryId" | "targetKey"
|
||||
> & {
|
||||
leafEntryId: string;
|
||||
};
|
||||
|
||||
export type SessionCompactionCheckpointEntryBuildContext = {
|
||||
/** Checkpoint row selected from the current persisted session entry. */
|
||||
checkpoint: SessionCompactionCheckpoint;
|
||||
|
||||
@@ -196,6 +196,8 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
{ name: "sessions.compaction.get", scope: "operator.read" },
|
||||
{ name: "sessions.compaction.branch", scope: "operator.write" },
|
||||
{ name: "sessions.compaction.restore", scope: "operator.admin" },
|
||||
{ name: "sessions.branches.list", scope: "operator.read" },
|
||||
{ name: "sessions.branches.switch", scope: "operator.admin" },
|
||||
{ name: "sessions.rewind", scope: "operator.admin" },
|
||||
{ name: "sessions.fork", scope: "operator.write" },
|
||||
// Params-aware: explicit cwd can point at any host checkout and requires admin.
|
||||
|
||||
@@ -647,6 +647,8 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
"sessions.create",
|
||||
"sessions.compaction.branch",
|
||||
"sessions.compaction.restore",
|
||||
"sessions.branches.list",
|
||||
"sessions.branches.switch",
|
||||
"sessions.rewind",
|
||||
"sessions.fork",
|
||||
"sessions.send",
|
||||
|
||||
@@ -101,14 +101,27 @@ function context(): GatewayRequestContext {
|
||||
} as unknown as GatewayRequestContext;
|
||||
}
|
||||
|
||||
async function invoke(method: "sessions.fork" | "sessions.rewind", entryId: string) {
|
||||
type MessageCutMethod =
|
||||
| "sessions.branches.list"
|
||||
| "sessions.branches.switch"
|
||||
| "sessions.fork"
|
||||
| "sessions.rewind";
|
||||
|
||||
async function invoke(method: MessageCutMethod, entryId?: string) {
|
||||
const respond = vi.fn() as unknown as RespondFn;
|
||||
await expectDefined(
|
||||
sessionsHandlers[method],
|
||||
`${method} handler`,
|
||||
)({
|
||||
req: { id: `${method}-request` } as never,
|
||||
params: { sessionKey, entryId },
|
||||
params: {
|
||||
sessionKey,
|
||||
...(method === "sessions.branches.switch"
|
||||
? { leafEntryId: entryId }
|
||||
: method === "sessions.branches.list"
|
||||
? {}
|
||||
: { entryId }),
|
||||
},
|
||||
respond,
|
||||
context: context(),
|
||||
client: null,
|
||||
@@ -118,6 +131,50 @@ async function invoke(method: "sessions.fork" | "sessions.rewind", entryId: stri
|
||||
}
|
||||
|
||||
describe("session message-cut methods", () => {
|
||||
it("lists branches and switches to an inactive tip", async () => {
|
||||
const listed = await invoke("sessions.branches.list");
|
||||
expect(listed).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
branches: [
|
||||
expect.objectContaining({
|
||||
leafEntryId: "assistant-entry",
|
||||
headline: "answer",
|
||||
messageCount: 2,
|
||||
active: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
leafEntryId: "off-path-entry",
|
||||
headline: "inactive",
|
||||
messageCount: 1,
|
||||
active: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
const switched = await invoke("sessions.branches.switch", "off-path-entry");
|
||||
expect(switched).toHaveBeenCalledWith(true, {}, undefined);
|
||||
expect(mocks.queueClear).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing", "branch entry not found"],
|
||||
["user-entry", "entry is not a branch tip"],
|
||||
["assistant-entry", "branch is already active"],
|
||||
])("rejects invalid branch switch target %s", async (entryId, message) => {
|
||||
const respond = await invoke("sessions.branches.switch", entryId);
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: expect.stringContaining(message),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns editor text for rewind and a new key for fork", async () => {
|
||||
const fork = await invoke("sessions.fork", "user-entry");
|
||||
expect(fork).toHaveBeenCalledWith(
|
||||
@@ -150,16 +207,19 @@ describe("session message-cut methods", () => {
|
||||
|
||||
it("rejects externally owned conversations", async () => {
|
||||
mocks.external = true;
|
||||
const respond = await invoke("sessions.rewind", "user-entry");
|
||||
const respond = await invoke("sessions.branches.switch", "off-path-entry");
|
||||
const listed = await invoke("sessions.branches.list");
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: expect.stringContaining("external agent harness"),
|
||||
}),
|
||||
);
|
||||
for (const response of [respond, listed]) {
|
||||
expect(response).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: expect.stringContaining("external agent harness"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a typed error for unsupported transcript storage", async () => {
|
||||
@@ -170,6 +230,7 @@ describe("session message-cut methods", () => {
|
||||
},
|
||||
);
|
||||
const respond = await invoke("sessions.rewind", "user-entry");
|
||||
const listed = await invoke("sessions.branches.list");
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
@@ -179,14 +240,26 @@ describe("session message-cut methods", () => {
|
||||
message: expect.stringContaining("storage does not support rewind"),
|
||||
}),
|
||||
);
|
||||
expect(listed).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: expect.stringContaining("storage does not support branch listing"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["sessions.fork", "Fork"],
|
||||
["sessions.rewind", "Rewind"],
|
||||
["sessions.branches.switch", "Branch switch"],
|
||||
] as const)("rejects %s while the source run is active", async (method, label) => {
|
||||
mocks.active = true;
|
||||
const respond = await invoke(method, "user-entry");
|
||||
const respond = await invoke(
|
||||
method,
|
||||
method === "sessions.branches.switch" ? "off-path-entry" : "user-entry",
|
||||
);
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
validateSessionsBranchesListParams,
|
||||
validateSessionsBranchesSwitchParams,
|
||||
validateSessionsForkParams,
|
||||
validateSessionsRewindParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
@@ -8,7 +10,11 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js";
|
||||
import {
|
||||
forkSessionAtMessage,
|
||||
listSessionBranches,
|
||||
rewindSessionToMessage,
|
||||
switchSessionBranch,
|
||||
type SessionBranchListResult,
|
||||
type SessionBranchSwitchMutationResult,
|
||||
type SessionMessageCutMutationResult,
|
||||
} from "../../config/sessions/session-accessor.js";
|
||||
import {
|
||||
@@ -32,12 +38,38 @@ import {
|
||||
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
type MessageCutAction = "fork" | "rewind";
|
||||
type MessageCutAction = "fork" | "rewind" | "switch";
|
||||
|
||||
const EXTERNAL_CONVERSATION_ERROR =
|
||||
"Rewind and fork are unavailable because this session is owned by an external agent harness.";
|
||||
"Session history changes are unavailable because this session is owned by an external agent harness.";
|
||||
|
||||
export const sessionRewindHandlers: GatewayRequestHandlers = {
|
||||
"sessions.branches.list": async (options) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
options.params,
|
||||
validateSessionsBranchesListParams,
|
||||
"sessions.branches.list",
|
||||
options.respond,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await listBranches(options);
|
||||
},
|
||||
"sessions.branches.switch": async (options) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
options.params,
|
||||
validateSessionsBranchesSwitchParams,
|
||||
"sessions.branches.switch",
|
||||
options.respond,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await mutateSessionAtMessage(options, "switch");
|
||||
},
|
||||
"sessions.rewind": async (options) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
@@ -66,13 +98,63 @@ export const sessionRewindHandlers: GatewayRequestHandlers = {
|
||||
},
|
||||
};
|
||||
|
||||
async function listBranches(options: GatewayRequestHandlerOptions): Promise<void> {
|
||||
const { params, respond, context } = options;
|
||||
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const requestedAgent = resolveRequestedGlobalAgentId(
|
||||
cfg,
|
||||
sessionKey,
|
||||
typeof params.agentId === "string" ? params.agentId : undefined,
|
||||
);
|
||||
if (!requestedAgent.ok) {
|
||||
respond(false, undefined, requestedAgent.error);
|
||||
return;
|
||||
}
|
||||
const current = loadAccessorSessionEntryForGatewayTarget({
|
||||
key: sessionKey,
|
||||
cfg,
|
||||
agentId: requestedAgent.agentId,
|
||||
});
|
||||
if (!current.entry?.sessionId) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${sessionKey}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (readSessionUpstreamLink(current.canonicalKey, current.target.agentId)) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, EXTERNAL_CONVERSATION_ERROR));
|
||||
return;
|
||||
}
|
||||
const result = await listSessionBranches({
|
||||
agentId: current.target.agentId,
|
||||
sessionKey: current.canonicalKey,
|
||||
sessionStoreKey: current.sessionStoreKey,
|
||||
storePath: current.storePath,
|
||||
});
|
||||
if (result.status !== "ok") {
|
||||
respondBranchListError(result, respond);
|
||||
return;
|
||||
}
|
||||
respond(true, { branches: result.branches }, undefined);
|
||||
}
|
||||
|
||||
async function mutateSessionAtMessage(
|
||||
options: GatewayRequestHandlerOptions,
|
||||
action: MessageCutAction,
|
||||
): Promise<void> {
|
||||
const { params, respond, context, client, isWebchatConnect } = options;
|
||||
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
|
||||
const entryId = typeof params.entryId === "string" ? params.entryId.trim() : "";
|
||||
const entryId =
|
||||
action === "switch"
|
||||
? typeof params.leafEntryId === "string"
|
||||
? params.leafEntryId.trim()
|
||||
: ""
|
||||
: typeof params.entryId === "string"
|
||||
? params.entryId.trim()
|
||||
: "";
|
||||
if (
|
||||
rejectWebchatSessionMutation({
|
||||
action,
|
||||
@@ -147,8 +229,8 @@ async function mutateSessionAtMessage(
|
||||
if (!targetStillCurrent) {
|
||||
return;
|
||||
}
|
||||
// Fork cannot disturb its source, and rewind must not invalidate queued work on failure.
|
||||
// Reject live work before either transcript mutation instead of interrupting it.
|
||||
// A message cut cannot disturb its source or invalidate queued work on failure.
|
||||
// Reject live work before transcript mutation instead of interrupting it.
|
||||
blockedByActiveRun =
|
||||
isCompetingSessionWorkAdmissionActive(initial.storePath, lifecycleIdentities) ||
|
||||
(asWorkerInferenceControl(context.workerEnvironmentService)?.hasInferenceForSession(
|
||||
@@ -179,7 +261,9 @@ async function mutateSessionAtMessage(
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
`${action === "fork" ? "Fork" : "Rewind"} is unavailable while the agent is working.`,
|
||||
action === "switch"
|
||||
? "Branch switch is unavailable while the agent is working."
|
||||
: `${action === "fork" ? "Fork" : "Rewind"} is unavailable while the agent is working.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -229,18 +313,26 @@ async function mutateSessionAtMessage(
|
||||
storePath: current.storePath,
|
||||
targetKey,
|
||||
})
|
||||
: rewindSessionToMessage({
|
||||
agentId: current.target.agentId,
|
||||
entryId,
|
||||
sessionKey: current.canonicalKey,
|
||||
sessionStoreKey: current.sessionStoreKey,
|
||||
storePath: current.storePath,
|
||||
}));
|
||||
: action === "rewind"
|
||||
? rewindSessionToMessage({
|
||||
agentId: current.target.agentId,
|
||||
entryId,
|
||||
sessionKey: current.canonicalKey,
|
||||
sessionStoreKey: current.sessionStoreKey,
|
||||
storePath: current.storePath,
|
||||
})
|
||||
: switchSessionBranch({
|
||||
agentId: current.target.agentId,
|
||||
leafEntryId: entryId,
|
||||
sessionKey: current.canonicalKey,
|
||||
sessionStoreKey: current.sessionStoreKey,
|
||||
storePath: current.storePath,
|
||||
}));
|
||||
if (result.status !== "created") {
|
||||
respondMessageCutError(result, action, entryId, respond);
|
||||
return;
|
||||
}
|
||||
if (action === "rewind") {
|
||||
if (action !== "fork") {
|
||||
clearSessionQueues(lifecycleIdentities);
|
||||
}
|
||||
respond(
|
||||
@@ -248,9 +340,11 @@ async function mutateSessionAtMessage(
|
||||
action === "fork"
|
||||
? {
|
||||
sessionKey: result.key,
|
||||
...(result.editorText ? { editorText: result.editorText } : {}),
|
||||
...("editorText" in result && result.editorText
|
||||
? { editorText: result.editorText }
|
||||
: {}),
|
||||
}
|
||||
: result.editorText
|
||||
: action === "rewind" && "editorText" in result && result.editorText
|
||||
? { editorText: result.editorText }
|
||||
: {},
|
||||
undefined,
|
||||
@@ -261,30 +355,58 @@ async function mutateSessionAtMessage(
|
||||
requestedAgent.agentId
|
||||
? { agentId: requestedAgent.agentId }
|
||||
: {}),
|
||||
reason: action,
|
||||
reason: action === "switch" ? "branch-switch" : action,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function respondMessageCutError(
|
||||
result: Exclude<SessionMessageCutMutationResult, { status: "created" }>,
|
||||
result: Exclude<
|
||||
SessionMessageCutMutationResult | SessionBranchSwitchMutationResult,
|
||||
{ status: "created" }
|
||||
>,
|
||||
action: MessageCutAction,
|
||||
entryId: string,
|
||||
respond: GatewayRequestHandlerOptions["respond"],
|
||||
): void {
|
||||
const actionLabel = action === "switch" ? "branch switch" : action;
|
||||
const message =
|
||||
result.status === "missing-session"
|
||||
? "session not found"
|
||||
: result.status === "missing-entry"
|
||||
? `message entry not found: ${entryId}`
|
||||
: result.status === "not-user-message"
|
||||
? `entry is not a user message: ${entryId}`
|
||||
: result.status === "off-active-path"
|
||||
? `message entry is not on the active path: ${entryId}`
|
||||
: result.status === "unsupported-storage"
|
||||
? `session transcript storage does not support ${action}`
|
||||
: `failed to ${action} session`;
|
||||
? `${action === "switch" ? "branch" : "message"} entry not found: ${entryId}`
|
||||
: result.status === "not-branch-tip"
|
||||
? `entry is not a branch tip: ${entryId}`
|
||||
: result.status === "already-active"
|
||||
? `branch is already active: ${entryId}`
|
||||
: result.status === "not-user-message"
|
||||
? `entry is not a user message: ${entryId}`
|
||||
: result.status === "off-active-path"
|
||||
? `message entry is not on the active path: ${entryId}`
|
||||
: result.status === "unsupported-storage"
|
||||
? `session transcript storage does not support ${actionLabel}`
|
||||
: `failed to ${actionLabel} session`;
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
result.status === "failed" ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST,
|
||||
message,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function respondBranchListError(
|
||||
result: Exclude<SessionBranchListResult, { status: "ok" }>,
|
||||
respond: GatewayRequestHandlerOptions["respond"],
|
||||
): void {
|
||||
const message =
|
||||
result.status === "missing-session"
|
||||
? "session not found"
|
||||
: result.status === "unsupported-storage"
|
||||
? "session transcript storage does not support branch listing"
|
||||
: "failed to list session branches";
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
|
||||
@@ -31,7 +31,7 @@ export const sessionLog = createSubsystemLogger("gateway/sessions");
|
||||
export class SessionWorkerPlacementMutationError extends Error {
|
||||
constructor(
|
||||
readonly placementState: SessionPlacement["state"],
|
||||
action: "delete" | "fork" | "reset" | "restore" | "rewind",
|
||||
action: "delete" | "fork" | "reset" | "restore" | "rewind" | "switch",
|
||||
key: string,
|
||||
) {
|
||||
super(`Session ${key} cannot ${action} while cloud worker placement is ${placementState}.`);
|
||||
@@ -39,7 +39,7 @@ export class SessionWorkerPlacementMutationError extends Error {
|
||||
}
|
||||
|
||||
export function resolveSessionWorkerPlacementMutationError(params: {
|
||||
action: "delete" | "fork" | "reset" | "restore" | "rewind";
|
||||
action: "delete" | "fork" | "reset" | "restore" | "rewind" | "switch";
|
||||
context: GatewayRequestContext;
|
||||
key: string;
|
||||
sessionId: string | undefined;
|
||||
@@ -286,6 +286,7 @@ export function rejectWebchatSessionMutation(params: {
|
||||
| "restore"
|
||||
| "rewind"
|
||||
| "fork"
|
||||
| "switch"
|
||||
| "dispatch"
|
||||
| "reclaim";
|
||||
client: GatewayClient | null;
|
||||
|
||||
@@ -44,6 +44,7 @@ const PLUGIN_GATEWAY_SESSION_MUTATION_METHODS = new Set([
|
||||
"sessions.compact",
|
||||
"sessions.compaction.branch",
|
||||
"sessions.compaction.restore",
|
||||
"sessions.branches.switch",
|
||||
"sessions.rewind",
|
||||
"sessions.fork",
|
||||
"sessions.create",
|
||||
|
||||
@@ -584,6 +584,11 @@ export type SessionsRewindResult =
|
||||
import("../../../packages/gateway-protocol/src/index.js").SessionsRewindResult;
|
||||
export type SessionsForkResult =
|
||||
import("../../../packages/gateway-protocol/src/index.js").SessionsForkResult;
|
||||
export type SessionBranch = import("../../../packages/gateway-protocol/src/index.js").SessionBranch;
|
||||
export type SessionsBranchesListResult =
|
||||
import("../../../packages/gateway-protocol/src/index.js").SessionsBranchesListResult;
|
||||
export type SessionsBranchesSwitchResult =
|
||||
import("../../../packages/gateway-protocol/src/index.js").SessionsBranchesSwitchResult;
|
||||
|
||||
export type SessionsPatchResult = SessionsPatchResultBase<{
|
||||
sessionId: string;
|
||||
|
||||
@@ -3513,6 +3513,13 @@ export const en: TranslationMap = {
|
||||
copyPath: "Copy path",
|
||||
copyBranch: "Copy branch name",
|
||||
copied: "Copied",
|
||||
branches: "Session branches",
|
||||
branchSwitchUnavailable: "Branch switch is unavailable while the agent is working.",
|
||||
branchSwitchRequiresAdmin: "Branch switching requires operator admin access.",
|
||||
untitledBranch: "Untitled branch",
|
||||
oneMessage: "{count} message",
|
||||
messages: "{count} messages",
|
||||
activeBranch: "Active branch",
|
||||
},
|
||||
board: {
|
||||
faceLabel: "Session face",
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
} from "../../api/gateway.ts";
|
||||
import type {
|
||||
GatewaySessionRow,
|
||||
SessionBranch,
|
||||
SessionCompactionCheckpoint,
|
||||
SessionRunStatus,
|
||||
SessionsCompactionBranchResult,
|
||||
SessionsCompactionListResult,
|
||||
SessionsCompactionRestoreResult,
|
||||
SessionsForkResult,
|
||||
SessionsBranchesListResult,
|
||||
SessionsBranchesSwitchResult,
|
||||
SessionsListResult,
|
||||
SessionsPatchResult,
|
||||
SessionsRewindResult,
|
||||
@@ -241,6 +244,12 @@ export type SessionCapability = {
|
||||
entryId: string,
|
||||
options?: { agentId?: string | null },
|
||||
) => Promise<SessionsForkResult>;
|
||||
listBranches: (key: string, options?: { agentId?: string | null }) => Promise<SessionBranch[]>;
|
||||
switchBranch: (
|
||||
key: string,
|
||||
leafEntryId: string,
|
||||
options?: { agentId?: string | null },
|
||||
) => Promise<SessionsBranchesSwitchResult>;
|
||||
/** Loads the gateway-owned group catalog, coalescing successful connection attempts. */
|
||||
groupsLoad: () => Promise<void>;
|
||||
/** Replaces the group catalog; stale means the initiating connection retired. */
|
||||
@@ -293,6 +302,18 @@ function buildSessionRequestParams(
|
||||
};
|
||||
}
|
||||
|
||||
function buildTranscriptMutationParams(
|
||||
sessionKey: string,
|
||||
agentId?: string | null,
|
||||
): { sessionKey: string; agentId?: string } {
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
const normalizedAgentId = agentId?.trim();
|
||||
return {
|
||||
sessionKey: normalizedSessionKey,
|
||||
...(normalizedAgentId ? { agentId: normalizedAgentId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSessionListParams(options: SessionListOptions = {}): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {
|
||||
...SESSION_LIST_PARAMS,
|
||||
@@ -537,7 +558,7 @@ function rewindSessionAtMessage(
|
||||
options: { agentId?: string | null } = {},
|
||||
): Promise<SessionsRewindResult> {
|
||||
return client.request<SessionsRewindResult>("sessions.rewind", {
|
||||
...buildSessionRequestParams(key, options.agentId),
|
||||
...buildTranscriptMutationParams(key, options.agentId),
|
||||
entryId,
|
||||
});
|
||||
}
|
||||
@@ -549,11 +570,34 @@ function forkSessionAtMessage(
|
||||
options: { agentId?: string | null } = {},
|
||||
): Promise<SessionsForkResult> {
|
||||
return client.request<SessionsForkResult>("sessions.fork", {
|
||||
...buildSessionRequestParams(key, options.agentId),
|
||||
...buildTranscriptMutationParams(key, options.agentId),
|
||||
entryId,
|
||||
});
|
||||
}
|
||||
|
||||
function listSessionBranches(
|
||||
client: SessionRequestClient,
|
||||
key: string,
|
||||
options: { agentId?: string | null } = {},
|
||||
): Promise<SessionsBranchesListResult> {
|
||||
return client.request<SessionsBranchesListResult>(
|
||||
"sessions.branches.list",
|
||||
buildTranscriptMutationParams(key, options.agentId),
|
||||
);
|
||||
}
|
||||
|
||||
function switchSessionBranch(
|
||||
client: SessionRequestClient,
|
||||
key: string,
|
||||
leafEntryId: string,
|
||||
options: { agentId?: string | null } = {},
|
||||
): Promise<SessionsBranchesSwitchResult> {
|
||||
return client.request<SessionsBranchesSwitchResult>("sessions.branches.switch", {
|
||||
...buildTranscriptMutationParams(key, options.agentId),
|
||||
leafEntryId,
|
||||
});
|
||||
}
|
||||
|
||||
function appendSessionResults(
|
||||
previous: SessionsListResult,
|
||||
page: SessionsListResult,
|
||||
@@ -1491,6 +1535,34 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
|
||||
return result;
|
||||
};
|
||||
|
||||
const listBranches = async (
|
||||
key: string,
|
||||
options: { agentId?: string | null } = {},
|
||||
): Promise<SessionBranch[]> => {
|
||||
const scope = captureConnection();
|
||||
if (!scope) {
|
||||
return [];
|
||||
}
|
||||
const result = await listSessionBranches(scope.client, key, options);
|
||||
return isCurrentConnection(scope) ? result.branches : [];
|
||||
};
|
||||
|
||||
const switchBranch = async (
|
||||
key: string,
|
||||
leafEntryId: string,
|
||||
options: { agentId?: string | null } = {},
|
||||
): Promise<SessionsBranchesSwitchResult> => {
|
||||
const scope = captureConnection();
|
||||
if (!scope) {
|
||||
throw new Error("Session branch switch requires an active Gateway connection");
|
||||
}
|
||||
const result = await switchSessionBranch(scope.client, key, leafEntryId, options);
|
||||
if (isCurrentConnection(scope)) {
|
||||
await refreshReplacement(options.agentId ?? state.agentId ?? undefined).catch(() => {});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const stopGateway = gateway.subscribe((next) => {
|
||||
const connectionChanged =
|
||||
next.client !== connectionClient || next.connected !== connectionConnected;
|
||||
@@ -1615,6 +1687,8 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
|
||||
restoreCheckpoint,
|
||||
rewind,
|
||||
forkAtMessage,
|
||||
listBranches,
|
||||
switchBranch,
|
||||
groupsLoad,
|
||||
groupsPut,
|
||||
groupsRename,
|
||||
|
||||
@@ -67,6 +67,10 @@ describe("session capability message cuts", () => {
|
||||
committed.resolve({ editorText: "edit me" });
|
||||
|
||||
await expect(pending).resolves.toEqual({ editorText: "edit me" });
|
||||
expect(request).toHaveBeenCalledWith("sessions.rewind", {
|
||||
sessionKey: "agent:main:main",
|
||||
entryId: "user-entry",
|
||||
});
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
@@ -88,6 +92,42 @@ describe("session capability message cuts", () => {
|
||||
sessionKey: "agent:main:dashboard:forked",
|
||||
editorText: "edit me",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("sessions.fork", {
|
||||
sessionKey: "agent:main:main",
|
||||
entryId: "user-entry",
|
||||
});
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
it("lists branches and sends the selected leaf to the switch RPC", async () => {
|
||||
const branch = {
|
||||
leafEntryId: "branch-b",
|
||||
headline: "Try the earlier path",
|
||||
messageCount: 3,
|
||||
active: false,
|
||||
};
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.branches.list") {
|
||||
return { branches: [branch] };
|
||||
}
|
||||
if (method === "sessions.branches.switch") {
|
||||
return {};
|
||||
}
|
||||
if (method === "sessions.list") {
|
||||
return { sessions: [], count: 0 };
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const { gateway } = createGatewayHarness(client);
|
||||
const sessions = createSessionCapability(gateway);
|
||||
|
||||
await expect(sessions.listBranches("agent:main:main")).resolves.toEqual([branch]);
|
||||
await expect(sessions.switchBranch("agent:main:main", "branch-b")).resolves.toEqual({});
|
||||
expect(request).toHaveBeenCalledWith("sessions.branches.switch", {
|
||||
sessionKey: "agent:main:main",
|
||||
leafEntryId: "branch-b",
|
||||
});
|
||||
sessions.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import {
|
||||
loadChatHistory,
|
||||
rewindChatHistory,
|
||||
switchChatHistoryBranch,
|
||||
type ChatHistoryResult,
|
||||
type ChatState,
|
||||
} from "./chat-history.ts";
|
||||
@@ -168,6 +169,74 @@ describe("rewindChatHistory", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("switchChatHistoryBranch", () => {
|
||||
it("clears the cached snapshot and refetches history plus branches", async () => {
|
||||
const state = createState({
|
||||
messages: [{ role: "assistant", content: "restored branch" }],
|
||||
}) as TestState & {
|
||||
chatMessagesBySession: ChatMessageCache;
|
||||
sessions: {
|
||||
listBranches: ReturnType<typeof vi.fn>;
|
||||
switchBranch: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
state.sessionKey = "agent:main:branches";
|
||||
state.chatMessages = [{ role: "assistant", content: "stale branch" }];
|
||||
state.chatMessagesBySession = new Map();
|
||||
state.sessions = {
|
||||
listBranches: vi.fn().mockResolvedValue([
|
||||
{
|
||||
leafEntryId: "branch-b",
|
||||
headline: "restored branch",
|
||||
messageCount: 2,
|
||||
active: true,
|
||||
},
|
||||
]),
|
||||
switchBranch: vi.fn().mockResolvedValue({}),
|
||||
setModelOverride: vi.fn(),
|
||||
};
|
||||
cacheChatSessionSnapshot(
|
||||
state.chatMessagesBySession,
|
||||
state,
|
||||
{ sessionKey: state.sessionKey },
|
||||
{
|
||||
messages: state.chatMessages,
|
||||
pagination: { hasMore: false, completeSnapshot: true },
|
||||
sessionId: "old-session",
|
||||
},
|
||||
);
|
||||
|
||||
await expect(switchChatHistoryBranch(state as never, "branch-b")).resolves.toBe(true);
|
||||
|
||||
expect(state.sessions.switchBranch).toHaveBeenCalledWith(
|
||||
state.sessionKey,
|
||||
"branch-b",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(state.sessions.listBranches).toHaveBeenCalledWith(state.sessionKey, expect.any(Object));
|
||||
expect(state.chatMessages).toEqual([{ role: "assistant", content: "restored branch" }]);
|
||||
expect(
|
||||
readChatMessagesFromCache(state.chatMessagesBySession, state, {
|
||||
sessionKey: state.sessionKey,
|
||||
}),
|
||||
).toEqual([{ role: "assistant", content: "restored branch" }]);
|
||||
});
|
||||
|
||||
it("refreshes branch metadata after the Gateway connection changes", async () => {
|
||||
const state = createState({ messages: [] }) as TestState & {
|
||||
sessions: { listBranches: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
state.chatBranchesSessionKey = state.sessionKey;
|
||||
state.chatBranchesConnectionEpoch = state.connectionEpoch - 1;
|
||||
state.sessions = { listBranches: vi.fn().mockResolvedValue([]), setModelOverride: vi.fn() };
|
||||
|
||||
await loadChatHistory(state);
|
||||
|
||||
expect(state.sessions.listBranches).toHaveBeenCalledWith(state.sessionKey, expect.any(Object));
|
||||
expect(state.chatBranchesConnectionEpoch).toBe(state.connectionEpoch);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat history plan replay", () => {
|
||||
const retainedPlan = {
|
||||
runId: "run-retained",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
GatewaySessionRow,
|
||||
GatewaySessionsDefaults,
|
||||
ModelCatalogEntry,
|
||||
SessionBranch,
|
||||
SessionsListResult,
|
||||
} from "../../api/types.ts";
|
||||
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
|
||||
@@ -90,6 +91,7 @@ const SYNTHETIC_TRANSCRIPT_REPAIR_RESULT =
|
||||
const CHAT_HISTORY_REQUEST_LIMIT = 100;
|
||||
const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000;
|
||||
const chatHistoryRequestVersions = new WeakMap<object, number>();
|
||||
const chatBranchRequestVersions = new WeakMap<object, number>();
|
||||
const selectedSessionMessageSubscriptionGenerations = new WeakMap<object, number>();
|
||||
|
||||
type ChatHistoryRequestOwnership = {
|
||||
@@ -354,6 +356,12 @@ export type ChatState = {
|
||||
agentsSelectedId?: string | null;
|
||||
hello: GatewayHelloOk | null;
|
||||
settings?: { chatPersistCommentary?: boolean; gatewayUrl?: string | null };
|
||||
sessions?: Partial<SessionCapability>;
|
||||
chatBranches?: SessionBranch[];
|
||||
chatBranchesSessionKey?: string | null;
|
||||
chatBranchesConnectionEpoch?: number | null;
|
||||
chatBranchesLoading?: boolean;
|
||||
requestUpdate?: () => void;
|
||||
};
|
||||
|
||||
type ChatAgentsListSnapshot = Partial<Omit<AgentsListResult, "agents">> & {
|
||||
@@ -801,6 +809,11 @@ type RewindChatHistoryState = ChatState &
|
||||
sessions: Pick<SessionCapability, "rewind">;
|
||||
};
|
||||
|
||||
type SwitchChatHistoryBranchState = ChatState &
|
||||
Parameters<typeof scheduleChatScroll>[0] & {
|
||||
sessions: Pick<SessionCapability, "listBranches" | "switchBranch">;
|
||||
};
|
||||
|
||||
function hasAbortableChatSessionRun(state: ClearChatHistoryState): boolean {
|
||||
if (state.chatRunId) {
|
||||
return true;
|
||||
@@ -933,7 +946,7 @@ export async function rewindChatHistory(
|
||||
return null;
|
||||
}
|
||||
state.chatMessages = [];
|
||||
await loadChatHistory(state);
|
||||
await Promise.all([loadChatHistory(state), loadChatBranches(state)]);
|
||||
if (!visibleSessionMatches(state, sessionKey, agentParams.agentId)) {
|
||||
return null;
|
||||
}
|
||||
@@ -946,6 +959,87 @@ export async function rewindChatHistory(
|
||||
}
|
||||
}
|
||||
|
||||
export async function switchChatHistoryBranch(
|
||||
state: SwitchChatHistoryBranchState,
|
||||
leafEntryId: string,
|
||||
): Promise<boolean> {
|
||||
if (!state.client || !state.connected) {
|
||||
return false;
|
||||
}
|
||||
const sessionKey = state.sessionKey;
|
||||
const agentParams = scopedAgentParamsForSession(state, sessionKey);
|
||||
try {
|
||||
await state.sessions.switchBranch(sessionKey, leafEntryId, agentParams);
|
||||
if (state.chatMessagesBySession) {
|
||||
clearChatMessagesFromCache(state.chatMessagesBySession, state, {
|
||||
sessionKey,
|
||||
agentId: agentParams.agentId,
|
||||
});
|
||||
}
|
||||
if (!visibleSessionMatches(state, sessionKey, agentParams.agentId)) {
|
||||
return false;
|
||||
}
|
||||
state.chatMessages = [];
|
||||
await Promise.all([loadChatHistory(state), loadChatBranches(state)]);
|
||||
return visibleSessionMatches(state, sessionKey, agentParams.agentId);
|
||||
} catch (error) {
|
||||
setChatError(state, error instanceof Error ? error.message : String(error));
|
||||
scheduleChatScroll(state);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadChatBranches(state: ChatState): Promise<void> {
|
||||
const sessions = state.sessions;
|
||||
const client = state.client;
|
||||
const sessionKey = state.sessionKey;
|
||||
if (!sessions?.listBranches || !client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (isGatewayMethodAdvertised(state, "sessions.branches.list") === false) {
|
||||
state.chatBranches = [];
|
||||
state.chatBranchesSessionKey = sessionKey;
|
||||
state.chatBranchesConnectionEpoch = state.connectionEpoch;
|
||||
return;
|
||||
}
|
||||
const version = (chatBranchRequestVersions.get(state as object) ?? 0) + 1;
|
||||
chatBranchRequestVersions.set(state as object, version);
|
||||
const connectionEpoch = state.connectionEpoch;
|
||||
const agentParams = scopedAgentParamsForSession(state, sessionKey);
|
||||
state.chatBranchesLoading = true;
|
||||
try {
|
||||
const branches = await sessions.listBranches(sessionKey, agentParams);
|
||||
if (
|
||||
chatBranchRequestVersions.get(state as object) !== version ||
|
||||
state.client !== client ||
|
||||
!state.connected ||
|
||||
state.connectionEpoch !== connectionEpoch ||
|
||||
!visibleSessionMatches(state, sessionKey, agentParams.agentId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
state.chatBranches = branches;
|
||||
state.chatBranchesSessionKey = sessionKey;
|
||||
state.chatBranchesConnectionEpoch = connectionEpoch;
|
||||
} catch {
|
||||
if (
|
||||
chatBranchRequestVersions.get(state as object) === version &&
|
||||
state.client === client &&
|
||||
state.connectionEpoch === connectionEpoch &&
|
||||
visibleSessionMatches(state, sessionKey, agentParams.agentId)
|
||||
) {
|
||||
state.chatBranches = [];
|
||||
state.chatBranchesSessionKey = sessionKey;
|
||||
state.chatBranchesConnectionEpoch = connectionEpoch;
|
||||
}
|
||||
} finally {
|
||||
if (chatBranchRequestVersions.get(state as object) === version) {
|
||||
state.chatBranchesLoading = false;
|
||||
state.requestUpdate?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadChatHistory(
|
||||
state: ChatState,
|
||||
opts: LoadChatHistoryOptions = {},
|
||||
@@ -972,6 +1066,12 @@ export async function loadChatHistory(
|
||||
) {
|
||||
return inFlight.promise;
|
||||
}
|
||||
if (
|
||||
state.chatBranchesSessionKey !== sessionKey ||
|
||||
state.chatBranchesConnectionEpoch !== connectionEpoch
|
||||
) {
|
||||
void loadChatBranches(state);
|
||||
}
|
||||
const promise = loadChatHistoryUncached(
|
||||
state,
|
||||
client,
|
||||
|
||||
@@ -118,6 +118,7 @@ import {
|
||||
loadOlderChatHistoryPage,
|
||||
rewindChatHistory,
|
||||
resolveChatHistoryPagination,
|
||||
switchChatHistoryBranch,
|
||||
syncSelectedSessionMessageSubscription,
|
||||
} from "./chat-history.ts";
|
||||
import {
|
||||
@@ -164,6 +165,7 @@ import {
|
||||
renderBackgroundTasksToggle,
|
||||
type BackgroundTasksProps,
|
||||
} from "./components/chat-background-tasks.ts";
|
||||
import { isChatRunWorking } from "./components/chat-composer.ts";
|
||||
import { renderChatControls } from "./components/chat-controls.ts";
|
||||
import {
|
||||
canRevealSessionWorkspace,
|
||||
@@ -1436,6 +1438,15 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async switchToBranch(leafEntryId: string): Promise<void> {
|
||||
const state = this.state;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
await switchChatHistoryBranch(state, leafEntryId);
|
||||
state.requestUpdate?.();
|
||||
}
|
||||
|
||||
private readonly handleCommandPaletteSlashCommand = (command: string) => {
|
||||
const state = this.state;
|
||||
if (!state) {
|
||||
@@ -2630,6 +2641,23 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
isGatewayMethodAdvertised(this.context.gateway.snapshot, "sessions.files.reveal") === true,
|
||||
hasAdminAccess: hasOperatorAdminAccess(this.context.gateway.snapshot.hello?.auth ?? null),
|
||||
});
|
||||
const branchSwitchWorking = this.state
|
||||
? this.state.chatSending ||
|
||||
isChatRunWorking({
|
||||
canAbort: hasAbortableSessionRun(this.state),
|
||||
onAbort: () => undefined,
|
||||
queue: this.state.chatQueue,
|
||||
runStatus: this.state.chatRunStatus,
|
||||
sessionKey: this.state.sessionKey,
|
||||
})
|
||||
: false;
|
||||
const branchSwitchDisabledReason = !hasOperatorAdminAccess(
|
||||
this.context.gateway.snapshot.hello?.auth ?? null,
|
||||
)
|
||||
? t("chat.sessionHeader.branchSwitchRequiresAdmin")
|
||||
: branchSwitchWorking
|
||||
? t("chat.sessionHeader.branchSwitchUnavailable")
|
||||
: null;
|
||||
return renderChatPaneHeader({
|
||||
paneId: this.paneId,
|
||||
narrow: this.narrow,
|
||||
@@ -2642,6 +2670,11 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
workspaceRoot: workspace.root,
|
||||
workspaceLabel: workspace.label,
|
||||
branch,
|
||||
branches:
|
||||
this.state && this.state.chatBranchesSessionKey === this.state.sessionKey
|
||||
? (this.state.chatBranches ?? [])
|
||||
: [],
|
||||
branchSwitchDisabledReason,
|
||||
platform: this.headerPlatform,
|
||||
canReveal,
|
||||
copiedAction: this.headerCopiedAction,
|
||||
@@ -2674,6 +2707,7 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
this.handleHeaderMenuAction(action, row, workspace.root, branch);
|
||||
}
|
||||
},
|
||||
onBranchSelect: (leafEntryId) => void this.switchToBranch(leafEntryId),
|
||||
onOpenSplitView: this.onOpenSplitView,
|
||||
onSplitDown: this.onSplitDown,
|
||||
onSplitRight: this.onSplitRight,
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
import {
|
||||
chatScopedEventSessionMatches,
|
||||
isHiddenAssistantStreamText,
|
||||
loadChatBranches,
|
||||
loadChatHistory,
|
||||
shouldHideAssistantChatMessage,
|
||||
type ChatMetadataResult,
|
||||
@@ -517,6 +518,10 @@ export function resetChatStateForRouteSession(
|
||||
state.chatAttachments = [];
|
||||
state.chatReplyTarget = null;
|
||||
state.chatMessages = snapshot.messages;
|
||||
state.chatBranches = [];
|
||||
state.chatBranchesSessionKey = null;
|
||||
state.chatBranchesConnectionEpoch = null;
|
||||
state.chatBranchesLoading = false;
|
||||
state.chatToolMessages = [];
|
||||
state.chatStreamSegments = [];
|
||||
state.chatThinkingLevel = null;
|
||||
@@ -1077,6 +1082,9 @@ function handleSessionMessageEvent(state: ChatPageHost, payload: unknown) {
|
||||
return;
|
||||
}
|
||||
const matchesChat = sessionMessageMatchesChat(state, event);
|
||||
if (matchesChat) {
|
||||
void loadChatBranches(state);
|
||||
}
|
||||
if (matchesChat && event.archived !== null) {
|
||||
state.selectedChatSessionArchived = event.archived;
|
||||
}
|
||||
@@ -1138,12 +1146,13 @@ function replayPendingSessionMessageReload(
|
||||
function handleSessionsChangedEvent(state: ChatPageHost, payload: unknown) {
|
||||
const runIdBeforeApply = state.chatRunId;
|
||||
const event = readSessionChangedEvent(payload);
|
||||
if (
|
||||
event &&
|
||||
globalSessionEventMatchesChat(state, event) &&
|
||||
sessionMessageMatchesChat(state, event) &&
|
||||
event.archived !== null
|
||||
) {
|
||||
const matchesChat = Boolean(
|
||||
event && globalSessionEventMatchesChat(state, event) && sessionMessageMatchesChat(state, event),
|
||||
);
|
||||
if (matchesChat) {
|
||||
void loadChatBranches(state);
|
||||
}
|
||||
if (event && matchesChat && event.archived !== null) {
|
||||
state.selectedChatSessionArchived = event.archived;
|
||||
}
|
||||
const result = reconcileSessionEvent(state, payload);
|
||||
@@ -1151,7 +1160,7 @@ function handleSessionsChangedEvent(state: ChatPageHost, payload: unknown) {
|
||||
result.applied &&
|
||||
event &&
|
||||
runIdBeforeApply &&
|
||||
sessionMessageMatchesChat(state, event) &&
|
||||
matchesChat &&
|
||||
finishSessionMessageRunReconcile(
|
||||
state,
|
||||
event.key,
|
||||
@@ -1239,6 +1248,10 @@ export function createPageState(
|
||||
chatSending: false,
|
||||
chatMessage: "",
|
||||
chatMessages: [] as unknown[],
|
||||
chatBranches: [],
|
||||
chatBranchesSessionKey: null,
|
||||
chatBranchesConnectionEpoch: null,
|
||||
chatBranchesLoading: false,
|
||||
chatToolMessages: [] as Record<string, unknown>[],
|
||||
chatThinkingLevel: null,
|
||||
chatVerboseLevel: null,
|
||||
|
||||
@@ -42,6 +42,8 @@ function mount(patch: Partial<ChatPaneHeaderProps> = {}) {
|
||||
workspaceRoot: "/repo/openclaw",
|
||||
workspaceLabel: "openclaw",
|
||||
branch: "feature/header",
|
||||
branches: [],
|
||||
branchSwitchDisabledReason: null,
|
||||
platform: "darwin",
|
||||
canReveal: true,
|
||||
copiedAction: null,
|
||||
@@ -56,6 +58,7 @@ function mount(patch: Partial<ChatPaneHeaderProps> = {}) {
|
||||
onCancelRename: vi.fn(),
|
||||
onMenuOpenChange: vi.fn(),
|
||||
onMenuAction: vi.fn(),
|
||||
onBranchSelect: vi.fn(),
|
||||
...patch,
|
||||
};
|
||||
render(html`${renderChatPaneHeader(props)}`, container);
|
||||
@@ -163,6 +166,58 @@ describe("chat pane header", () => {
|
||||
expect(container.querySelector('wa-dropdown-item[value="reveal"]')).toBeNull();
|
||||
expect(container.querySelector('wa-dropdown-item[value="copy-path"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides one branch and lists multiple branches with the active tip marked", () => {
|
||||
const one = mount({
|
||||
branches: [{ leafEntryId: "only", headline: "Only path", messageCount: 1, active: true }],
|
||||
});
|
||||
expect(one.container.querySelector(".chat-pane__branches-trigger")).toBeNull();
|
||||
|
||||
const multiple = mount({
|
||||
branches: [
|
||||
{ leafEntryId: "active", headline: "Current work", messageCount: 4, active: true },
|
||||
{
|
||||
leafEntryId: "other",
|
||||
headline: "Earlier idea",
|
||||
messageCount: 2,
|
||||
updatedAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
active: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const items = multiple.container.querySelectorAll(".chat-pane__branch-item");
|
||||
expect(multiple.container.querySelector(".chat-pane__branches-trigger")).not.toBeNull();
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]?.textContent).toContain("Current work");
|
||||
expect(items[0]?.getAttribute("data-active")).toBe("true");
|
||||
expect(items[0]?.querySelector(".chat-pane__branch-active")).not.toBeNull();
|
||||
expect(items[1]?.textContent).toContain("Earlier idea");
|
||||
|
||||
multiple.container.querySelector(".chat-pane__branches-menu")?.dispatchEvent(
|
||||
new CustomEvent("wa-select", {
|
||||
detail: { item: { value: "other" } },
|
||||
}),
|
||||
);
|
||||
expect(multiple.props.onBranchSelect).toHaveBeenCalledWith("other");
|
||||
});
|
||||
|
||||
it("disables branch switching while the agent is working", () => {
|
||||
const { container, props } = mount({
|
||||
branchSwitchDisabledReason: "Branch switch is unavailable while the agent is working.",
|
||||
branches: [
|
||||
{ leafEntryId: "active", headline: "Current work", messageCount: 4, active: true },
|
||||
{ leafEntryId: "other", headline: "Earlier idea", messageCount: 2, active: false },
|
||||
],
|
||||
});
|
||||
const trigger = container.querySelector<HTMLButtonElement>(".chat-pane__branches-trigger");
|
||||
expect(trigger?.disabled).toBe(true);
|
||||
container.querySelector(".chat-pane__branches-menu")?.dispatchEvent(
|
||||
new CustomEvent("wa-select", {
|
||||
detail: { item: { value: "other" } },
|
||||
}),
|
||||
);
|
||||
expect(props.onBranchSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat pane workspace resolution", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import type { GatewaySessionRow } from "../../../api/types.ts";
|
||||
import type { GatewaySessionRow, SessionBranch } from "../../../api/types.ts";
|
||||
import { beginNativeWindowDrag } from "../../../app/native-window-drag.ts";
|
||||
import {
|
||||
COMMAND_PALETTE_OPEN_EVENT,
|
||||
@@ -11,6 +11,7 @@ import { isCloudWorkerPlacementState } from "../../../components/session-row-bad
|
||||
import "../../../components/tooltip.ts";
|
||||
import "../../../components/web-awesome.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { formatRelativeTimestamp } from "../../../lib/format.ts";
|
||||
|
||||
export type ChatPaneHeaderAction = "reveal" | "copy-path" | "copy-branch";
|
||||
|
||||
@@ -26,6 +27,8 @@ type ChatPaneHeaderProps = {
|
||||
workspaceRoot: string | null;
|
||||
workspaceLabel: string | null;
|
||||
branch: string | null;
|
||||
branches: SessionBranch[];
|
||||
branchSwitchDisabledReason: string | null;
|
||||
platform: string | null;
|
||||
canReveal: boolean;
|
||||
copiedAction: ChatPaneHeaderAction | null;
|
||||
@@ -42,6 +45,7 @@ type ChatPaneHeaderProps = {
|
||||
onCancelRename: () => void;
|
||||
onMenuOpenChange: (open: boolean) => void;
|
||||
onMenuAction: (action: ChatPaneHeaderAction) => void;
|
||||
onBranchSelect: (leafEntryId: string) => void;
|
||||
onOpenSplitView?: () => void;
|
||||
onSplitDown?: (paneId: string) => void;
|
||||
onSplitRight?: (paneId: string) => void;
|
||||
@@ -63,6 +67,11 @@ function pathBasename(value: string): string {
|
||||
return trimmed.split(/[\\/]/).pop() || trimmed;
|
||||
}
|
||||
|
||||
function branchRelativeTime(updatedAt: string | undefined): string {
|
||||
const timestamp = updatedAt ? Date.parse(updatedAt) : Number.NaN;
|
||||
return Number.isFinite(timestamp) ? formatRelativeTimestamp(timestamp, { fallback: "" }) : "";
|
||||
}
|
||||
|
||||
export function resolveChatPaneWorkspace(params: {
|
||||
session: GatewaySessionRow | undefined;
|
||||
agentWorkspace?: string;
|
||||
@@ -224,6 +233,69 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
|
||||
`
|
||||
: nothing}
|
||||
${props.faceControl ?? nothing}
|
||||
${!props.catalog && props.branches.length > 1
|
||||
? html`
|
||||
<wa-dropdown
|
||||
class="chat-pane__branches-menu"
|
||||
placement="bottom-end"
|
||||
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
|
||||
const leafEntryId = event.detail.item.value;
|
||||
const branch = props.branches.find(
|
||||
(candidate) => candidate.leafEntryId === leafEntryId,
|
||||
);
|
||||
if (leafEntryId && branch && !branch.active && !props.branchSwitchDisabledReason) {
|
||||
props.onBranchSelect(leafEntryId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<openclaw-tooltip
|
||||
slot="trigger"
|
||||
.content=${props.branchSwitchDisabledReason ?? t("chat.sessionHeader.branches")}
|
||||
>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-pane__branches-trigger"
|
||||
type="button"
|
||||
?disabled=${Boolean(props.branchSwitchDisabledReason)}
|
||||
aria-label=${t("chat.sessionHeader.branches")}
|
||||
>
|
||||
${icons.gitBranch}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
${props.branches.map((branch) => {
|
||||
const relativeTime = branchRelativeTime(branch.updatedAt);
|
||||
return html`
|
||||
<wa-dropdown-item
|
||||
class="chat-pane__branch-item"
|
||||
value=${branch.leafEntryId}
|
||||
?disabled=${branch.active || Boolean(props.branchSwitchDisabledReason)}
|
||||
data-active=${branch.active ? "true" : "false"}
|
||||
>
|
||||
<span class="chat-pane__branch-copy">
|
||||
<span class="chat-pane__branch-headline"
|
||||
>${branch.headline || t("chat.sessionHeader.untitledBranch")}</span
|
||||
>
|
||||
<span class="chat-pane__branch-meta"
|
||||
>${t(
|
||||
branch.messageCount === 1
|
||||
? "chat.sessionHeader.oneMessage"
|
||||
: "chat.sessionHeader.messages",
|
||||
{ count: String(branch.messageCount) },
|
||||
)}${relativeTime ? ` · ${relativeTime}` : ""}</span
|
||||
>
|
||||
</span>
|
||||
${branch.active
|
||||
? html`<span
|
||||
class="chat-pane__branch-active"
|
||||
aria-label=${t("chat.sessionHeader.activeBranch")}
|
||||
>${icons.check}</span
|
||||
>`
|
||||
: nothing}
|
||||
</wa-dropdown-item>
|
||||
`;
|
||||
})}
|
||||
</wa-dropdown>
|
||||
`
|
||||
: nothing}
|
||||
<div class="chat-pane__actions">
|
||||
${props.boardDockAction ?? nothing} ${props.terminalAction}
|
||||
${props.catalog
|
||||
|
||||
@@ -214,6 +214,44 @@ openclaw-chat-pane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-pane__branches-menu {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chat-pane__branches-trigger svg,
|
||||
.chat-pane__branch-active svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.chat-pane__branch-item {
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.chat-pane__branch-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chat-pane__branch-headline {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-pane__branch-meta {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chat-pane__branch-active {
|
||||
display: inline-flex;
|
||||
margin-left: auto;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-pane__workspace-chip {
|
||||
display: inline-flex;
|
||||
max-width: 180px;
|
||||
|
||||
Reference in New Issue
Block a user