fix(tui): run new sessions through lifecycle hooks (#100241)

* fix(tui): run new sessions through lifecycle hooks

Co-authored-by: chunqiu <caopu@bupt.edu.cn>

* test(tui): wait for idle before PTY new-session checks

* docs(changelog): defer TUI batch entries

---------

Co-authored-by: chunqiu <caopu@bupt.edu.cn>
This commit is contained in:
Peter Steinberger
2026-07-05 06:48:05 -07:00
committed by GitHub
parent ccdbb70cf0
commit cd305b3b0c
16 changed files with 1055 additions and 507 deletions

View File

@@ -13,6 +13,7 @@ import type {
TuiEvent,
TuiModelChoice,
TuiSessionList,
TuiSessionCreateOptions,
} from "../tui/tui-backend.js";
import { runTui as defaultRunTui } from "../tui/tui.js";
import type { CrestodianAssistantPlanner } from "./assistant.js";
@@ -227,6 +228,15 @@ class CrestodianTuiBackend implements TuiBackend {
return { ok: true };
}
async createSession(_opts: TuiSessionCreateOptions) {
await this.resetSession();
return {
ok: true as const,
key: CRESTODIAN_SESSION_KEY,
entry: { sessionId: "crestodian", updatedAt: Date.now() },
};
}
async getGatewayStatus(): Promise<string> {
const overview = await loadOverviewForTui(this.opts);
return overview.gateway.reachable ? "Gateway reachable" : "Gateway unreachable";

View File

@@ -3,7 +3,6 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
readStringValue,
} from "@openclaw/normalization-core/string-coerce";
@@ -34,11 +33,7 @@ import {
} from "../../../packages/gateway-protocol/src/index.js";
import { readAcpSessionMeta } from "../../acp/runtime/session-meta.js";
import { resolveModelAgentRuntimeMetadata } from "../../agents/agent-runtime-metadata.js";
import {
listAgentIds,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
abortEmbeddedAgentRun,
isEmbeddedAgentRunActive,
@@ -62,16 +57,10 @@ import {
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
import {
applySessionPatchProjection,
createSessionEntryWithTranscript,
preflightSessionTranscriptForManualCompact,
trimSessionTranscriptForManualCompact,
} from "../../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
createInternalHookEvent,
hasInternalHookListeners,
triggerInternalHook,
} from "../../hooks/internal-hooks.js";
import {
measureDiagnosticsTimelineSpan,
measureDiagnosticsTimelineSpanSync,
@@ -79,12 +68,7 @@ import {
import { formatErrorMessage } from "../../infra/errors.js";
import { patchPluginSessionExtension } from "../../plugins/host-hook-state.js";
import { isPluginJsonValue } from "../../plugins/host-hooks.js";
import {
normalizeAgentId,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
toAgentStoreSessionKey,
} from "../../routing/session-key.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import {
interruptSessionWorkAdmissions,
isSessionLifecycleMutationActive,
@@ -100,6 +84,11 @@ import {
getSessionCompactionCheckpoint,
listSessionCompactionCheckpoints,
} from "../session-compaction-checkpoints.js";
import {
buildDashboardSessionKey,
createGatewaySession,
resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId,
} from "../session-create-service.js";
import { triggerSessionPatchHook } from "../session-patch-hooks.js";
import {
resolveSessionStoreAgentId,
@@ -130,7 +119,7 @@ import {
type SessionsPreviewEntry,
type SessionsPreviewResult,
} from "../session-utils.js";
import { applySessionsPatchToStore, projectSessionsPatchEntry } from "../sessions-patch.js";
import { projectSessionsPatchEntry } from "../sessions-patch.js";
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
import { setGatewayDedupeEntry } from "./agent-wait-dedupe.js";
import { chatHandlers } from "./chat.js";
@@ -178,38 +167,6 @@ function filterSessionStoreToConfiguredAgents(
);
}
function inheritSessionRuntimeSelection(
parentEntry: SessionEntry | undefined,
): Partial<SessionEntry> {
if (!parentEntry) {
return {};
}
return {
...(parentEntry.providerOverride ? { providerOverride: parentEntry.providerOverride } : {}),
...(parentEntry.modelOverride ? { modelOverride: parentEntry.modelOverride } : {}),
...(parentEntry.modelOverrideSource
? { modelOverrideSource: parentEntry.modelOverrideSource }
: {}),
...(parentEntry.agentRuntimeOverride
? { agentRuntimeOverride: parentEntry.agentRuntimeOverride }
: {}),
...(parentEntry.modelProvider ? { modelProvider: parentEntry.modelProvider } : {}),
...(parentEntry.model ? { model: parentEntry.model } : {}),
...(parentEntry.thinkingLevel ? { thinkingLevel: parentEntry.thinkingLevel } : {}),
...(parentEntry.fastMode !== undefined ? { fastMode: parentEntry.fastMode } : {}),
...(parentEntry.verboseLevel ? { verboseLevel: parentEntry.verboseLevel } : {}),
...(parentEntry.traceLevel ? { traceLevel: parentEntry.traceLevel } : {}),
...(parentEntry.reasoningLevel ? { reasoningLevel: parentEntry.reasoningLevel } : {}),
...(parentEntry.elevatedLevel ? { elevatedLevel: parentEntry.elevatedLevel } : {}),
...(parentEntry.authProfileOverride
? { authProfileOverride: parentEntry.authProfileOverride }
: {}),
...(parentEntry.authProfileOverrideSource
? { authProfileOverrideSource: parentEntry.authProfileOverrideSource }
: {}),
};
}
const loadSessionsRuntimeModule = createLazyRuntimeModule(() => import("./sessions.runtime.js"));
function requireSessionKey(key: unknown, respond: RespondFn): string | null {
@@ -348,10 +305,6 @@ function rejectWebchatSessionMutation(params: {
return true;
}
function buildDashboardSessionKey(agentId: string): string {
return `agent:${agentId}:dashboard:${randomUUID()}`;
}
function isAgentMainSessionKey(cfg: OpenClawConfig, sessionKey: string): boolean {
const parsed = parseAgentSessionKey(sessionKey);
if (!parsed) {
@@ -530,61 +483,6 @@ function resolveSessionMessageSubscriptionKey(params: {
: params.canonicalKey;
}
type RequestedGlobalAgentIdResolution =
| { ok: true; agentId?: string }
| { ok: false; error: ReturnType<typeof errorShape> };
function resolveRequestedGlobalAgentId(
cfg: OpenClawConfig,
key: string,
explicitAgentId?: string,
): RequestedGlobalAgentIdResolution {
const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey: key });
const parsed = parseAgentSessionKey(key);
const requestedAgentId = normalizeOptionalString(explicitAgentId);
if (requestedAgentId) {
const agentId = normalizeAgentId(requestedAgentId);
if (!listAgentIds(cfg).includes(agentId)) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${explicitAgentId}"`),
};
}
if (parsed?.agentId && normalizeAgentId(parsed.agentId) !== agentId) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"),
};
}
if (canonicalKey !== "global") {
const keyAgentId = parsed?.agentId
? normalizeAgentId(parsed.agentId)
: normalizeAgentId(resolveSessionStoreAgentId(cfg, canonicalKey));
if (keyAgentId !== agentId) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"),
};
}
}
return { ok: true, agentId };
}
if (!parsed?.agentId) {
return { ok: true };
}
const inferredAgentId = normalizeAgentId(parsed.agentId);
if (canonicalKey === "global" && !listAgentIds(cfg).includes(inferredAgentId)) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${parsed.agentId}"`),
};
}
return {
ok: true,
agentId: canonicalKey === "global" ? inferredAgentId : undefined,
};
}
async function interruptSessionRunIfActive(params: {
req: GatewayRequestHandlerOptions["req"];
context: GatewayRequestContext;
@@ -1280,239 +1178,77 @@ export const sessionsHandlers: GatewayRequestHandlers = {
}
const p = params;
const cfg = context.getRuntimeConfig();
const requestedKey = normalizeOptionalString(p.key);
const agentId = normalizeAgentId(
normalizeOptionalString(p.agentId) ?? resolveDefaultAgentId(cfg),
);
if (requestedKey) {
const requestedAgentId = parseAgentSessionKey(requestedKey)?.agentId;
if (requestedAgentId && requestedAgentId !== agentId && normalizeOptionalString(p.agentId)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`sessions.create key agent (${requestedAgentId}) does not match agentId (${agentId})`,
),
);
return;
}
}
const parentSessionKey = normalizeOptionalString(p.parentSessionKey);
let canonicalParentSessionKey: string | undefined;
let parentSessionEntry: SessionEntry | undefined;
let parentSelectedAgentId: string | undefined;
if (parentSessionKey) {
const parentCanonicalKey = resolveSessionStoreKey({ cfg, sessionKey: parentSessionKey });
if (parentCanonicalKey === "global") {
const parentRequestedAgent = resolveRequestedGlobalAgentId(
cfg,
parentSessionKey,
p.agentId,
);
if (!parentRequestedAgent.ok) {
respond(false, undefined, parentRequestedAgent.error);
return;
}
parentSelectedAgentId = parentRequestedAgent.agentId;
}
const parent = loadSessionEntry(
parentSessionKey,
parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined,
);
if (!parent.entry?.sessionId) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown parent session: ${parentSessionKey}`),
);
return;
}
canonicalParentSessionKey = parent.canonicalKey;
parentSessionEntry = parent.entry;
}
if (
canonicalParentSessionKey &&
p.emitCommandHooks === true &&
!requestedKey &&
!resolveOptionalInitialSessionMessage(p) &&
cfg.session?.dmScope === "main"
) {
const parentAgentId = normalizeAgentId(
parentSelectedAgentId ??
resolveAgentIdFromSessionKey(canonicalParentSessionKey) ??
resolveDefaultAgentId(cfg),
);
const parentMainKey = resolveAgentMainSessionKey({ cfg, agentId: parentAgentId });
if (canonicalParentSessionKey === parentMainKey) {
const { performGatewaySessionReset } = await loadSessionsRuntimeModule();
const resetResult = await performGatewaySessionReset({
key: canonicalParentSessionKey,
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
? { agentId: parentSelectedAgentId }
: {}),
reason: "new",
commandSource: "webchat",
});
if (!resetResult.ok) {
respond(false, undefined, resetResult.error);
return;
}
respond(
true,
{
ok: true,
key: resetResult.key,
sessionId: resetResult.entry.sessionId,
entry: resetResult.entry,
runStarted: false,
},
undefined,
);
emitSessionsChanged(context, {
sessionKey: resetResult.key,
...(resetResult.key === "global" ? { agentId: resetResult.agentId } : {}),
reason: "new",
});
return;
}
}
if (canonicalParentSessionKey && p.emitCommandHooks === true) {
const { entry: parentEntry } = loadSessionEntry(
canonicalParentSessionKey,
parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined,
);
const parentAgentId = normalizeAgentId(
parentSelectedAgentId ??
resolveAgentIdFromSessionKey(canonicalParentSessionKey) ??
resolveDefaultAgentId(cfg),
);
const workspaceDir = resolveAgentWorkspaceDir(cfg, parentAgentId);
if (hasInternalHookListeners("command", "new")) {
const hookEvent = createInternalHookEvent("command", "new", canonicalParentSessionKey, {
sessionEntry: parentEntry,
previousSessionEntry: parentEntry,
commandSource: "webchat",
cfg,
workspaceDir,
});
await triggerInternalHook(hookEvent);
}
const parentTarget = resolveGatewaySessionStoreTarget({
cfg,
key: canonicalParentSessionKey,
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
? { agentId: parentSelectedAgentId }
: {}),
});
const { emitGatewayBeforeResetPluginHook } = await loadSessionsRuntimeModule();
await emitGatewayBeforeResetPluginHook({
cfg,
key: canonicalParentSessionKey,
target: parentTarget,
storePath: parentTarget.storePath,
entry: parentEntry,
reason: "new",
});
}
const loweredRequestedKey = normalizeOptionalLowercaseString(requestedKey);
const key = requestedKey
? loweredRequestedKey === "global" || loweredRequestedKey === "unknown"
? loweredRequestedKey
: toAgentStoreSessionKey({
agentId,
requestKey: requestedKey,
mainKey: cfg.session?.mainKey,
})
: buildDashboardSessionKey(agentId);
const target = resolveGatewaySessionStoreTarget({ cfg, key, agentId });
const targetAgentId = target.agentId;
const created = await createSessionEntryWithTranscript(
{
agentId: targetAgentId,
sessionKey: target.canonicalKey,
storePath: target.storePath,
},
async ({ sessionEntries }) => {
const patched = await applySessionsPatchToStore({
cfg,
store: sessionEntries,
storeKey: target.canonicalKey,
agentId: targetAgentId,
patch: {
key: target.canonicalKey,
label: normalizeOptionalString(p.label),
model: normalizeOptionalString(p.model),
},
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
});
if (!patched.ok || !canonicalParentSessionKey) {
return patched;
}
const inheritedSelection = normalizeOptionalString(p.model)
? {}
: inheritSessionRuntimeSelection(parentSessionEntry);
const nextEntry: SessionEntry = {
...patched.entry,
...inheritedSelection,
parentSessionKey: canonicalParentSessionKey,
};
return {
...patched,
entry: nextEntry,
};
},
);
if (!created.ok) {
respond(
false,
undefined,
created.phase === "transcript"
? errorShape(
ErrorCodes.UNAVAILABLE,
`failed to create session transcript: ${created.error}`,
)
: created.error,
);
return;
}
const createdEntry = created.entry;
const initialMessage = resolveOptionalInitialSessionMessage(p);
let runPayload: Record<string, unknown> | undefined;
let runError: unknown;
let runMeta: Record<string, unknown> | undefined;
const messageSeq = initialMessage
? (await readSessionMessageCountAsync({
agentId: target.agentId,
sessionEntry: createdEntry,
sessionId: createdEntry.sessionId,
sessionKey: target.canonicalKey,
storePath: target.storePath,
})) + 1
: undefined;
if (initialMessage) {
await chatHandlers["chat.send"]({
req,
params: {
sessionKey: target.canonicalKey,
...(target.canonicalKey === "global" ? { agentId: target.agentId } : {}),
message: initialMessage,
idempotencyKey: randomUUID(),
},
respond: (ok, payload, error, meta) => {
if (ok && payload && typeof payload === "object") {
runPayload = payload as Record<string, unknown>;
} else {
runError = error;
let messageSeq: number | undefined;
const created = await createGatewaySession({
cfg,
key: p.key,
agentId: p.agentId,
label: p.label,
model: p.model,
parentSessionKey: p.parentSessionKey,
emitCommandHooks: p.emitCommandHooks,
resetMainWhenUnspecified: !initialMessage,
commandSource: "webchat",
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
afterCreate: initialMessage
? async ({ key, agentId, entry, storePath }) => {
messageSeq =
(await readSessionMessageCountAsync({
agentId,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: key,
storePath,
})) + 1;
await chatHandlers["chat.send"]({
req,
params: {
sessionKey: key,
...(key === "global" ? { agentId } : {}),
message: initialMessage,
idempotencyKey: randomUUID(),
},
respond: (ok, payload, error, meta) => {
if (ok && payload && typeof payload === "object") {
runPayload = payload as Record<string, unknown>;
} else {
runError = error;
}
runMeta = meta;
},
context,
client,
isWebchatConnect,
});
}
runMeta = meta;
: undefined,
});
if (!created.ok) {
respond(false, undefined, created.error);
return;
}
if (created.resetExisting) {
respond(
true,
{
ok: true,
key: created.key,
sessionId: created.entry.sessionId,
entry: created.entry,
runStarted: false,
},
context,
client,
isWebchatConnect,
undefined,
);
emitSessionsChanged(context, {
sessionKey: created.key,
...(created.key === "global" ? { agentId: created.agentId } : {}),
reason: "new",
});
return;
}
const runStarted =
@@ -1526,9 +1262,9 @@ export const sessionsHandlers: GatewayRequestHandlers = {
true,
{
ok: true,
key: target.canonicalKey,
sessionId: createdEntry.sessionId,
entry: createdEntry,
key: created.key,
sessionId: created.entry.sessionId,
entry: created.entry,
runStarted,
...(runPayload ? runPayload : {}),
...(runStarted && typeof messageSeq === "number" ? { messageSeq } : {}),
@@ -1537,52 +1273,17 @@ export const sessionsHandlers: GatewayRequestHandlers = {
undefined,
);
emitSessionsChanged(context, {
sessionKey: target.canonicalKey,
...(target.canonicalKey === "global" ? { agentId: target.agentId } : {}),
sessionKey: created.key,
...(created.key === "global" ? { agentId: created.agentId } : {}),
reason: "create",
});
if (runStarted) {
emitSessionsChanged(context, {
sessionKey: target.canonicalKey,
...(target.canonicalKey === "global" ? { agentId: target.agentId } : {}),
sessionKey: created.key,
...(created.key === "global" ? { agentId: created.agentId } : {}),
reason: "send",
});
}
if (canonicalParentSessionKey && p.emitCommandHooks === true) {
const { entry: parentEntry } = loadSessionEntry(
canonicalParentSessionKey,
parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined,
);
const parentTarget = resolveGatewaySessionStoreTarget({
cfg,
key: canonicalParentSessionKey,
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
? { agentId: parentSelectedAgentId }
: {}),
});
const { emitGatewaySessionEndPluginHook, emitGatewaySessionStartPluginHook } =
await loadSessionsRuntimeModule();
emitGatewaySessionEndPluginHook({
cfg,
sessionKey: canonicalParentSessionKey,
sessionId: parentEntry?.sessionId,
storePath: parentTarget.storePath,
sessionFile: parentEntry?.sessionFile,
agentId: parentTarget.agentId,
reason: "new",
nextSessionId: createdEntry.sessionId,
nextSessionKey: target.canonicalKey,
});
emitGatewaySessionStartPluginHook({
cfg,
sessionKey: target.canonicalKey,
sessionId: createdEntry.sessionId,
resumedFrom: parentEntry?.sessionId,
storePath: target.storePath,
sessionFile: createdEntry.sessionFile,
agentId: target.agentId,
});
}
},
"sessions.compaction.branch": async ({ params, respond, context }) => {
if (

View File

@@ -626,3 +626,22 @@ test("sessions.create can start the first agent turn from an initial task", asyn
ws.close();
});
test("sessions.create rejects replacing its parent key", async () => {
await createSessionStoreDir();
testState.agentsConfig = { list: [{ id: "main", default: true }] };
await writeSessionStore({ entries: { main: sessionStoreEntry("sess-parent-task") } });
const created = await directSessionReq("sessions.create", {
key: "main",
parentSessionKey: "agent:main:main",
emitCommandHooks: true,
task: "hello after replacing parent",
});
expect(created.ok).toBe(false);
expect(created.error).toMatchObject({
code: "INVALID_REQUEST",
message: "sessions.create key must differ from parentSessionKey",
});
});

View File

@@ -3,6 +3,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { expect, test, vi } from "vitest";
import { beginSessionWorkAdmission } from "../sessions/session-lifecycle-admission.js";
import { embeddedRunMock, testState, writeSessionStore } from "./test-helpers.js";
import {
setupGatewaySessionsTestHarness,
@@ -647,6 +648,89 @@ test("sessions.create with emitCommandHooks=true emits reset lifecycle hooks aga
expectStringWithPrefix(startEvent.sessionKey, "agent:main:dashboard:", "created session key");
});
test("sessions.create waits for the parent run lifecycle before firing hooks", async () => {
await createSessionStoreDir();
await writeMainSessionEntry("sess-active-parent");
embeddedRunMock.activeIds.add("sess-active-parent");
const result = await directSessionReq("sessions.create", {
key: "tui-next",
parentSessionKey: "main",
emitCommandHooks: true,
});
expect(result.ok).toBe(false);
expect(result.error).toMatchObject({ code: "UNAVAILABLE" });
expect(result.error?.message).toMatch(/parent session.*still active/i);
expect(commandNewHookEvents()).toHaveLength(0);
expect(beforeResetHookMocks.runBeforeReset).not.toHaveBeenCalled();
expect(sessionLifecycleHookMocks.runSessionEnd).not.toHaveBeenCalled();
expect(sessionLifecycleHookMocks.runSessionStart).not.toHaveBeenCalled();
});
test("sessions.create waits for the parent work admission to release", async () => {
const { storePath } = await createSessionStoreDir();
await writeMainSessionEntry("sess-finishing-parent");
const admission = await beginSessionWorkAdmission({
scope: storePath,
identities: ["agent:main:main", "sess-finishing-parent"],
assertAllowed: () => {},
});
try {
const result = await directSessionReq("sessions.create", {
key: "tui-next",
parentSessionKey: "main",
emitCommandHooks: true,
});
expect(result.ok).toBe(false);
expect(result.error).toMatchObject({ code: "UNAVAILABLE" });
expect(commandNewHookEvents()).toHaveLength(0);
expect(sessionLifecycleHookMocks.runSessionEnd).not.toHaveBeenCalled();
} finally {
admission.release();
}
});
test("sessions.create fences new parent work while rollover hooks run", async () => {
const { storePath } = await createSessionStoreDir();
await writeMainSessionEntry("sess-parent-fenced");
let releaseHook: (() => void) | undefined;
sessionHookMocks.triggerInternalHook.mockImplementationOnce(
async () =>
await new Promise<void>((resolve) => {
releaseHook = resolve;
}),
);
const creating = directSessionReq("sessions.create", {
key: "tui-next",
parentSessionKey: "main",
emitCommandHooks: true,
});
await vi.waitFor(() => expect(sessionHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1));
let admissionStarted = false;
const admission = beginSessionWorkAdmission({
scope: storePath,
identities: ["agent:main:main", "sess-parent-fenced"],
assertAllowed: () => {
admissionStarted = true;
},
});
await Promise.resolve();
expect(admissionStarted).toBe(false);
if (!releaseHook) {
throw new Error("expected pending command:new hook");
}
releaseHook();
expect((await creating).ok).toBe(true);
const lease = await admission;
expect(admissionStarted).toBe(true);
lease.release();
});
test("sessions.create with emitCommandHooks=true resets parent in place when session.dmScope is 'main' (#77434)", async () => {
const { dir } = await createSessionStoreDir();
const transcriptPath = await writeMessageTranscript({
@@ -666,6 +750,7 @@ test("sessions.create with emitCommandHooks=true resets parent in place when ses
},
},
});
embeddedRunMock.activeIds.add("sess-parent-dms");
const result = await directSessionReq<{
ok: boolean;
@@ -696,6 +781,47 @@ test("sessions.create with emitCommandHooks=true resets parent in place when ses
}
});
test("sessions.create keeps an explicit TUI child key when session.dmScope is 'main'", async () => {
const { dir } = await createSessionStoreDir();
const transcriptPath = await writeMessageTranscript({
dir,
sessionId: "sess-parent-tui",
content: "hello before TUI /new",
});
testState.sessionConfig = { dmScope: "main" };
try {
await writeSessionStore({
entries: {
main: {
sessionId: "sess-parent-tui",
sessionFile: transcriptPath,
updatedAt: Date.now(),
},
},
});
const result = await directSessionReq<{ key: string; sessionId: string }>("sessions.create", {
key: "tui-explicit",
agentId: "main",
parentSessionKey: "main",
emitCommandHooks: true,
});
expect(result.ok).toBe(true);
expect(result.payload?.key).toBe("agent:main:tui-explicit");
expect(result.payload?.sessionId).not.toBe("sess-parent-tui");
expect(expectSingleCommandNewHookEvent().context?.commandSource).toBe("webchat");
const [endEvent] = firstHookCall(sessionLifecycleHookMocks.runSessionEnd);
const [startEvent] = firstHookCall(sessionLifecycleHookMocks.runSessionStart);
expect(endEvent.sessionKey).toBe("agent:main:main");
expect(endEvent.nextSessionKey).toBe("agent:main:tui-explicit");
expect(startEvent.sessionKey).toBe("agent:main:tui-explicit");
} finally {
testState.sessionConfig = undefined;
}
});
test("sessions.create without emitCommandHooks does not fire command:new hook (#76957)", async () => {
const { dir } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-parent2", "hello from parent 2");

View File

@@ -0,0 +1,494 @@
import { randomUUID } from "node:crypto";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import {
ErrorCodes,
type ErrorShape,
errorShape,
} from "../../packages/gateway-protocol/src/index.js";
import {
listAgentIds,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { isEmbeddedAgentRunActive } from "../agents/embedded-agent.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.types.js";
import type { SessionEntry } from "../config/sessions.js";
import { resolveAgentMainSessionKey } from "../config/sessions/main-session.js";
import { createSessionEntryWithTranscript } from "../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
createInternalHookEvent,
hasInternalHookListeners,
triggerInternalHook,
} from "../hooks/internal-hooks.js";
import {
normalizeAgentId,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
toAgentStoreSessionKey,
} from "../routing/session-key.js";
import {
isSessionWorkAdmissionActive,
runExclusiveSessionLifecycleMutation,
} from "../sessions/session-lifecycle-admission.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "./session-store-key.js";
import { loadSessionEntry, resolveGatewaySessionStoreTarget } from "./session-utils.js";
import { applySessionsPatchToStore } from "./sessions-patch.js";
const loadSessionLifecycleRuntime = createLazyRuntimeModule(
() => import("./server-methods/sessions.runtime.js"),
);
type RequestedSessionAgentIdResolution =
| { ok: true; agentId?: string }
| { ok: false; error: ErrorShape };
export function resolveRequestedSessionAgentId(
cfg: OpenClawConfig,
key: string,
explicitAgentId?: string,
): RequestedSessionAgentIdResolution {
const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey: key });
const parsed = parseAgentSessionKey(key);
const requestedAgentId = normalizeOptionalString(explicitAgentId);
if (requestedAgentId) {
const agentId = normalizeAgentId(requestedAgentId);
if (!listAgentIds(cfg).includes(agentId)) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${explicitAgentId}"`),
};
}
if (parsed?.agentId && normalizeAgentId(parsed.agentId) !== agentId) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"),
};
}
if (canonicalKey !== "global") {
const keyAgentId = parsed?.agentId
? normalizeAgentId(parsed.agentId)
: normalizeAgentId(resolveSessionStoreAgentId(cfg, canonicalKey));
if (keyAgentId !== agentId) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"),
};
}
}
return { ok: true, agentId };
}
if (!parsed?.agentId) {
return { ok: true };
}
const inferredAgentId = normalizeAgentId(parsed.agentId);
if (canonicalKey === "global" && !listAgentIds(cfg).includes(inferredAgentId)) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${parsed.agentId}"`),
};
}
return {
ok: true,
agentId: canonicalKey === "global" ? inferredAgentId : undefined,
};
}
export function buildDashboardSessionKey(agentId: string): string {
return `agent:${agentId}:dashboard:${randomUUID()}`;
}
function inheritSessionRuntimeSelection(
parentEntry: SessionEntry | undefined,
): Partial<SessionEntry> {
if (!parentEntry) {
return {};
}
return {
...(parentEntry.providerOverride ? { providerOverride: parentEntry.providerOverride } : {}),
...(parentEntry.modelOverride ? { modelOverride: parentEntry.modelOverride } : {}),
...(parentEntry.modelOverrideSource
? { modelOverrideSource: parentEntry.modelOverrideSource }
: {}),
...(parentEntry.agentRuntimeOverride
? { agentRuntimeOverride: parentEntry.agentRuntimeOverride }
: {}),
...(parentEntry.modelProvider ? { modelProvider: parentEntry.modelProvider } : {}),
...(parentEntry.model ? { model: parentEntry.model } : {}),
...(parentEntry.thinkingLevel ? { thinkingLevel: parentEntry.thinkingLevel } : {}),
...(parentEntry.fastMode !== undefined ? { fastMode: parentEntry.fastMode } : {}),
...(parentEntry.verboseLevel ? { verboseLevel: parentEntry.verboseLevel } : {}),
...(parentEntry.traceLevel ? { traceLevel: parentEntry.traceLevel } : {}),
...(parentEntry.reasoningLevel ? { reasoningLevel: parentEntry.reasoningLevel } : {}),
...(parentEntry.elevatedLevel ? { elevatedLevel: parentEntry.elevatedLevel } : {}),
...(parentEntry.authProfileOverride
? { authProfileOverride: parentEntry.authProfileOverride }
: {}),
...(parentEntry.authProfileOverrideSource
? { authProfileOverrideSource: parentEntry.authProfileOverrideSource }
: {}),
};
}
type CreatedGatewaySession = {
key: string;
agentId: string;
entry: SessionEntry;
storePath: string;
};
type CreateGatewaySessionResult =
| {
ok: true;
key: string;
agentId: string;
entry: SessionEntry;
resetExisting: boolean;
}
| { ok: false; error: ErrorShape };
export async function createGatewaySession(params: {
cfg: OpenClawConfig;
key?: string;
agentId?: string;
label?: string;
model?: string;
parentSessionKey?: string;
emitCommandHooks?: boolean;
resetMainWhenUnspecified?: boolean;
commandSource: string;
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
afterCreate?: (created: CreatedGatewaySession) => Promise<void>;
}): Promise<CreateGatewaySessionResult> {
const requestedKey = normalizeOptionalString(params.key);
const agentId = normalizeAgentId(
normalizeOptionalString(params.agentId) ?? resolveDefaultAgentId(params.cfg),
);
if (requestedKey) {
const requestedAgentId = parseAgentSessionKey(requestedKey)?.agentId;
if (
requestedAgentId &&
requestedAgentId !== agentId &&
normalizeOptionalString(params.agentId)
) {
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
`sessions.create key agent (${requestedAgentId}) does not match agentId (${agentId})`,
),
};
}
}
const loweredRequestedKey = normalizeOptionalLowercaseString(requestedKey);
const explicitTargetKey = requestedKey
? loweredRequestedKey === "global" || loweredRequestedKey === "unknown"
? loweredRequestedKey
: toAgentStoreSessionKey({
agentId,
requestKey: requestedKey,
mainKey: params.cfg.session?.mainKey,
})
: undefined;
const parentSessionKey = normalizeOptionalString(params.parentSessionKey);
let canonicalParentSessionKey: string | undefined;
let parentSessionEntry: SessionEntry | undefined;
let parentSelectedAgentId: string | undefined;
if (parentSessionKey) {
const parentCanonicalKey = resolveSessionStoreKey({
cfg: params.cfg,
sessionKey: parentSessionKey,
});
if (parentCanonicalKey === "global") {
const parentRequestedAgent = resolveRequestedSessionAgentId(
params.cfg,
parentSessionKey,
params.agentId,
);
if (!parentRequestedAgent.ok) {
return parentRequestedAgent;
}
parentSelectedAgentId = parentRequestedAgent.agentId;
}
const parent = loadSessionEntry(
parentSessionKey,
parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined,
);
if (!parent.entry?.sessionId) {
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
`unknown parent session: ${parentSessionKey}`,
),
};
}
canonicalParentSessionKey = parent.canonicalKey;
parentSessionEntry = parent.entry;
}
if (
canonicalParentSessionKey &&
explicitTargetKey &&
resolveGatewaySessionStoreTarget({ cfg: params.cfg, key: explicitTargetKey, agentId })
.canonicalKey === canonicalParentSessionKey
) {
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
"sessions.create key must differ from parentSessionKey",
),
};
}
if (
canonicalParentSessionKey &&
params.emitCommandHooks === true &&
!requestedKey &&
params.resetMainWhenUnspecified === true &&
params.cfg.session?.dmScope === "main"
) {
const parentAgentId = normalizeAgentId(
parentSelectedAgentId ??
resolveAgentIdFromSessionKey(canonicalParentSessionKey) ??
resolveDefaultAgentId(params.cfg),
);
const parentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: parentAgentId });
if (canonicalParentSessionKey === parentMainKey) {
const { performGatewaySessionReset } = await loadSessionLifecycleRuntime();
const resetResult = await performGatewaySessionReset({
key: canonicalParentSessionKey,
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
? { agentId: parentSelectedAgentId }
: {}),
reason: "new",
commandSource: params.commandSource,
});
if (!resetResult.ok) {
return resetResult;
}
return {
ok: true,
key: resetResult.key,
agentId: resetResult.agentId,
entry: resetResult.entry,
resetExisting: true,
};
}
}
let createdContext: CreatedGatewaySession | undefined;
const createChildSession = async (): Promise<CreateGatewaySessionResult> => {
let currentParentSessionEntry = parentSessionEntry;
if (canonicalParentSessionKey && params.emitCommandHooks === true) {
const currentParent = loadSessionEntry(
canonicalParentSessionKey,
parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined,
);
const currentParentEntry = currentParent.entry;
if (
!currentParentEntry?.sessionId ||
currentParentEntry.sessionId !== parentSessionEntry?.sessionId
) {
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
`Parent session ${parentSessionKey} changed before /new; retry.`,
),
};
}
currentParentSessionEntry = currentParentEntry;
const parentTarget = resolveGatewaySessionStoreTarget({
cfg: params.cfg,
key: canonicalParentSessionKey,
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
? { agentId: parentSelectedAgentId }
: {}),
});
const parentHasActiveWork =
isEmbeddedAgentRunActive(currentParentEntry.sessionId) ||
isSessionWorkAdmissionActive(parentTarget.storePath, [
canonicalParentSessionKey,
currentParentEntry.sessionId,
]);
if (parentHasActiveWork) {
return {
ok: false,
error: errorShape(
ErrorCodes.UNAVAILABLE,
`Parent session ${parentSessionKey} is still active; try again in a moment.`,
),
};
}
}
if (canonicalParentSessionKey && params.emitCommandHooks === true) {
const parentEntry = currentParentSessionEntry;
const parentAgentId = normalizeAgentId(
parentSelectedAgentId ??
resolveAgentIdFromSessionKey(canonicalParentSessionKey) ??
resolveDefaultAgentId(params.cfg),
);
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, parentAgentId);
if (hasInternalHookListeners("command", "new")) {
await triggerInternalHook(
createInternalHookEvent("command", "new", canonicalParentSessionKey, {
sessionEntry: parentEntry,
previousSessionEntry: parentEntry,
commandSource: params.commandSource,
cfg: params.cfg,
workspaceDir,
}),
);
}
const parentTarget = resolveGatewaySessionStoreTarget({
cfg: params.cfg,
key: canonicalParentSessionKey,
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
? { agentId: parentSelectedAgentId }
: {}),
});
const { emitGatewayBeforeResetPluginHook } = await loadSessionLifecycleRuntime();
await emitGatewayBeforeResetPluginHook({
cfg: params.cfg,
key: canonicalParentSessionKey,
target: parentTarget,
storePath: parentTarget.storePath,
entry: parentEntry,
reason: "new",
});
}
const key = explicitTargetKey ?? buildDashboardSessionKey(agentId);
const target = resolveGatewaySessionStoreTarget({ cfg: params.cfg, key, agentId });
const created = await createSessionEntryWithTranscript<ErrorShape>(
{
agentId: target.agentId,
sessionKey: target.canonicalKey,
storePath: target.storePath,
},
async ({ sessionEntries }) => {
const patched = await applySessionsPatchToStore({
cfg: params.cfg,
store: sessionEntries,
storeKey: target.canonicalKey,
agentId: target.agentId,
patch: {
key: target.canonicalKey,
label: normalizeOptionalString(params.label),
model: normalizeOptionalString(params.model),
},
loadGatewayModelCatalog: params.loadGatewayModelCatalog,
});
if (!patched.ok || !canonicalParentSessionKey) {
return patched;
}
const inheritedSelection = normalizeOptionalString(params.model)
? {}
: inheritSessionRuntimeSelection(currentParentSessionEntry);
return {
...patched,
entry: {
...patched.entry,
...inheritedSelection,
parentSessionKey: canonicalParentSessionKey,
},
};
},
);
if (!created.ok) {
return {
ok: false,
error:
created.phase === "transcript"
? errorShape(
ErrorCodes.UNAVAILABLE,
`failed to create session transcript: ${created.error}`,
)
: created.error,
};
}
createdContext = {
key: target.canonicalKey,
agentId: target.agentId,
entry: created.entry,
storePath: target.storePath,
};
if (canonicalParentSessionKey && params.emitCommandHooks === true) {
const parentEntry = currentParentSessionEntry;
const parentTarget = resolveGatewaySessionStoreTarget({
cfg: params.cfg,
key: canonicalParentSessionKey,
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
? { agentId: parentSelectedAgentId }
: {}),
});
const { emitGatewaySessionEndPluginHook, emitGatewaySessionStartPluginHook } =
await loadSessionLifecycleRuntime();
emitGatewaySessionEndPluginHook({
cfg: params.cfg,
sessionKey: canonicalParentSessionKey,
sessionId: parentEntry?.sessionId,
storePath: parentTarget.storePath,
sessionFile: parentEntry?.sessionFile,
agentId: parentTarget.agentId,
reason: "new",
nextSessionId: created.entry.sessionId,
nextSessionKey: target.canonicalKey,
});
emitGatewaySessionStartPluginHook({
cfg: params.cfg,
sessionKey: target.canonicalKey,
sessionId: created.entry.sessionId,
resumedFrom: parentEntry?.sessionId,
storePath: target.storePath,
sessionFile: created.entry.sessionFile,
agentId: target.agentId,
});
}
return {
ok: true,
key: target.canonicalKey,
agentId: target.agentId,
entry: created.entry,
resetExisting: false,
};
};
if (
canonicalParentSessionKey &&
parentSessionEntry?.sessionId &&
params.emitCommandHooks === true
) {
const parentTarget = resolveGatewaySessionStoreTarget({
cfg: params.cfg,
key: canonicalParentSessionKey,
...(canonicalParentSessionKey === "global" && parentSelectedAgentId
? { agentId: parentSelectedAgentId }
: {}),
});
const result = await runExclusiveSessionLifecycleMutation({
scope: parentTarget.storePath,
identities: [canonicalParentSessionKey, parentSessionEntry.sessionId],
run: createChildSession,
});
if (result.ok && !result.resetExisting && createdContext) {
await params.afterCreate?.(createdContext);
}
return result;
}
const result = await createChildSession();
if (result.ok && !result.resetExisting && createdContext) {
await params.afterCreate?.(createdContext);
}
return result;
}

