From cd305b3b0cb9fcedaccfd5cec3b0ced1fde032ce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 06:48:05 -0700 Subject: [PATCH] fix(tui): run new sessions through lifecycle hooks (#100241) * fix(tui): run new sessions through lifecycle hooks Co-authored-by: chunqiu * test(tui): wait for idle before PTY new-session checks * docs(changelog): defer TUI batch entries --------- Co-authored-by: chunqiu --- src/crestodian/tui-backend.ts | 10 + src/gateway/server-methods/sessions.ts | 457 +++------------- src/gateway/server.sessions.create.test.ts | 19 + .../server.sessions.reset-hooks.test.ts | 126 +++++ src/gateway/session-create-service.ts | 494 ++++++++++++++++++ src/tui/embedded-backend.test.ts | 40 ++ src/tui/embedded-backend.ts | 18 + src/tui/gateway-chat.ts | 8 + src/tui/tui-backend.ts | 8 + src/tui/tui-command-handlers.test.ts | 143 ++++- src/tui/tui-command-handlers.ts | 39 +- src/tui/tui-pty-harness.e2e.test.ts | 26 +- src/tui/tui-pty-local.e2e.test.ts | 68 +++ src/tui/tui-session-actions.test.ts | 68 +-- src/tui/tui-session-actions.ts | 35 +- src/tui/tui.ts | 3 - 16 files changed, 1055 insertions(+), 507 deletions(-) create mode 100644 src/gateway/session-create-service.ts diff --git a/src/crestodian/tui-backend.ts b/src/crestodian/tui-backend.ts index 73d6fe498616..c1553a3b30f3 100644 --- a/src/crestodian/tui-backend.ts +++ b/src/crestodian/tui-backend.ts @@ -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 { const overview = await loadOverviewForTui(this.opts); return overview.gateway.reachable ? "Gateway reachable" : "Gateway unreachable"; diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 12e783c93329..900f85bb4761 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -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 { - 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 }; - -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 | undefined; let runError: unknown; let runMeta: Record | 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; - } 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; + } 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 ( diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 97dc5a8b2ad0..f637d181ef5d 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -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", + }); +}); diff --git a/src/gateway/server.sessions.reset-hooks.test.ts b/src/gateway/server.sessions.reset-hooks.test.ts index 487f64b0bbd2..191f78140576 100644 --- a/src/gateway/server.sessions.reset-hooks.test.ts +++ b/src/gateway/server.sessions.reset-hooks.test.ts @@ -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((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"); diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts new file mode 100644 index 000000000000..edc64c42eafa --- /dev/null +++ b/src/gateway/session-create-service.ts @@ -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 { + 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; + afterCreate?: (created: CreatedGatewaySession) => Promise; +}): Promise { + 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 => { + 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( + { + 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; +} diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index f9b1a0d31a1a..34b638a05d0c 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -21,6 +21,7 @@ const updateSessionGoalStatusMock = vi.fn(); const ensureRuntimePluginsLoadedMock = vi.fn(); const ensureContextWindowCacheLoadedMock = vi.fn(async () => undefined); const runSessionStartupMigrationMock = vi.fn<() => Promise>(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<{ diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index a6cb313255a7..21ca8525095b 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -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; diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 88fecef9ac38..a2768a7ba7b1 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -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("sessions.patch", opts); } + async createSession(opts: TuiSessionCreateOptions): Promise { + return await this.client.request("sessions.create", { + ...opts, + emitCommandHooks: Boolean(opts.parentSessionKey), + }); + } + async resetSession( key: string, reason?: "new" | "reset", diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts index d661b7540c2e..98058d756ea7 100644 --- a/src/tui/tui-backend.ts +++ b/src/tui/tui-backend.ts @@ -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; listAgents: () => Promise; patchSession: (opts: SessionsPatchParams) => Promise; + createSession: (opts: TuiSessionCreateOptions) => Promise; resetSession: ( key: string, reason?: "new" | "reset", diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index ba1b4a950764..50b76c15281d 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -17,7 +17,6 @@ type SelectableOverlay = { }; type SetActivityStatusMock = ReturnType & ((text: string) => void); type SetSessionMock = ReturnType & ((key: string) => Promise); -type SetEmptySessionMock = ReturnType & ((key: string) => Promise); type ConsumeCompletedRunMock = ReturnType & ((runId: string) => boolean); type FlushPendingHistoryRefreshMock = ReturnType & (() => void); @@ -81,11 +80,11 @@ function createHarness(params?: { listSessions?: ReturnType; listModels?: ReturnType; patchSession?: ReturnType; + createSession?: ReturnType; resetSession?: ReturnType; runGoalCommand?: ReturnType; runAuthFlow?: RunAuthFlow; setSession?: SetSessionMock; - setEmptySession?: SetEmptySessionMock; loadHistory?: LoadHistoryMock; refreshSessionInfo?: ReturnType; applySessionInfoFromPatch?: ReturnType; @@ -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, }); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index b1b935b971ca..44cb27347a06 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -57,7 +57,6 @@ type CommandHandlerContext = { refreshSessionInfo: () => Promise; loadHistory: () => Promise; setSession: (key: string) => Promise; - setEmptySession: (key: string) => Promise; refreshAgents: () => Promise; abortActive: (params?: { preferActive?: boolean }) => Promise; 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, diff --git a/src/tui/tui-pty-harness.e2e.test.ts b/src/tui/tui-pty-harness.e2e.test.ts index 50933af69ea3..926abb9a4cc1 100644 --- a/src/tui/tui-pty-harness.e2e.test.ts +++ b/src/tui/tui-pty-harness.e2e.test.ts @@ -318,6 +318,12 @@ async function writeTuiPtyFixtureScript(dir: string) { }; } + async createSession(opts: Parameters[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).key; - return key === "main" || key === "agent:main:main"; + return typeof key === "string" && (key === "main" || key.startsWith("agent:main:")); }); }, TEST_TIMEOUT_MS, diff --git a/src/tui/tui-pty-local.e2e.test.ts b/src/tui/tui-pty-local.e2e.test.ts index e8227ce154e2..83c222515bb1 100644 --- a/src/tui/tui-pty-local.e2e.test.ts +++ b/src/tui/tui-pty-local.e2e.test.ts @@ -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 { 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 () => { diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index aecc6da0e566..b4005d61eada 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -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(); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 339ccd3ec4f7..b644caf16c57 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -37,7 +37,6 @@ type SessionActionContext = { setActivityStatus: (text: string) => void; clearLocalRunIds?: () => void; rememberSessionKey?: (sessionKey: string) => void | Promise; - emptySessionInfoDefaults?: SessionInfo; }; type SessionInfoDefaults = { @@ -118,7 +117,6 @@ export function createSessionActions(context: SessionActionContext) { setActivityStatus, clearLocalRunIds, rememberSessionKey, - emptySessionInfoDefaults, } = context; let refreshSessionInfoInFlight: Promise | 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, }; } diff --git a/src/tui/tui.ts b/src/tui/tui.ts index e6aa96242708..b33c08bdeb58 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -1305,7 +1305,6 @@ export async function runTui(opts: RunTuiOptions): Promise { setActivityStatus, clearLocalRunIds, rememberSessionKey: rememberCurrentSessionKey, - emptySessionInfoDefaults, }); const { refreshAgents, @@ -1314,7 +1313,6 @@ export async function runTui(opts: RunTuiOptions): Promise { applySessionMutationResult, loadHistory, setSession, - setEmptySession, abortActive, } = sessionActions; @@ -1411,7 +1409,6 @@ export async function runTui(opts: RunTuiOptions): Promise { applySessionMutationResult, loadHistory, setSession, - setEmptySession, refreshAgents, abortActive, setActivityStatus,