mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
08b80224ea
commit
e1d87e9a9d
@@ -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. */
|
||||
|
||||
+53
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user