fix(sessions): preserve parent lifecycle for parallel children (#110541)

* fix(sessions): declare parent lifecycle disposition

Co-authored-by: Luiz Antonio Busnello <lab@generantis.com.br>

* chore: keep release notes in PR body

* fix(sessions): preserve parallel fallback on old gateways

* chore(i18n): refresh native source inventory

---------

Co-authored-by: Luiz Antonio Busnello <lab@generantis.com.br>
This commit is contained in:
Peter Steinberger
2026-07-18 10:16:14 +01:00
committed by GitHub
parent 08b80224ea
commit e1d87e9a9d
19 changed files with 426 additions and 37 deletions
+14 -14
View File
@@ -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",
@@ -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. */
@@ -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<Pair<String, String?>>()
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() =
@@ -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
+1 -1
View File
@@ -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.
+2
View File
@@ -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.
@@ -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),
+14 -2
View File
@@ -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",
+4
View File
@@ -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[];
@@ -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",
@@ -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);
});
+44 -12
View File
@@ -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<void>;
}): Promise<CreateGatewaySessionResult> {
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,
+75
View File
@@ -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",
+33 -2
View File
@@ -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<TuiSessionMutationResult> {
return await this.client.request<TuiSessionMutationResult>("sessions.create", {
const params = {
...opts,
emitCommandHooks: Boolean(opts.parentSessionKey),
});
};
try {
return await this.client.request<TuiSessionMutationResult>("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<TuiSessionMutationResult>(
"sessions.create",
parallelParams,
);
}
// Legacy rollover is equivalent to an explicit successor request.
return await this.client.request<TuiSessionMutationResult>("sessions.create", legacyParams);
}
}
async resetSession(
+1
View File
@@ -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. */
+3 -2
View File
@@ -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 } : {}),
});
});
+3 -1
View File
@@ -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");
+12 -1
View File
@@ -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 () => {
+4 -1
View File
@@ -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 }
: {}),
};
}