diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index efcaeafd3a05..48f0122d5d74 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -1723,7 +1723,7 @@ }, { "kind": "ui-call", - "line": 1035, + "line": 1036, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Wait for the current response to finish before starting a new chat.", "surface": "android", @@ -1731,7 +1731,7 @@ }, { "kind": "ui-call", - "line": 1167, + "line": 1169, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not update model.", "surface": "android", @@ -1739,7 +1739,7 @@ }, { "kind": "ui-call", - "line": 1232, + "line": 1234, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not update thinking level.", "surface": "android", @@ -1747,7 +1747,7 @@ }, { "kind": "ui-call", - "line": 1725, + "line": 1727, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Chat failed before the run started; try again.", "surface": "android", @@ -1755,7 +1755,7 @@ }, { "kind": "ui-call", - "line": 3007, + "line": 3009, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not stage an attachment for sending.", "surface": "android", @@ -1763,7 +1763,7 @@ }, { "kind": "ui-call", - "line": 3040, + "line": 3042, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.", "surface": "android", @@ -1771,7 +1771,7 @@ }, { "kind": "ui-call", - "line": 3046, + "line": 3048, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Attachments are too large to queue for one message; remove some and try again.", "surface": "android", @@ -1779,7 +1779,7 @@ }, { "kind": "ui-call", - "line": 3052, + "line": 3054, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Offline attachment storage is full; delete queued items first.", "surface": "android", @@ -1787,7 +1787,7 @@ }, { "kind": "ui-call", - "line": 3057, + "line": 3059, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Gateway health not OK; cannot send", "surface": "android", @@ -1795,7 +1795,7 @@ }, { "kind": "ui-call", - "line": 3064, + "line": 3066, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not queue message for later delivery.", "surface": "android", @@ -1803,7 +1803,7 @@ }, { "kind": "ui-call", - "line": 3781, + "line": 3783, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Chat failed", "surface": "android", @@ -1811,7 +1811,7 @@ }, { "kind": "ui-call", - "line": 3919, + "line": 3921, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Event stream interrupted; try refreshing.", "surface": "android", @@ -1819,7 +1819,7 @@ }, { "kind": "ui-call", - "line": 4056, + "line": 4058, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Timed out waiting for a reply; try again or refresh.", "surface": "android", @@ -1827,7 +1827,7 @@ }, { "kind": "ui-call", - "line": 4266, + "line": 4268, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Timed out confirming the sent message; refresh to check delivery.", "surface": "android", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index be1e81df62e6..de57c82e5df3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -4,6 +4,7 @@ import ai.openclaw.app.GatewayModelSummary import ai.openclaw.app.gateway.GatewayRequestDefinitiveFailure import ai.openclaw.app.gateway.GatewayRequestNotEnqueued import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown +import ai.openclaw.app.gateway.GatewayRequestRejected import ai.openclaw.app.gateway.GatewaySession import ai.openclaw.app.gateway.QuestionListResult import ai.openclaw.app.gateway.QuestionRecord @@ -1050,11 +1051,12 @@ class ChatController internal constructor( if (hasLoadedParentSession) { put("parentSessionKey", JsonPrimitive(parentKey)) put("emitCommandHooks", JsonPrimitive(true)) + put("succeedsParent", JsonPrimitive(false)) } put("label", JsonPrimitive(label)) if (worktree) put("worktree", JsonPrimitive(true)) } - val res = requestGatewayBound(createGatewayId, "sessions.create", params.toString()) + val res = requestSessionCreateWithDispositionFallback(createGatewayId, params) if (!isCurrentHistoryLoad(parentKey, _sessionKey.value, requestGeneration, historyLoadGeneration.get())) { return false } @@ -4745,6 +4747,31 @@ class ChatController internal constructor( requestGatewayForGateway(gatewayId, method, paramsJson) } + private suspend fun requestSessionCreateWithDispositionFallback( + gatewayId: String?, + params: JsonObject, + ): String = + try { + requestGatewayBound(gatewayId, "sessions.create", params.toString()) + } catch (err: GatewayRequestRejected) { + val message = err.gatewayError.message + val isOlderGateway = + err.gatewayError.code == "INVALID_REQUEST" && + message.contains("invalid sessions.create params") && + message.contains("succeedsParent") + if (!isOlderGateway || "succeedsParent" !in params) throw err + + // Older Gateways cannot express a linked parallel child. Keep New Chat parallel by + // dropping the parent lifecycle fields instead of falling back to legacy rollover. + val legacyParams = + JsonObject( + params.filterKeys { key -> + key != "succeedsParent" && key != "parentSessionKey" && key != "emitCommandHooks" + }, + ) + requestGatewayBound(gatewayId, "sessions.create", legacyParams.toString()) + } + private fun currentCacheScope(): ChatCacheScope? = normalizedChatCacheScope(cacheScope()) /** Keeps an unscoped chat bound to its verified agent only while the same gateway reconnects. */ diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerCommandControlsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerCommandControlsTest.kt index 4b7acd3f4d54..70679643a951 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerCommandControlsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerCommandControlsTest.kt @@ -1,5 +1,7 @@ package ai.openclaw.app.chat +import ai.openclaw.app.gateway.GatewayRequestRejected +import ai.openclaw.app.gateway.GatewaySession import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -249,6 +251,7 @@ class ChatControllerCommandControlsTest { assertTrue(create.second.orEmpty().contains("\"agentId\":\"main\"")) assertTrue(create.second.orEmpty().contains("\"parentSessionKey\":\"main\"")) assertTrue(create.second.orEmpty().contains("\"emitCommandHooks\":true")) + assertTrue(create.second.orEmpty().contains("\"succeedsParent\":false")) assertTrue(create.second.orEmpty().contains("\"label\":\"New chat\"")) assertEquals("agent:main:dashboard:fresh", controller.sessionKey.value) assertEquals("fresh-session", controller.sessionId.value) @@ -256,6 +259,56 @@ class ChatControllerCommandControlsTest { assertTrue(requests.any { it.first == "sessions.list" }) } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun startNewChatRetriesWithoutParentLifecycleAgainstOlderGateway() = + runTest { + val requests = mutableListOf>() + var createCalls = 0 + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.create" -> { + createCalls += 1 + if (createCalls == 1) { + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = + "invalid sessions.create params: at root: unexpected property 'succeedsParent'", + ), + ) + } + """{"ok":true,"key":"agent:main:dashboard:fresh"}""" + } + "chat.history" -> """{"sessionId":"fresh-session","messages":[]}""" + "health" -> "{}" + "sessions.list" -> """{"sessions":[]}""" + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + controller.load("main") + advanceUntilIdle() + + assertTrue(controller.startNewChatAwait()) + + val creates = requests.filter { it.first == "sessions.create" } + assertEquals(2, creates.size) + assertTrue(creates[0].second.orEmpty().contains("\"succeedsParent\":false")) + assertEquals(false, creates[1].second.orEmpty().contains("\"succeedsParent\"")) + assertEquals(false, creates[1].second.orEmpty().contains("\"parentSessionKey\"")) + assertEquals(false, creates[1].second.orEmpty().contains("\"emitCommandHooks\"")) + assertTrue(creates[1].second.orEmpty().contains("\"agentId\":\"main\"")) + assertTrue(creates[1].second.orEmpty().contains("\"label\":\"New chat\"")) + assertEquals("agent:main:dashboard:fresh", controller.sessionKey.value) + } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun startNewChatInWorktreeIncludesWorktreeFlag() = diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index be9b00036f82..76d3ac69d3a3 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -5053,6 +5053,7 @@ public struct SessionsCreateParams: Codable, Sendable { public let parentsessionkey: String? public let fork: Bool? public let emitcommandhooks: Bool? + public let succeedsparent: Bool? public let task: String? public let message: String? public let attachments: [AnyCodable]? @@ -5072,6 +5073,7 @@ public struct SessionsCreateParams: Codable, Sendable { parentsessionkey: String? = nil, fork: Bool? = nil, emitcommandhooks: Bool? = nil, + succeedsparent: Bool? = nil, task: String? = nil, message: String? = nil, attachments: [AnyCodable]? = nil, @@ -5090,6 +5092,7 @@ public struct SessionsCreateParams: Codable, Sendable { self.parentsessionkey = parentsessionkey self.fork = fork self.emitcommandhooks = emitcommandhooks + self.succeedsparent = succeedsparent self.task = task self.message = message self.attachments = attachments @@ -5110,6 +5113,7 @@ public struct SessionsCreateParams: Codable, Sendable { case parentsessionkey = "parentSessionKey" case fork case emitcommandhooks = "emitCommandHooks" + case succeedsparent = "succeedsParent" case task case message case attachments diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 6b12c40028d3..e5b8ff60d039 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -527,7 +527,7 @@ methods. Treat this as feature discovery, not a full enumeration of - `sessions.preview` returns bounded transcript previews for specific session keys. - `sessions.describe` returns one gateway session row for an exact session key. - `sessions.resolve` resolves or canonicalizes a session target. - - `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. + - `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. - `sessions.dispatch` (`operator.admin`) moves an existing local OpenClaw session with a session-owned managed worktree to a configured cloud-worker profile. Pass `{ key, profileId, agentId? }`. The method is absent when no worker profile is configured, closes local turn admission before draining active work, and returns only after placement reaches `active` worker ownership. Dispatch is one-way; worker-to-local pull-back is not part of this RPC. - `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` manage the gateway-owned custom session group catalog (names + display order). Membership stays on each session's `category` field; rename and delete update member sessions server-side. - `sessions.send` sends a message into an existing session. diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index 77d2b9999138..03b1f68cf7c8 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -167,6 +167,8 @@ observation-only. | `before_compaction` / `after_compaction` | Observe or annotate compaction cycles | | `before_reset` | Observe session-reset events (`/reset`, programmatic resets) | +For `sessions.create` calls with `parentSessionKey` and `emitCommandHooks: true`, a distinct child always receives `session_start`. Callers declare whether the parent also receives terminal `session_end` with `succeedsParent`: `true` means successor, `false` means parallel child. Omission preserves the legacy parent-rollover behavior. The `command:new` and `before_reset` hooks still describe the requested `/new` action in both cases. + **Subagents** - `subagent_spawned` / `subagent_ended` - observe subagent launch and completion. diff --git a/packages/gateway-protocol/src/schema/sessions-create.ts b/packages/gateway-protocol/src/schema/sessions-create.ts index c22b4d48ea63..cdbaa159417d 100644 --- a/packages/gateway-protocol/src/schema/sessions-create.ts +++ b/packages/gateway-protocol/src/schema/sessions-create.ts @@ -16,6 +16,12 @@ export const SessionsCreateParamsSchema = closedObject({ Type.Boolean({ description: "Fork the parent transcript; requires parentSessionKey." }), ), emitCommandHooks: Type.Optional(Type.Boolean()), + succeedsParent: Type.Optional( + Type.Boolean({ + description: + "When sessions.create creates a distinct child, whether that child succeeds its parent and emits the parent's terminal session_end. Requires parentSessionKey and emitCommandHooks. False keeps the parent active; omission preserves legacy behavior.", + }), + ), task: Type.Optional(Type.String()), message: Type.Optional(Type.String()), attachments: Type.Optional(ChatAttachmentsSchema), diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index 36de7dcfa994..58b1b512f116 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -1371,7 +1371,13 @@ describe("OpenClaw SDK", () => { }); const oc = new OpenClaw({ transport }); - const session = await oc.sessions.create({ key: "session-main", thinkingLevel: "high" }); + const session = await oc.sessions.create({ + key: "session-main", + thinkingLevel: "high", + parentSessionKey: "main", + emitCommandHooks: true, + succeedsParent: false, + }); const run = await session.send({ message: "continue", thinking: "medium", timeoutMs: 1_500 }); const noTimeoutRun = await session.send({ message: "continue without timeout", timeoutMs: 0 }); await session.compact(); @@ -1382,7 +1388,13 @@ describe("OpenClaw SDK", () => { { method: "sessions.create", options: undefined, - params: { key: "session-main", thinkingLevel: "high" }, + params: { + key: "session-main", + thinkingLevel: "high", + parentSessionKey: "main", + emitCommandHooks: true, + succeedsParent: false, + }, }, { method: "sessions.send", diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index f3d7a8ecc78d..290f4a51a282 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -343,6 +343,10 @@ export type SessionCreateParams = { model?: string; thinkingLevel?: string; parentSessionKey?: string; + /** Emit command and lifecycle hooks for parent-linked creation. */ + emitCommandHooks?: boolean; + /** Whether a distinct child terminates its parent; requires command hooks. */ + succeedsParent?: boolean; task?: string; message?: string; attachments?: unknown[]; diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index 0dc0acf8fcb9..2337c1d7acde 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -317,6 +317,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { // A plain New Chat that resets an existing session must not inherit its prior worktree cwd. clearSpawnedCwd: p.worktree !== true, fork: p.fork, + succeedsParent: p.succeedsParent, emitCommandHooks: p.emitCommandHooks, resetMainWhenUnspecified: !hasInitialTurn, commandSource: "webchat", diff --git a/src/gateway/server.sessions.reset-hooks.succession.test.ts b/src/gateway/server.sessions.reset-hooks.succession.test.ts new file mode 100644 index 000000000000..e07e155b17e8 --- /dev/null +++ b/src/gateway/server.sessions.reset-hooks.succession.test.ts @@ -0,0 +1,124 @@ +// sessions.create parent-disposition coverage. Kept separate because the main +// reset-hook suite is already at its max-lines budget. +import { expect, test } from "vitest"; +import { writeSessionStore } from "./test-helpers.js"; +import { + beforeResetHookMocks, + beforeResetHookState, + directSessionReq, + seedSessionTranscript, + sessionLifecycleHookMocks, + setupGatewaySessionsTestHarness, +} from "./test/server-sessions.test-helpers.js"; + +const { createSessionStoreDir } = setupGatewaySessionsTestHarness(); + +type HookEvent = { + sessionKey?: string; + nextSessionKey?: string; +}; + +function firstHookEvent(mock: { mock: { calls: unknown[][] } }): HookEvent { + const call = mock.mock.calls.at(0); + if (!call) { + throw new Error("Expected hook call"); + } + return call[0] as HookEvent; +} + +async function seedParent(sessionId: string) { + const { storePath } = await createSessionStoreDir(); + await writeSessionStore({ + entries: { main: { sessionId, updatedAt: Date.now() } }, + }); + await seedSessionTranscript({ + agentId: "main", + sessionId, + sessionKey: "agent:main:main", + storePath, + messages: [{ role: "user", content: "before child creation", id: "m1" }], + }); +} + +test("sessions.create keeps the parent active for an explicit parallel child", async () => { + await seedParent("sess-parallel"); + beforeResetHookState.hasBeforeResetHook = true; + + const result = await directSessionReq<{ key: string }>("sessions.create", { + parentSessionKey: "main", + emitCommandHooks: true, + succeedsParent: false, + }); + + expect(result.ok).toBe(true); + expect(result.payload?.key).toMatch(/^agent:main:dashboard:/); + expect(beforeResetHookMocks.runBeforeReset).toHaveBeenCalledTimes(1); + expect(sessionLifecycleHookMocks.runSessionEnd).not.toHaveBeenCalled(); + expect(firstHookEvent(sessionLifecycleHookMocks.runSessionStart).sessionKey).toBe( + result.payload?.key, + ); +}); + +test("sessions.create accepts an explicit successor with a minted dashboard key", async () => { + await seedParent("sess-successor"); + + const result = await directSessionReq<{ key: string }>("sessions.create", { + parentSessionKey: "main", + emitCommandHooks: true, + succeedsParent: true, + }); + + expect(result.ok).toBe(true); + expect(result.payload?.key).toMatch(/^agent:main:dashboard:/); + const endEvent = firstHookEvent(sessionLifecycleHookMocks.runSessionEnd); + expect(endEvent.sessionKey).toBe("agent:main:main"); + expect(endEvent.nextSessionKey).toBe(result.payload?.key); + expect(firstHookEvent(sessionLifecycleHookMocks.runSessionStart).sessionKey).toBe( + result.payload?.key, + ); +}); + +test("sessions.create rejects an explicit successor fork", async () => { + await seedParent("sess-fork"); + + const result = await directSessionReq("sessions.create", { + key: "forked-child", + parentSessionKey: "main", + emitCommandHooks: true, + fork: true, + succeedsParent: true, + }); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ code: "INVALID_REQUEST" }); + expect(result.error?.message).toMatch(/fork/i); + expect(sessionLifecycleHookMocks.runSessionEnd).not.toHaveBeenCalled(); +}); + +test("sessions.create requires a parent for either explicit disposition", async () => { + await createSessionStoreDir(); + + const result = await directSessionReq("sessions.create", { + key: "parallel-child", + emitCommandHooks: true, + succeedsParent: false, + }); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ code: "INVALID_REQUEST" }); + expect(result.error?.message).toMatch(/parentSessionKey/i); +}); + +test("sessions.create requires command hooks for either explicit disposition", async () => { + await seedParent("sess-no-hooks"); + + const result = await directSessionReq("sessions.create", { + key: "parallel-child", + parentSessionKey: "main", + succeedsParent: false, + }); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ code: "INVALID_REQUEST" }); + expect(result.error?.message).toMatch(/emitCommandHooks/i); +}); diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index 4053b6be2b26..6435ed941c37 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -203,6 +203,11 @@ export async function createGatewaySession(params: { clearExecBinding?: boolean; clearSpawnedCwd?: boolean; fork?: boolean; + /** + * Controls whether a distinct child terminates its parent. Omission preserves + * the legacy rollover; callers use `false` for a parallel child. + */ + succeedsParent?: boolean; emitCommandHooks?: boolean; resetMainWhenUnspecified?: boolean; commandSource: string; @@ -216,6 +221,7 @@ export async function createGatewaySession(params: { afterCreate?: (created: CreatedGatewaySession) => Promise; }): Promise { const requestedKey = normalizeOptionalString(params.key); + const parentSessionKey = normalizeOptionalString(params.parentSessionKey); const agentId = normalizeAgentId( normalizeOptionalString(params.agentId) ?? resolveDefaultAgentId(params.cfg), ); @@ -228,6 +234,29 @@ export async function createGatewaySession(params: { error: errorShape(ErrorCodes.INVALID_REQUEST, "invalid catalog session target"), }; } + if (params.succeedsParent !== undefined) { + if (!parentSessionKey) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "succeedsParent requires parentSessionKey"), + }; + } + if (params.emitCommandHooks !== true) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "succeedsParent requires emitCommandHooks"), + }; + } + if (params.succeedsParent && params.fork === true) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "succeedsParent conflicts with fork: a fork runs in parallel to its parent", + ), + }; + } + } if (requestedKey) { const requestedAgentId = parseAgentSessionKey(requestedKey)?.agentId; if ( @@ -305,7 +334,6 @@ export async function createGatewaySession(params: { }; } - const parentSessionKey = normalizeOptionalString(params.parentSessionKey); if (params.fork === true && !parentSessionKey) { return { ok: false, @@ -735,17 +763,21 @@ export async function createGatewaySession(params: { const parentEntry = currentParentSessionEntry; const { emitGatewaySessionEndPluginHook, emitGatewaySessionStartPluginHook } = await loadSessionLifecycleRuntime(); - emitGatewaySessionEndPluginHook({ - cfg: params.cfg, - sessionKey: canonicalParentSessionKey, - sessionId: parentEntry?.sessionId, - storePath: parentSessionTarget.storePath, - sessionFile: parentEntry?.sessionFile, - agentId: parentSessionTarget.agentId, - reason: "new", - nextSessionId: created.entry.sessionId, - nextSessionKey: target.canonicalKey, - }); + // Child key shape does not establish lifecycle ownership. The caller owns + // that fact; omission keeps the shipped rollover for out-of-tree clients. + if (params.succeedsParent !== false) { + emitGatewaySessionEndPluginHook({ + cfg: params.cfg, + sessionKey: canonicalParentSessionKey, + sessionId: parentEntry?.sessionId, + storePath: parentSessionTarget.storePath, + sessionFile: parentEntry?.sessionFile, + agentId: parentSessionTarget.agentId, + reason: "new", + nextSessionId: created.entry.sessionId, + nextSessionKey: target.canonicalKey, + }); + } emitGatewaySessionStartPluginHook({ cfg: params.cfg, sessionKey: target.canonicalKey, diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index 250ff31bc281..193d4d2c116d 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -841,6 +841,81 @@ describe("GatewayChatClient", () => { expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { sessionKey: "main" }); }); + it("retries session creation without disposition on older Gateways", async () => { + const client = new GatewayChatClient({ + url: "ws://127.0.0.1:18789", + token: "test-token", + allowInsecureLocalOperatorUi: true, + }); + const request = vi + .fn() + .mockRejectedValueOnce( + new GatewayClientRequestError({ + code: "INVALID_REQUEST", + message: "invalid sessions.create params: at root: unexpected property 'succeedsParent'", + }), + ) + .mockResolvedValueOnce({ ok: true, key: "agent:main:tui-next" }); + (client as unknown as { client: { request: typeof request } }).client.request = request; + + await expect( + client.createSession({ + key: "tui-next", + parentSessionKey: "agent:main:main", + succeedsParent: true, + }), + ).resolves.toEqual({ ok: true, key: "agent:main:tui-next" }); + expect(request).toHaveBeenNthCalledWith(1, "sessions.create", { + key: "tui-next", + parentSessionKey: "agent:main:main", + succeedsParent: true, + emitCommandHooks: true, + }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.create", { + key: "tui-next", + parentSessionKey: "agent:main:main", + emitCommandHooks: true, + }); + }); + + it("retries parallel session creation without parent lifecycle on older Gateways", async () => { + const client = new GatewayChatClient({ + url: "ws://127.0.0.1:18789", + token: "test-token", + allowInsecureLocalOperatorUi: true, + }); + const request = vi + .fn() + .mockRejectedValueOnce( + new GatewayClientRequestError({ + code: "INVALID_REQUEST", + message: "invalid sessions.create params: at root: unexpected property 'succeedsParent'", + }), + ) + .mockResolvedValueOnce({ ok: true, key: "agent:main:tui-parallel" }); + (client as unknown as { client: { request: typeof request } }).client.request = request; + + await expect( + client.createSession({ + key: "tui-parallel", + agentId: "main", + parentSessionKey: "agent:main:main", + succeedsParent: false, + }), + ).resolves.toEqual({ ok: true, key: "agent:main:tui-parallel" }); + expect(request).toHaveBeenNthCalledWith(1, "sessions.create", { + key: "tui-parallel", + agentId: "main", + parentSessionKey: "agent:main:main", + succeedsParent: false, + emitCommandHooks: true, + }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.create", { + key: "tui-parallel", + agentId: "main", + }); + }); + it("returns the actual chat send ack status from the gateway", async () => { const client = new GatewayChatClient({ url: "ws://127.0.0.1:18789", diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index f99b32d36e56..fb9ad04c34c3 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -117,6 +117,14 @@ function isLegacyPreserveSideRunsError(err: unknown): boolean { return message.includes("invalid chat.abort params") && message.includes("preservesideruns"); } +function isLegacySucceedsParentError(err: unknown): boolean { + if (!(err instanceof GatewayClientRequestError) || err.gatewayCode !== "INVALID_REQUEST") { + return false; + } + const message = err.message.toLowerCase(); + return message.includes("invalid sessions.create params") && message.includes("succeedsparent"); +} + type GatewaySessionList = TuiSessionList; type GatewayAgentsList = TuiAgentsList; type GatewayModelChoice = TuiModelChoice; @@ -304,10 +312,33 @@ export class GatewayChatClient implements TuiBackend { } async createSession(opts: TuiSessionCreateOptions): Promise { - return await this.client.request("sessions.create", { + const params = { ...opts, emitCommandHooks: Boolean(opts.parentSessionKey), - }); + }; + try { + return await this.client.request("sessions.create", params); + } catch (err) { + if (opts.succeedsParent === undefined || !isLegacySucceedsParentError(err)) { + throw err; + } + const { succeedsParent: _succeedsParent, ...legacyParams } = params; + if (!opts.succeedsParent) { + // Older Gateways cannot express a linked parallel child. Preserve the + // parent's lifecycle by retrying as an unlinked child, never a rollover. + const { + parentSessionKey: _parentSessionKey, + emitCommandHooks: _emitCommandHooks, + ...parallelParams + } = legacyParams; + return await this.client.request( + "sessions.create", + parallelParams, + ); + } + // Legacy rollover is equivalent to an explicit successor request. + return await this.client.request("sessions.create", legacyParams); + } } async resetSession( diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts index d29d52a72642..40381fdeb449 100644 --- a/src/tui/tui-backend.ts +++ b/src/tui/tui-backend.ts @@ -168,6 +168,7 @@ export type TuiSessionCreateOptions = { key: string; agentId?: string; parentSessionKey?: string; + succeedsParent?: boolean; }; /** Minimal backend interface shared by Gateway and embedded local TUI modes. */ diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index a97748aa24c4..59dc83602b94 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -976,13 +976,14 @@ describe("tui command handlers", () => { // /new creates a unique session key (isolates TUI client) (#39217) expect(createSessionMock).toHaveBeenCalledTimes(1); const createOptions = firstMockArg(createSessionMock, "createSession") as - | { key?: string; agentId?: string; parentSessionKey?: string } + | { key?: string; agentId?: string; parentSessionKey?: string; succeedsParent?: boolean } | undefined; if (!createOptions?.key) { throw new Error("expected /new to create a TUI session key"); } expect(createOptions.agentId).toBe("main"); expect(createOptions.parentSessionKey).toBe("agent:main:main"); + expect(createOptions.succeedsParent).toBe(true); 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]); @@ -1025,7 +1026,7 @@ describe("tui command handlers", () => { expect(createSession).toHaveBeenCalledWith({ key: expect.stringMatching(/^tui-/), agentId: currentAgentId, - ...(expectedParent ? { parentSessionKey: expectedParent } : {}), + ...(expectedParent ? { parentSessionKey: expectedParent, succeedsParent: true } : {}), }); }); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index cd24f91dc0a8..1ec99ed109b5 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -747,7 +747,9 @@ export function createCommandHandlers(context: CommandHandlerContext) { const result = await client.createSession({ key: uniqueKey, agentId: state.currentAgentId, - ...(state.currentSessionId ? { parentSessionKey: state.currentSessionKey } : {}), + ...(state.currentSessionId + ? { parentSessionKey: state.currentSessionKey, succeedsParent: true } + : {}), }); if (!result.key) { throw new Error("sessions.create returned no session key"); diff --git a/ui/src/lib/sessions/create.test.ts b/ui/src/lib/sessions/create.test.ts index 2221690b2593..1db7f6d7ac4f 100644 --- a/ui/src/lib/sessions/create.test.ts +++ b/ui/src/lib/sessions/create.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it, vi } from "vitest"; -import { requestSessionCreate } from "./create.ts"; +import { requestSessionCreate, resolveSessionCreateParams } from "./create.ts"; + +describe("resolveSessionCreateParams", () => { + it("marks a Control UI child as parallel to its selected parent", () => { + expect(resolveSessionCreateParams(" agent:main:signal:direct:42 ", " main ")).toEqual({ + agentId: "main", + parentSessionKey: "agent:main:signal:direct:42", + emitCommandHooks: true, + succeedsParent: false, + }); + }); +}); describe("requestSessionCreate", () => { it("returns the started initial-run outcome", async () => { diff --git a/ui/src/lib/sessions/create.ts b/ui/src/lib/sessions/create.ts index 6462b1a52213..a8460a047e0a 100644 --- a/ui/src/lib/sessions/create.ts +++ b/ui/src/lib/sessions/create.ts @@ -13,6 +13,7 @@ export type SessionCreateParams = { currentSessionKey?: string; parentSessionKey?: string; fork?: boolean; + succeedsParent?: boolean; label?: string; model?: string; thinkingLevel?: string; @@ -40,7 +41,9 @@ export function resolveSessionCreateParams(sessionKey = "", agentId?: string) { : undefined; return { ...(agentId?.trim() ? { agentId: agentId.trim() } : {}), - ...(parentSessionKey ? { parentSessionKey, emitCommandHooks: true } : {}), + ...(parentSessionKey + ? { parentSessionKey, emitCommandHooks: true, succeedsParent: false } + : {}), }; }