View File

@@ -21,6 +21,7 @@ const updateSessionGoalStatusMock = vi.fn();
const ensureRuntimePluginsLoadedMock = vi.fn();
const ensureContextWindowCacheLoadedMock = vi.fn(async () => undefined);
const runSessionStartupMigrationMock = vi.fn<() => Promise<void>>(async () => undefined);
const createGatewaySessionMock = vi.fn();
const listSessionsFromStoreAsyncMock = vi.fn(
async (_options?: unknown): Promise<{ sessions: unknown[] }> => ({ sessions: [] }),
);
@@ -206,6 +207,10 @@ vi.mock("../gateway/server-model-catalog.js", () => ({
loadGatewayModelCatalog: (params?: unknown) => loadGatewayModelCatalogMock(params),
}));
vi.mock("../gateway/session-create-service.js", () => ({
createGatewaySession: (...args: unknown[]) => createGatewaySessionMock(...args),
}));
vi.mock("../gateway/session-reset-service.js", () => ({
performGatewaySessionReset: () => ({ ok: true, key: "agent:main:main", entry: {} }),
}));
@@ -287,6 +292,13 @@ describe("EmbeddedTuiBackend", () => {
ensureContextWindowCacheLoadedMock.mockResolvedValue(undefined);
runSessionStartupMigrationMock.mockReset();
runSessionStartupMigrationMock.mockResolvedValue(undefined);
createGatewaySessionMock.mockReset();
createGatewaySessionMock.mockResolvedValue({
ok: true,
key: "agent:main:tui-created",
entry: { sessionId: "created-session" },
resetExisting: false,
});
listSessionsFromStoreAsyncMock.mockReset();
listSessionsFromStoreAsyncMock.mockResolvedValue({ sessions: [] });
loadCombinedSessionStoreForGatewayMock.mockReset();
@@ -344,6 +356,34 @@ describe("EmbeddedTuiBackend", () => {
defaultRuntime.error = originalRuntimeError;
});
it("creates TUI sessions through the shared gateway lifecycle", async () => {
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
const backend = new EmbeddedTuiBackend();
const result = await backend.createSession({
key: "tui-created",
agentId: "main",
parentSessionKey: "agent:main:main",
});
expect(createGatewaySessionMock).toHaveBeenCalledWith(
expect.objectContaining({
cfg: {},
key: "tui-created",
agentId: "main",
parentSessionKey: "agent:main:main",
emitCommandHooks: true,
commandSource: "tui:embedded",
loadGatewayModelCatalog: expect.any(Function),
}),
);
expect(result).toEqual({
ok: true,
key: "agent:main:tui-created",
entry: { sessionId: "created-session" },
});
});
it("bridges assistant and lifecycle events into chat events", async () => {
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
const pending = deferred<{

View File

@@ -48,6 +48,7 @@ import {
replaceOversizedChatHistoryMessages,
} from "../gateway/server-methods/chat.js";
import { loadGatewayModelCatalog } from "../gateway/server-model-catalog.js";
import { createGatewaySession } from "../gateway/session-create-service.js";
import { performGatewaySessionReset } from "../gateway/session-reset-service.js";
import {
capArrayByJsonBytes,
@@ -86,6 +87,7 @@ import type {
TuiEvent,
TuiModelChoice,
TuiSessionList,
TuiSessionCreateOptions,
} from "./tui-backend.js";
type LocalRunState = {
@@ -673,6 +675,22 @@ export class EmbeddedTuiBackend implements TuiBackend {
return { ok: true as const, key: result.key, entry: result.entry };
}
async createSession(opts: TuiSessionCreateOptions) {
await this.ready;
const cfg = getRuntimeConfig();
const result = await createGatewaySession({
cfg,
...opts,
emitCommandHooks: Boolean(opts.parentSessionKey),
commandSource: "tui:embedded",
loadGatewayModelCatalog: () => loadEmbeddedTuiModelCatalog(cfg),
});
if (!result.ok) {
throw new Error(result.error.message);
}
return { ok: true as const, key: result.key, entry: result.entry };
}
private async runBtwTurn(params: {
runId: string;
sessionKey: string;

View File

@@ -39,6 +39,7 @@ import type {
TuiModelChoice,
TuiApprovalDecision,
TuiSessionList,
TuiSessionCreateOptions,
TuiSessionMutationResult,
TuiChatSendResult,
} from "./tui-backend.js";
@@ -283,6 +284,13 @@ export class GatewayChatClient implements TuiBackend {
return await this.client.request<SessionsPatchResult>("sessions.patch", opts);
}
async createSession(opts: TuiSessionCreateOptions): Promise<TuiSessionMutationResult> {
return await this.client.request<TuiSessionMutationResult>("sessions.create", {
...opts,
emitCommandHooks: Boolean(opts.parentSessionKey),
});
}
async resetSession(
key: string,
reason?: "new" | "reset",

View File

@@ -152,6 +152,13 @@ export type TuiSessionMutationResult = {
};
};
/** Options for creating a fresh TUI session through the backend lifecycle. */
export type TuiSessionCreateOptions = {
key: string;
agentId?: string;
parentSessionKey?: string;
};
/** Minimal backend interface shared by Gateway and embedded local TUI modes. */
export type TuiBackend = {
connection: {
@@ -177,6 +184,7 @@ export type TuiBackend = {
listSessions: (opts?: SessionsListParams) => Promise<TuiSessionList>;
listAgents: () => Promise<TuiAgentsList>;
patchSession: (opts: SessionsPatchParams) => Promise<SessionsPatchResult>;
createSession: (opts: TuiSessionCreateOptions) => Promise<TuiSessionMutationResult>;
resetSession: (
key: string,
reason?: "new" | "reset",

View File

@@ -17,7 +17,6 @@ type SelectableOverlay = {
};
type SetActivityStatusMock = ReturnType<typeof vi.fn> & ((text: string) => void);
type SetSessionMock = ReturnType<typeof vi.fn> & ((key: string) => Promise<void>);
type SetEmptySessionMock = ReturnType<typeof vi.fn> & ((key: string) => Promise<void>);
type ConsumeCompletedRunMock = ReturnType<typeof vi.fn> & ((runId: string) => boolean);
type FlushPendingHistoryRefreshMock = ReturnType<typeof vi.fn> & (() => void);
@@ -81,11 +80,11 @@ function createHarness(params?: {
listSessions?: ReturnType<typeof vi.fn>;
listModels?: ReturnType<typeof vi.fn>;
patchSession?: ReturnType<typeof vi.fn>;
createSession?: ReturnType<typeof vi.fn>;
resetSession?: ReturnType<typeof vi.fn>;
runGoalCommand?: ReturnType<typeof vi.fn>;
runAuthFlow?: RunAuthFlow;
setSession?: SetSessionMock;
setEmptySession?: SetEmptySessionMock;
loadHistory?: LoadHistoryMock;
refreshSessionInfo?: ReturnType<typeof vi.fn>;
applySessionInfoFromPatch?: ReturnType<typeof vi.fn>;
@@ -112,11 +111,15 @@ function createHarness(params?: {
const listSessions = params?.listSessions ?? vi.fn().mockResolvedValue({ sessions: [] });
const listModels = params?.listModels ?? vi.fn().mockResolvedValue([]);
const patchSession = params?.patchSession ?? vi.fn().mockResolvedValue({});
const createSession =
params?.createSession ??
vi.fn().mockImplementation(async (opts: { key: string }) => ({
ok: true,
key: `agent:main:${opts.key}`,
}));
const resetSession = params?.resetSession ?? vi.fn().mockResolvedValue({ ok: true });
const runGoalCommand = params?.runGoalCommand ?? vi.fn().mockResolvedValue({ text: "Goal" });
const setSession = params?.setSession ?? (vi.fn().mockResolvedValue(undefined) as SetSessionMock);
const setEmptySession =
params?.setEmptySession ?? (vi.fn().mockResolvedValue(undefined) as SetEmptySessionMock);
const addUser = vi.fn();
const addPendingUser = vi.fn();
const dropPendingUser = vi.fn();
@@ -159,13 +162,14 @@ function createHarness(params?: {
sessionInfo: {},
};
const { handleCommand, openSessionSelector } = createCommandHandlers({
const { handleCommand, sendMessage, openSessionSelector } = createCommandHandlers({
client: {
sendChat,
getGatewayStatus,
listSessions,
listModels,
patchSession,
createSession,
resetSession,
runGoalCommand,
} as never,
@@ -187,7 +191,6 @@ function createHarness(params?: {
refreshSessionInfo: refreshSessionInfo as never,
loadHistory,
setSession,
setEmptySession,
refreshAgents: vi.fn(),
abortActive,
setActivityStatus,
@@ -207,6 +210,7 @@ function createHarness(params?: {
return {
handleCommand,
sendMessage,
getGatewayStatus,
listSessions,
listModels,
@@ -216,10 +220,10 @@ function createHarness(params?: {
overlayHandle,
closeOverlay,
patchSession,
createSession,
resetSession,
runGoalCommand,
setSession,
setEmptySession,
addUser,
addPendingUser,
dropPendingUser,
@@ -948,7 +952,10 @@ describe("tui command handlers", () => {
it("creates unique session for /new and resets shared session for /reset", async () => {
const loadHistory = vi.fn().mockResolvedValue(undefined);
const setSessionMock = vi.fn().mockResolvedValue(undefined) as SetSessionMock;
const setEmptySessionMock = vi.fn().mockResolvedValue(undefined) as SetEmptySessionMock;
const createSessionMock = vi.fn().mockResolvedValue({
ok: true,
key: "agent:main:tui-canonical",
});
const applySessionMutationResult = vi.fn().mockReturnValue(true);
const refreshSessionInfo = vi.fn().mockResolvedValue(undefined);
const resetResult = {
@@ -959,7 +966,8 @@ describe("tui command handlers", () => {
const { handleCommand, resetSession } = createHarness({
loadHistory,
setSession: setSessionMock,
setEmptySession: setEmptySessionMock,
createSession: createSessionMock,
currentSessionId: "old-session",
applySessionMutationResult,
refreshSessionInfo,
resetSession: vi.fn().mockResolvedValue(resetResult),
@@ -969,18 +977,20 @@ describe("tui command handlers", () => {
await handleCommand("/reset");
// /new creates a unique session key (isolates TUI client) (#39217)
expect(setSessionMock).not.toHaveBeenCalled();
expect(setEmptySessionMock).toHaveBeenCalledTimes(1);
const newSessionKey = firstMockArg(setEmptySessionMock, "setEmptySession") as
| string
expect(createSessionMock).toHaveBeenCalledTimes(1);
const createOptions = firstMockArg(createSessionMock, "createSession") as
| { key?: string; agentId?: string; parentSessionKey?: string }
| undefined;
if (!newSessionKey) {
throw new Error("expected /new to set a TUI session key");
if (!createOptions?.key) {
throw new Error("expected /new to create a TUI session key");
}
expect(newSessionKey.startsWith("tui-")).toBe(true);
const uuidParts: string[] = newSessionKey.slice("tui-".length).split("-");
expect(createOptions.agentId).toBe("main");
expect(createOptions.parentSessionKey).toBe("agent:main:main");
expect(createOptions.key.startsWith("tui-")).toBe(true);
const uuidParts: string[] = createOptions.key.slice("tui-".length).split("-");
expect(uuidParts.map((part) => part.length)).toEqual([8, 4, 4, 4, 12]);
expect(uuidParts.every((part) => /^[0-9a-f]+$/.test(part))).toBe(true);
expect(setSessionMock).toHaveBeenCalledWith("agent:main:tui-canonical");
// /reset still resets the shared session
expect(resetSession).toHaveBeenCalledTimes(1);
expect(resetSession).toHaveBeenCalledWith("agent:main:main", "reset", undefined);
@@ -989,6 +999,101 @@ describe("tui command handlers", () => {
expect(loadHistory).not.toHaveBeenCalled();
});
it.each([
{
name: "omits an unknown parent for the first session",
currentSessionKey: "agent:main:main",
currentAgentId: "main",
currentSessionId: null,
expectedParent: undefined,
},
{
name: "attributes a global session to the selected agent",
currentSessionKey: "global",
currentAgentId: "work",
currentSessionId: "global-session",
expectedParent: "global",
},
])("$name", async ({ currentSessionKey, currentAgentId, currentSessionId, expectedParent }) => {
const createSession = vi.fn().mockResolvedValue({ ok: true, key: "agent:work:tui-next" });
const { handleCommand } = createHarness({
createSession,
currentSessionKey,
currentAgentId,
currentSessionId,
});
await handleCommand("/new");
expect(createSession).toHaveBeenCalledWith({
key: expect.stringMatching(/^tui-/),
agentId: currentAgentId,
...(expectedParent ? { parentSessionKey: expectedParent } : {}),
});
});
it.each([
{
activeChatRunId: "active-run",
pendingChatRunId: null,
pendingOptimisticUserMessage: false,
activityStatus: "running",
},
{
activeChatRunId: null,
pendingChatRunId: "pending-run",
pendingOptimisticUserMessage: false,
activityStatus: "sending",
},
{
activeChatRunId: null,
pendingChatRunId: null,
pendingOptimisticUserMessage: true,
activityStatus: "sending",
},
{
activeChatRunId: null,
pendingChatRunId: null,
pendingOptimisticUserMessage: false,
activityStatus: "finishing context",
},
])("blocks /new while the current session lifecycle is unfinished", async (runState) => {
const createSession = vi.fn();
const { handleCommand, addSystem } = createHarness({ createSession, ...runState });
await handleCommand("/new");
expect(createSession).not.toHaveBeenCalled();
expect(addSystem).toHaveBeenCalledWith("abort the current run before /new");
});
it("serializes input until /new adopts the created session", async () => {
let resolveCreate: ((value: { ok: true; key: string }) => void) | undefined;
const createSession = vi.fn().mockImplementation(
() =>
new Promise<{ ok: true; key: string }>((resolve) => {
resolveCreate = resolve;
}),
);
const { handleCommand, sendMessage, sendChat, addSystem } = createHarness({ createSession });
const creating = handleCommand("/new");
await Promise.resolve();
await sendMessage("must not reach parent");
await handleCommand("/new");
expect(sendChat).not.toHaveBeenCalled();
expect(createSession).toHaveBeenCalledTimes(1);
expect(addSystem).toHaveBeenCalledWith("session change in progress; message not sent");
expect(addSystem).toHaveBeenCalledWith("session change in progress; wait for /new to finish");
if (!resolveCreate) {
throw new Error("expected pending session creation");
}
resolveCreate({ ok: true, key: "agent:main:tui-created" });
await creating;
});
it("reloads history after /reset when the backend does not return a session entry", async () => {
const loadHistory = vi.fn().mockResolvedValue(undefined);
const applySessionMutationResult = vi.fn().mockReturnValue(false);
@@ -1101,10 +1206,10 @@ describe("tui command handlers", () => {
});
it("sanitizes control sequences in /new and /reset failures", async () => {
const setEmptySession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m"));
const createSession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m"));
const resetSession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m"));
const { handleCommand, addSystem } = createHarness({
setEmptySession,
createSession,
resetSession,
});

View File

@@ -57,7 +57,6 @@ type CommandHandlerContext = {
refreshSessionInfo: () => Promise<void>;
loadHistory: () => Promise<void>;
setSession: (key: string) => Promise<void>;
setEmptySession: (key: string) => Promise<void>;
refreshAgents: () => Promise<void>;
abortActive: (params?: { preferActive?: boolean }) => Promise<void>;
setActivityStatus: (text: string) => void;
@@ -129,7 +128,6 @@ export function createCommandHandlers(context: CommandHandlerContext) {
refreshSessionInfo,
loadHistory,
setSession,
setEmptySession,
refreshAgents,
abortActive,
setActivityStatus,
@@ -146,6 +144,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
runAuthFlow,
requestExit,
} = context;
let sessionCreationInFlight = false;
const addUnsupportedLocalCommand = (name: string) => {
chatLog.addSystem(`/${name} is not available in local embedded mode; message not sent`);
@@ -165,6 +164,9 @@ export function createCommandHandlers(context: CommandHandlerContext) {
const hasTrackedAbortTarget = () =>
Boolean(state.activeChatRunId || state.pendingChatRunId || state.pendingOptimisticUserMessage);
const hasUnsafeSessionRollover = () =>
hasTrackedAbortTarget() || state.activityStatus === "finishing context";
const currentSessionPatchTarget = () => ({
key: state.currentSessionKey,
...(state.currentSessionKey === "global" ? { agentId: state.currentAgentId } : {}),
@@ -359,6 +361,11 @@ export function createCommandHandlers(context: CommandHandlerContext) {
if (!name) {
return;
}
if (sessionCreationInFlight && name !== "exit" && name !== "quit") {
chatLog.addSystem("session change in progress; wait for /new to finish");
tui.requestRender();
return;
}
switch (name) {
case "help":
chatLog.addSystem(
@@ -704,6 +711,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
break;
}
case "new":
if (hasUnsafeSessionRollover()) {
chatLog.addSystem("abort the current run before /new");
tui.requestRender();
break;
}
sessionCreationInFlight = true;
try {
// Clear token counts immediately to avoid stale display (#1523)
state.sessionInfo.inputTokens = null;
@@ -711,14 +724,21 @@ export function createCommandHandlers(context: CommandHandlerContext) {
state.sessionInfo.totalTokens = null;
tui.requestRender();
// Generate unique session key to isolate this TUI client (#39217)
// This ensures /new creates a fresh session that doesn't broadcast
// to other connected TUI clients sharing the original session key.
const uniqueKey = `tui-${randomUUID()}`;
await setEmptySession(uniqueKey);
chatLog.addSystem(`new session: ${uniqueKey}`);
const result = await client.createSession({
key: uniqueKey,
agentId: state.currentAgentId,
...(state.currentSessionId ? { parentSessionKey: state.currentSessionKey } : {}),
});
if (!result.key) {
throw new Error("sessions.create returned no session key");
}
await setSession(result.key);
chatLog.addSystem(`new session: ${result.key}`);
} catch (err) {
chatLog.addSystem(`new session failed: ${sanitizeRenderableText(String(err))}`);
} finally {
sessionCreationInFlight = false;
}
break;
case "reset":
@@ -782,6 +802,11 @@ export function createCommandHandlers(context: CommandHandlerContext) {
tui.requestRender();
return;
}
if (sessionCreationInFlight) {
chatLog.addSystem("session change in progress; message not sent");
tui.requestRender();
return;
}
const isBtw = isBtwCommand(text);
const busy = Boolean(
state.activeChatRunId || state.pendingChatRunId || state.pendingOptimisticUserMessage,

View File

@@ -318,6 +318,12 @@ async function writeTuiPtyFixtureScript(dir: string) {
};
}
async createSession(opts: Parameters<TuiBackend["createSession"]>[0]) {
record("createSession", opts);
const key = "agent:main:" + opts.key;
return { ok: true, key, entry: { ...sessionEntry(key), sessionId: "created-session" } };
}
async resetSession(key: string, reason?: "new" | "reset") {
record("resetSession", { key, reason });
return {};
@@ -640,6 +646,24 @@ describe.sequential("TUI PTY harness", () => {
TEST_TIMEOUT_MS,
);
it(
"creates a backend session from /new and adopts its canonical key",
async () => {
await fixture.run.write("/new\r", { delay: false });
await fixture.run.waitForOutput("new session: agent:main:tui-");
const created = await fixture.waitForLogEntry((entry) => entry.method === "createSession");
expect(created.payload).toMatchObject({ agentId: "main" });
expect(created.payload).not.toHaveProperty("parentSessionKey");
await fixture.run.write("after new\r", { delay: false });
const sent = await fixture.waitForLogEntry(
(entry) => entry.method === "sendChat" && objectFieldEquals(entry, "message", "after new"),
);
expect(sent.payload).toMatchObject({ sessionKey: expect.stringMatching(/^agent:main:tui-/) });
},
TEST_TIMEOUT_MS,
);
it(
"resets the current session from /reset",
async () => {
@@ -654,7 +678,7 @@ describe.sequential("TUI PTY harness", () => {
return false;
}
const key = (entry.payload as Record<string, unknown>).key;
return key === "main" || key === "agent:main:main";
return typeof key === "string" && (key === "main" || key.startsWith("agent:main:"));
});
},
TEST_TIMEOUT_MS,

View File

@@ -28,6 +28,17 @@ const LOCAL_OUTPUT_TIMEOUT_MS = 120_000;
const LOCAL_EXIT_TIMEOUT_MS = 4_000;
const LOCAL_TEST_TIMEOUT_MS = 150_000;
async function waitForOutputAfter(run: PtyRun, needle: string, offset: number) {
await waitFor({
timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS,
read: () => (run.output().slice(offset).includes(needle) ? true : null),
onTimeout: () =>
new Error(
`timed out waiting for ${JSON.stringify(needle)} after offset ${offset}\n${run.output()}`,
),
});
}
async function readRequestBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
@@ -414,6 +425,24 @@ describe("TUI PTY real backends", () => {
expect(request?.body.model).toBe("gpt-5.5");
await fixture.run.waitForOutput("LOCAL_PTY_RESPONSE");
// Text deltas arrive before the terminal lifecycle event. Wait for the
// finished run to become idle so /new exercises session creation.
const responseOffset = fixture.run.output().lastIndexOf("LOCAL_PTY_RESPONSE");
await waitForOutputAfter(fixture.run, "| idle", responseOffset);
await fixture.run.write("/new\r", { delay: false });
await fixture.run.waitForOutput("new session: agent:main:tui-");
await fixture.run.write("send after local new\r");
await waitFor({
timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS,
read: () => (fixture.mockModel.requests().length === 2 ? true : null),
onTimeout: () =>
new Error(`post-/new prompt did not reach the model\n${fixture.run.output()}`),
});
expect(JSON.stringify(fixture.mockModel.requests()[1]?.body)).toContain(
"send after local new",
);
await fixture.run.write("/exit\r", { delay: false });
const exit = await fixture.run.waitForExit();
expect(exit.exitCode).toBe(0);
@@ -424,6 +453,45 @@ describe("TUI PTY real backends", () => {
LOCAL_TEST_TIMEOUT_MS,
);
it(
"creates and adopts a fresh session through the real Gateway backend",
async () => {
const fixture = await startGatewayModeTui({
queueMode: "followup",
firstResponseDelayMs: 0,
});
try {
await fixture.run.waitForOutput("gateway connected", LOCAL_STARTUP_TIMEOUT_MS);
await fixture.run.write("seed gateway session\r");
await fixture.run.waitForOutput("FIRST_RUN_ACTIVE");
const responseOffset = fixture.run.output().lastIndexOf("FIRST_RUN_ACTIVE");
await waitForOutputAfter(fixture.run, "| idle", responseOffset);
await fixture.run.write("/new\r", { delay: false });
await fixture.run.waitForOutput("new session: agent:main:tui-");
await fixture.run.write("send after gateway new\r");
await waitFor({
timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS,
read: () => (fixture.mockModel.requests().length === 2 ? true : null),
onTimeout: () =>
new Error(
`post-/new Gateway prompt did not reach the model\n${fixture.gateway.logs()}\n${fixture.run.output()}`,
),
});
const freshRequest = JSON.stringify(fixture.mockModel.requests()[1]?.body);
expect(freshRequest).toContain("send after gateway new");
expect(freshRequest).not.toContain("seed gateway session");
await fixture.run.write("/exit\r", { delay: false });
expect((await fixture.run.waitForExit()).exitCode).toBe(0);
} finally {
await fixture.cleanup();
}
},
LOCAL_TEST_TIMEOUT_MS,
);
it(
"forwards an active-run prompt through the real Gateway followup queue",
async () => {

View File

@@ -121,6 +121,7 @@ describe("tui session actions", () => {
sessions: [
{
key: "agent:main:main",
sessionId: "session-old",
model: "old",
modelProvider: "anthropic",
},
@@ -141,6 +142,7 @@ describe("tui session actions", () => {
sessions: [
{
key: "agent:main:main",
sessionId: "session-current",
model: "Minimax-M2.7",
modelProvider: "minimax",
},
@@ -150,6 +152,7 @@ describe("tui session actions", () => {
await Promise.all([first, second]);
expect(state.sessionInfo.model).toBe("Minimax-M2.7");
expect(state.currentSessionId).toBe("session-current");
expect(updateAutocompleteProvider).toHaveBeenCalledTimes(2);
expect(updateFooter).toHaveBeenCalledTimes(2);
expect(requestRender).toHaveBeenCalledTimes(2);
@@ -762,71 +765,6 @@ describe("tui session actions", () => {
expect(state.pendingChatRunId).toBeNull();
});
it("starts an empty session without loading gateway history", async () => {
const loadHistory = vi.fn().mockResolvedValue({ messages: [] });
const listSessions = vi.fn().mockResolvedValue({ sessions: [] });
const addSystem = vi.fn();
const clearAll = vi.fn();
const requestRender = vi.fn();
const rememberSessionKey = vi.fn();
const state = createBaseState({
activeChatRunId: "run-1",
pendingChatRunId: "run-2",
pendingOptimisticUserMessage: true,
currentSessionId: "old-session",
historyLoaded: false,
sessionInfo: {
model: "old-model",
modelProvider: "old-provider",
contextTokens: 99,
thinkingLevel: "high",
fastMode: false,
verboseLevel: "debug",
inputTokens: 1,
outputTokens: 2,
totalTokens: 3,
},
});
const { setEmptySession } = createTestSessionActions({
client: { listSessions, loadHistory } as unknown as TuiBackend,
chatLog: {
addSystem,
clearAll,
} as unknown as import("./components/chat-log.js").ChatLog,
tui: { requestRender } as unknown as import("@earendil-works/pi-tui").TUI,
state,
rememberSessionKey,
emptySessionInfoDefaults: {
verboseLevel: "on",
},
});
await setEmptySession("agent:main:tui-empty");
expect(loadHistory).not.toHaveBeenCalled();
expect(listSessions).not.toHaveBeenCalled();
expect(state.currentSessionKey).toBe("agent:main:tui-empty");
expect(state.currentSessionId).toBeNull();
expect(state.activeChatRunId).toBeNull();
expect(state.pendingChatRunId).toBeNull();
expect(state.pendingOptimisticUserMessage).toBe(false);
expect(state.historyLoaded).toBe(true);
expect(state.sessionInfo.model).toBeUndefined();
expect(state.sessionInfo.modelProvider).toBeUndefined();
expect(state.sessionInfo.contextTokens).toBeNull();
expect(state.sessionInfo.thinkingLevel).toBeUndefined();
expect(state.sessionInfo.fastMode).toBeUndefined();
expect(state.sessionInfo.verboseLevel).toBe("on");
expect(state.sessionInfo.inputTokens).toBeNull();
expect(state.sessionInfo.outputTokens).toBeNull();
expect(state.sessionInfo.totalTokens).toBeNull();
expect(clearAll).toHaveBeenCalled();
expect(addSystem).toHaveBeenCalledWith("session agent:main:tui-empty");
expect(rememberSessionKey).toHaveBeenCalledWith("agent:main:tui-empty");
expect(requestRender).toHaveBeenCalled();
});
it("applies reset mutation result without reloading gateway history", () => {
const loadHistory = vi.fn().mockResolvedValue({ messages: [] });
const addSystem = vi.fn();

View File

@@ -37,7 +37,6 @@ type SessionActionContext = {
setActivityStatus: (text: string) => void;
clearLocalRunIds?: () => void;
rememberSessionKey?: (sessionKey: string) => void | Promise<void>;
emptySessionInfoDefaults?: SessionInfo;
};
type SessionInfoDefaults = {
@@ -118,7 +117,6 @@ export function createSessionActions(context: SessionActionContext) {
setActivityStatus,
clearLocalRunIds,
rememberSessionKey,
emptySessionInfoDefaults,
} = context;
let refreshSessionInfoInFlight: Promise<void> | null = null;
let refreshSessionInfoQueued = false;
@@ -340,6 +338,7 @@ export function createSessionActions(context: SessionActionContext) {
state.currentSessionKey = entry.key;
updateHeader();
}
state.currentSessionId = typeof entry?.sessionId === "string" ? entry.sessionId : null;
applySessionInfo({
entry,
defaults: result.defaults,
@@ -583,37 +582,6 @@ export function createSessionActions(context: SessionActionContext) {
await loadHistory();
};
const setEmptySession = async (rawKey: string) => {
const nextKey = resolveSessionKey(rawKey);
updateAgentFromSessionKey(nextKey);
state.currentSessionKey = nextKey;
state.activeChatRunId = null;
state.pendingChatRunId = null;
state.pendingOptimisticUserMessage = false;
state.pendingSubmitDraft = null;
setActivityStatus("idle");
state.currentSessionId = null;
const defaults = lastSessionDefaults;
state.sessionInfo = {
...emptySessionInfoDefaults,
modelProvider: defaults?.modelProvider ?? undefined,
model: defaults?.model ?? undefined,
contextTokens: defaults?.contextTokens ?? null,
thinkingLevels: defaults?.thinkingLevels ?? emptySessionInfoDefaults?.thinkingLevels,
inputTokens: null,
outputTokens: null,
totalTokens: null,
goal: undefined,
updatedAt: null,
displayName: undefined,
};
clearLocalRunIds?.();
updateHeader();
updateAutocompleteProvider();
updateFooter();
clearDisplayedSession();
};
const abortActive = async (params?: { preferActive?: boolean }) => {
if (
opts.local === true &&
@@ -674,7 +642,6 @@ export function createSessionActions(context: SessionActionContext) {
applySessionMutationResult,
loadHistory,
setSession,
setEmptySession,
abortActive,
};
}

View File

@@ -1305,7 +1305,6 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
setActivityStatus,
clearLocalRunIds,
rememberSessionKey: rememberCurrentSessionKey,
emptySessionInfoDefaults,
});
const {
refreshAgents,
@@ -1314,7 +1313,6 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
applySessionMutationResult,
loadHistory,
setSession,
setEmptySession,
abortActive,
} = sessionActions;
@@ -1411,7 +1409,6 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
applySessionMutationResult,
loadHistory,
setSession,
setEmptySession,
refreshAgents,
abortActive,
setActivityStatus,