fix: spawned workers take over the chat you were talking in (#158992)

* fix(agents): keep spawned workers out of the current conversation

Agent-spawned workers (sessions_spawn thread=true, native and ACP) may only bind a new child thread. Channels whose spawn placement is the current conversation (Telegram, Feishu, LINE, generic current-conversation bindings) now reject thread=true instead of handing the user's chat to the worker. Legacy spawn-created bindings on those channels are ignored by the binding service, so the conversation routes to its normal agent again. Discord/Matrix child-thread sessions and defaultSpawnContext are unchanged.

* test(telegram): expect parent routing after worker-takeover upgrade

* test(agents): refresh prompt snapshots for child-only thread spawns

* test(telegram): keep parent-phase checkpoint codes in public upgrade evidence
This commit is contained in:
Ayaan Zaidi
2026-09-27 11:31:21 +05:30
committed by GitHub
parent 5e1a2cf4ea
commit 856dbe9017
25 changed files with 748 additions and 308 deletions
@@ -7,8 +7,9 @@ interpretation, persistent fixtures, forum topics, or a failed run. The primary
## Published-driver topic-binding upgrade
The npm Telegram lane's standalone `telegram-published-upgrade-bindings` selector
proves an actual binding created by an installed published Gateway survives its
own updater and the candidate's next restart. Run it only in the lane's isolated
proves that a topic an installed published Gateway handed to a spawned worker
returns to its parent session after that Gateway's own updater and the
candidate's next restart. Run it only in the lane's isolated
container: the secretless install phase owns the published prefix, and the
validated candidate tarball is mounted read-only for the live phase.
@@ -29,13 +30,17 @@ the pinned TDLib through the maintained loader. It uses existing Python 3 and
the driver's standard-library implementation, without `uv` or a source build.
One maintained credential/run scope owns fixture setup, the proxy, recorder,
mock provider, installed Gateway children, and updater. The published Gateway
mock provider, installed Gateway children, and updater. The published baseline
must accept a real `sessions_spawn` with `thread:true` and `mode:session`, and
both parent and child must reply in the actual topic before shutdown. The
genuine published CLI then runs `update --tag file:<candidate> --yes --no-restart
--json` with the same runner-created config, token file, workspace, and databases.
The candidate must route the next topic turn to that same child, restart, and
continue routing to it. Each of the three Gateway stops requires a joined exit
both parent and child must reply in the actual topic before shutdown; this
legacy spawn binding hands the current topic to the child. The genuine published
CLI then runs `update --tag file:<candidate> --yes --no-restart --json` with the
same runner-created config, token file, workspace, and databases. The candidate
no longer honors spawn-created bindings on the current Telegram conversation, so
the next topic turn and its reply must land in the parent topic session
transcript, not the child's, both after activation and after another restart.
The child keeps its canonical identity and spawn-phase history, and no later
turn reaches it. Each of the three Gateway stops requires a joined exit
code 0 with no signal; forced process cleanup cannot qualify orderly shutdown.
Artifact hashes, native observations, accepted tool-result correlation, canonical
session identity, and receipt-scoped cleanup all participate in the verdict.
@@ -167,6 +167,18 @@ test("public failure evidence keeps typed outcomes while dropping credentials, i
remoteMessage: secret,
},
});
write("routing-before.json.diagnostic.json", {
status: "failed",
stage: "phase-conditions",
code: "FOLLOWUP_ROUTED_TO_CHILD",
earlyPhase: secret,
});
write("routing-after.json.diagnostic.json", {
status: "failed",
stage: "phase-conditions",
code: "PRIOR_PARENT_PHASE_DISAPPEARED",
missingPhase: secret,
});
write("cleanup.json", { ok: false, retainedLease: true, error: secret, groupId: secret });
const report = publicUpgradeFailure(root, "live-scenario");
assert.equal(report.updater.exitCode, 17);
@@ -176,6 +188,14 @@ test("public failure evidence keeps typed outcomes while dropping credentials, i
assert.equal(report.checkpoints[0].code, "CHECKPOINT_RPC_FAILED");
assert.equal(report.checkpoints[0].rpc.remoteCode, "UNAVAILABLE");
assert.deepEqual(report.checkpoints[0].missing, ["PARENT_NATIVE_ACK"]);
assert.deepEqual(
{ phase: report.checkpoints[1].phase, code: report.checkpoints[1].code },
{ phase: "before", code: "FOLLOWUP_ROUTED_TO_CHILD" },
);
assert.deepEqual(
{ phase: report.checkpoints[2].phase, code: report.checkpoints[2].code },
{ phase: "after", code: "PRIOR_PARENT_PHASE_DISAPPEARED" },
);
assert.deepEqual(report.cleanup, {
confirmed: false,
fixtureConfirmed: false,
@@ -225,7 +245,7 @@ test("public success retains actual shutdown and updater receipts without privat
}
const result = {
ok: true,
sameChildAcrossRestart: true,
topicReturnedToParentAcrossRestart: true,
verifiedRestarts: 2,
nativePhases: ["PARENT", "CHILD", "BEFORE", "AFTER"],
providerRequests: 5,
@@ -243,7 +263,7 @@ test("public success retains actual shutdown and updater receipts without privat
},
root,
);
assert.equal(report.sameChildAcrossUpgradeAndRestart, true);
assert.equal(report.topicReturnedToParentAcrossUpgradeAndRestart, true);
assert.equal(report.orderlyGatewayStops, 3);
assert.equal(report.updater.durationMs, 12345);
assert.deepEqual(
@@ -502,31 +502,30 @@ async function main() {
fail("CHILD_IDENTITY_CHANGED_DURING_READ");
}
stage = "phase-conditions";
const required =
phase === "spawn"
? ["CHILD"]
: phase === "before"
? ["CHILD", "BEFORE"]
: ["CHILD", "BEFORE", "AFTER"];
// The published baseline hands the topic to the spawned child. The candidate
// hides that legacy takeover, so later topic turns return to the parent.
const followups =
phase === "spawn" ? [] : phase === "before" ? ["BEFORE"] : ["BEFORE", "AFTER"];
const phases = {};
for (const name of required) {
const matches = child.messages.flatMap((message, index) =>
function phaseTurn(session, messages, name) {
const owner = session.toUpperCase();
const matches = messages.flatMap((message, index) =>
ordinaryUser(message) && hasMarker(message, `TELEGRAM_BINDING_${name}_${runId}`)
? [{ message, index }]
: [],
);
if (matches.length > 1) {
fail("DUPLICATE_CHILD_PHASE_USER", { missingPhase: name });
fail(`DUPLICATE_${owner}_PHASE_USER`, { missingPhase: name });
}
if (!matches.length) {
if (baseline?.phases?.[name]) {
fail("PRIOR_CHILD_PHASE_DISAPPEARED", { missingPhase: name });
fail(`PRIOR_${owner}_PHASE_DISAPPEARED`, { missingPhase: name });
}
missing.push(`${name}_TRANSCRIPT_USER`);
continue;
return;
}
const sent = matches[0];
const later = child.messages.slice(sent.index + 1);
const later = messages.slice(sent.index + 1);
const end = later.findIndex(ordinaryUser);
const turn = end < 0 ? later : later.slice(0, end);
const replies = turn.filter(
@@ -535,30 +534,39 @@ async function main() {
text(message).trim() === `TELEGRAM_BINDING_ACK_${name}_${runId}`,
);
if (replies.length > 1) {
fail("DUPLICATE_CHILD_PHASE_ACK", { missingPhase: name });
fail(`DUPLICATE_${owner}_PHASE_ACK`, { missingPhase: name });
}
if (!replies.length) {
if (baseline?.phases?.[name]) {
fail("PRIOR_CHILD_ACK_DISAPPEARED", { missingPhase: name });
fail(`PRIOR_${owner}_ACK_DISAPPEARED`, { missingPhase: name });
}
missing.push(`${name}_TRANSCRIPT_ACK`);
continue;
return;
}
phases[name] = { user: messageFact(sent.message), assistant: messageFact(replies[0]) };
phases[name] = {
session,
user: messageFact(sent.message),
assistant: messageFact(replies[0]),
};
}
for (const early of phase === "spawn"
? ["BEFORE", "AFTER"]
: phase === "before"
? ["AFTER"]
: []) {
if (
child.messages.some(
(message) =>
ordinaryUser(message) && hasMarker(message, `TELEGRAM_BINDING_${early}_${runId}`),
)
) {
fail("FOLLOWUP_PRECEDED_CHECKPOINT", { earlyPhase: early });
for (const name of ["BEFORE", "AFTER"]) {
const present = (message) =>
ordinaryUser(message) && hasMarker(message, `TELEGRAM_BINDING_${name}_${runId}`);
if (child.messages.some(present)) {
fail(
followups.includes(name) ? "FOLLOWUP_ROUTED_TO_CHILD" : "FOLLOWUP_PRECEDED_CHECKPOINT",
{
earlyPhase: name,
},
);
}
if (!followups.includes(name) && parent.messages.some(present)) {
fail("FOLLOWUP_PRECEDED_CHECKPOINT", { earlyPhase: name });
}
}
phaseTurn("child", child.messages, "CHILD");
for (const name of followups) {
phaseTurn("parent", parent.messages, name);
}
return {
missing,
@@ -0,0 +1,249 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import test, { afterEach } from "node:test";
import { fileURLToPath } from "node:url";
import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.ts";
const temporary = useAutoCleanupTempDirTracker(afterEach);
const checkpointPath = fileURLToPath(new URL("./telegram-binding-checkpoint.mjs", import.meta.url));
const sourceRoot = fs.realpathSync(fileURLToPath(new URL("../../../../", import.meta.url)));
const runId = "upgrade-proof";
const chatId = "-1001234567890";
const topicId = 42;
const parentKey = `agent:main:telegram:group:${chatId}:topic:${topicId}`;
const childKey = "agent:main:subagent:worker";
const toolCallId = "call_mock_sessions_spawn_0123456789|fc_fixture";
const baselineCommit = "a".repeat(40);
const candidateCommit = "b".repeat(40);
// Installed Gateway stand-in: answers the checkpoint's read-only RPCs from a transcript file.
const fakeEntry = `import { readFileSync } from "node:fs";
const args = process.argv.slice(2);
const method = args[2];
const params = JSON.parse(args[args.indexOf("--params") + 1]);
const state = JSON.parse(readFileSync(process.env.CHECKPOINT_TEST_TRANSCRIPTS, "utf8"));
const session = params.key === state.childKey ? "child" : params.key === state.parentKey ? "parent" : undefined;
const result =
method === "sessions.describe"
? { session: session === "child" ? { key: params.key, sessionId: "child-session" } : undefined }
: { messages: state[session] };
console.log(JSON.stringify(result));
`;
let seq = 0;
const stamp = (message) => ({
...message,
__openclaw: { id: `m${++seq}`, seq },
timestamp: 1_700_000_000_000 + seq,
});
const user = (value) => stamp({ role: "user", content: [{ type: "text", text: value }] });
const assistant = (value) => stamp({ role: "assistant", content: [{ type: "text", text: value }] });
const ack = (name) => assistant(`TELEGRAM_BINDING_ACK_${name}_${runId}`);
const turn = (name) => [user(`@sut TELEGRAM_BINDING_${name}_${runId}.`), ack(name)];
function spawnTranscripts() {
const args = {
task: `TELEGRAM_BINDING_CHILD_${runId}. Reply with the child fixture acknowledgment.`,
taskName: `telegram-binding-${runId}`,
runtime: "subagent",
thread: true,
mode: "session",
cleanup: "keep",
context: "isolated",
};
return {
parentKey,
childKey,
parent: [
user(`@sut TELEGRAM_BINDING_SPAWN_${runId}.`),
stamp({
role: "assistant",
content: [{ type: "toolCall", name: "sessions_spawn", id: toolCallId, arguments: args }],
}),
stamp({
role: "toolResult",
toolName: "sessions_spawn",
toolCallId,
isError: false,
content: [
{
type: "text",
text: JSON.stringify({
status: "accepted",
taskName: args.taskName,
mode: "session",
context: "isolated",
childSessionKey: childKey,
}),
},
],
}),
ack("PARENT"),
],
child: [user(args.task), ack("CHILD")],
};
}
function createRun() {
const owned = temporary.make("openclaw-tg-user-mock-sut-");
const proof = path.join(owned, "proof");
const packageRoot = path.join(owned, "package");
fs.mkdirSync(path.join(packageRoot, "dist/plugin-sdk"), { recursive: true });
fs.mkdirSync(path.join(owned, "state"));
fs.mkdirSync(proof);
fs.writeFileSync(path.join(packageRoot, "dist/entry.js"), fakeEntry);
fs.writeFileSync(
path.join(packageRoot, "dist/plugin-sdk/logging-core.js"),
"export const redactSensitiveText = (value) => value;\n",
);
const buildInfo = (commit) => ({ buildId: "fixture-build", commit });
fs.writeFileSync(path.join(packageRoot, "dist/build-info.json"), JSON.stringify(buildInfo()));
const runtimeHashes = {
"dist/entry.js": createHash("sha256").update(fakeEntry).digest("hex"),
};
fs.writeFileSync(
path.join(proof, "upgrade-input.json"),
JSON.stringify({
packageRoot,
baseline: { buildInfo: buildInfo(baselineCommit), runtimeHashes },
candidate: { buildInfo: buildInfo(candidateCommit), runtimeHashes },
}),
);
const eventsPath = path.join(proof, "events.ndjson");
fs.writeFileSync(
path.join(proof, "fixture.json"),
JSON.stringify({ runId, chatId, topicId, eventsPath }),
);
fs.writeFileSync(
path.join(owned, "config.json"),
JSON.stringify({ gateway: { port: 18789, bind: "loopback", auth: { mode: "none" } } }),
);
let elapsedMs = 0;
const events = [];
const transcriptsPath = path.join(owned, "transcripts.json");
return {
proof,
// Native readiness: each marker send is followed by its SUT ack in the same forum topic.
observe(trigger, acks) {
events.push({
kind: "action",
actionType: "send",
status: "completed",
text: `@sut TELEGRAM_BINDING_${trigger}_${runId}.`,
elapsedMs: ++elapsedMs,
});
for (const name of acks) {
events.push({
kind: "message",
isSut: true,
text: `TELEGRAM_BINDING_ACK_${name}_${runId}`,
elapsedMs: ++elapsedMs,
topicType: "messageTopicForum",
topicId,
raw: { message: { chat_id: Number(chatId) } },
});
}
fs.writeFileSync(eventsPath, events.map((event) => JSON.stringify(event) + "\n").join(""));
},
checkpoint(phase, transcripts) {
fs.writeFileSync(transcriptsPath, JSON.stringify(transcripts));
fs.writeFileSync(
path.join(proof, "installed-runtime.json"),
JSON.stringify({
packageRoot: fs.realpathSync(packageRoot),
build: buildInfo(phase === "spawn" ? baselineCommit : candidateCommit),
}),
);
const output = path.join(proof, `routing-${phase}.json`);
const previous = { before: "spawn", after: "before" }[phase];
const child = spawnSync(
process.execPath,
[
checkpointPath,
phase,
parentKey,
runId,
output,
sourceRoot,
...(previous ? [path.join(proof, `routing-${previous}.json`)] : []),
],
{
cwd: sourceRoot,
encoding: "utf8",
env: {
PATH: process.env.PATH,
OPENCLAW_CONFIG_PATH: path.join(owned, "config.json"),
OPENCLAW_STATE_DIR: path.join(owned, "state"),
TELEGRAM_E2E_TEST_API_ROOT: "http://127.0.0.1:9",
CHECKPOINT_TEST_TRANSCRIPTS: transcriptsPath,
},
},
);
const diagnostic = JSON.parse(fs.readFileSync(`${output}.diagnostic.json`, "utf8"));
return {
exitCode: child.status,
diagnostic,
checkpoint: child.status === 0 ? JSON.parse(fs.readFileSync(output, "utf8")) : undefined,
};
},
};
}
test("candidate checkpoints require follow-ups in the parent topic session after a legacy takeover", () => {
const run = createRun();
const transcripts = spawnTranscripts();
run.observe("SPAWN", ["PARENT", "CHILD"]);
const spawned = run.checkpoint("spawn", transcripts);
assert.equal(spawned.exitCode, 0, JSON.stringify(spawned.diagnostic));
assert.equal(spawned.checkpoint.phases.CHILD.session, "child");
run.observe("BEFORE", ["BEFORE"]);
transcripts.parent.push(...turn("BEFORE"));
const before = run.checkpoint("before", transcripts);
assert.equal(before.exitCode, 0, JSON.stringify(before.diagnostic));
assert.equal(before.checkpoint.childKey, childKey);
assert.equal(before.checkpoint.phases.CHILD.session, "child");
assert.equal(before.checkpoint.phases.BEFORE.session, "parent");
run.observe("AFTER", ["AFTER"]);
transcripts.parent.push(...turn("AFTER"));
const after = run.checkpoint("after", transcripts);
assert.equal(after.exitCode, 0, JSON.stringify(after.diagnostic));
assert.deepEqual(
Object.fromEntries(
Object.entries(after.checkpoint.phases).map(([name, fact]) => [name, fact.session]),
),
{ CHILD: "child", BEFORE: "parent", AFTER: "parent" },
);
});
test("candidate checkpoints reject a follow-up still routed to the legacy spawned child", () => {
const run = createRun();
const transcripts = spawnTranscripts();
run.observe("SPAWN", ["PARENT", "CHILD"]);
assert.equal(run.checkpoint("spawn", transcripts).exitCode, 0);
run.observe("BEFORE", ["BEFORE"]);
transcripts.child.push(...turn("BEFORE"));
const before = run.checkpoint("before", transcripts);
assert.equal(before.exitCode, 1);
assert.equal(before.diagnostic.code, "FOLLOWUP_ROUTED_TO_CHILD");
assert.equal(before.diagnostic.earlyPhase, "BEFORE");
});
test("candidate checkpoints reject a removed spawn-phase child transcript", () => {
const run = createRun();
const transcripts = spawnTranscripts();
run.observe("SPAWN", ["PARENT", "CHILD"]);
assert.equal(run.checkpoint("spawn", transcripts).exitCode, 0);
run.observe("BEFORE", ["BEFORE"]);
transcripts.parent.push(...turn("BEFORE"));
transcripts.child = [];
const before = run.checkpoint("before", transcripts);
assert.equal(before.exitCode, 1);
assert.equal(before.diagnostic.code, "PRIOR_CHILD_PHASE_DISAPPEARED");
});
@@ -94,6 +94,15 @@ function readPublicUpgradeEvidence(proof, stage) {
"PARENT_HISTORY_SHAPE_INVALID",
"CHILD_HISTORY_SHAPE_INVALID",
"FOLLOWUP_PRECEDED_CHECKPOINT",
"FOLLOWUP_ROUTED_TO_CHILD",
"DUPLICATE_CHILD_PHASE_USER",
"DUPLICATE_CHILD_PHASE_ACK",
"PRIOR_CHILD_PHASE_DISAPPEARED",
"PRIOR_CHILD_ACK_DISAPPEARED",
"DUPLICATE_PARENT_PHASE_USER",
"DUPLICATE_PARENT_PHASE_ACK",
"PRIOR_PARENT_PHASE_DISAPPEARED",
"PRIOR_PARENT_ACK_DISAPPEARED",
"INSTALLED_RUNTIME_CHANGED_DURING_CHECKPOINT",
]);
const checkpoints = ["spawn", "before", "after"].flatMap((phase) => {
@@ -219,7 +228,8 @@ export function publicUpgradeReport(result, upgrade, proof) {
commit: upgrade.candidate.buildInfo.commit,
sha256: upgrade.candidate.sha256,
},
sameChildAcrossUpgradeAndRestart: result.sameChildAcrossRestart === true,
topicReturnedToParentAcrossUpgradeAndRestart:
result.topicReturnedToParentAcrossRestart === true,
orderlyGatewayStops: evidence.gatewayStops.length,
verifiedRestarts: result.verifiedRestarts,
nativePhases: result.nativePhases,
@@ -324,6 +334,7 @@ export function judgeBindingUpgrade({
value.phase !== name ||
value.runId !== runId ||
diagnostic.status !== "completed" ||
value.parentKey !== spawned.parentKey ||
value.childKey !== spawned.childKey ||
value.sessionId !== spawned.sessionId ||
value.toolCallId !== spawned.toolCallId ||
@@ -332,11 +343,19 @@ export function judgeBindingUpgrade({
value.runtime?.installedCommit !==
(name === "spawn" ? upgrade.baseline.buildInfo.commit : upgrade.candidate.buildInfo.commit)
) {
throw new Error("SAME_CHILD_CHECKPOINTS_MISSING");
throw new Error("CHILD_IDENTITY_CHECKPOINTS_MISSING");
}
}
if (!spawned.phases?.CHILD || !before.phases?.BEFORE || !after.phases?.AFTER) {
throw new Error("PHASE_CHECKPOINT_ACKS_MISSING");
// The baseline's spawn takes over the topic; the candidate must route later
// turns back to the parent while the child's own transcript stays intact.
if (
spawned.phases?.CHILD?.session !== "child" ||
after.phases?.CHILD?.session !== "child" ||
before.phases?.BEFORE?.session !== "parent" ||
after.phases?.BEFORE?.session !== "parent" ||
after.phases?.AFTER?.session !== "parent"
) {
throw new Error("PARENT_ROUTING_CHECKPOINTS_MISSING");
}
const requestPath = join(proof, "mock-openai-requests.ndjson");
if (statSync(requestPath).size > 128 * 1024 * 1024) {
@@ -442,7 +461,7 @@ export function judgeBindingUpgrade({
return {
ok: true,
candidateCommit: upgrade.candidate.buildInfo.commit,
sameChildAcrossRestart: true,
topicReturnedToParentAcrossRestart: true,
verifiedRestarts: 2,
publishedDriverUpgrade: true,
beforeBuild: updateReceipt.beforeBuild,
+2 -2
View File
@@ -1,4 +1,4 @@
509a8855674a6c2dce5a555d5b3d212ee804651bc1302115b3441624b39b4008 config-baseline.json
f55922ecb2ef9afedcf61f694c2ac5d19fd3eec561034c73a2213efeaea0fc2d config-baseline.json
72e638bf7cbcd2d7e3b9c911adddc39f8cb804105dec823e0ca720c7756e6513 config-baseline.core.json
419f7464fdf281fd06880b93d2621a372c49189cb0255c3c6f2ef41110cf9039 config-baseline.channel.json
4b6c0bce3096e37beb17c68e2578a2d51622253eeabb9cfd070cc880122cac6f config-baseline.channel.json
e6339fc600d3f77b6441b24acbdb7df2c678f74c7adbf58ea29ea91f8f0fe40d config-baseline.plugin.json
+1 -1
View File
@@ -106,7 +106,7 @@ title: "Configuration — agent sessions"
- `enabled`: master switch for supported channel thread bindings
- `idleHours`: default inactivity auto-unbind in hours (`0` disables; providers can override)
- `maxAgeHours`: default hard max age in hours (`0` disables; providers can override)
- `spawnSessions`: default gate for creating thread-bound work sessions from `sessions_spawn` and ACP thread spawns. Defaults to `true` when thread bindings are enabled; providers/accounts can override.
- `spawnSessions`: default gate for creating thread-bound work sessions from `sessions_spawn` and ACP thread spawns. Agent spawns always open a new child thread and never bind the current conversation. Defaults to `true` when thread bindings are enabled; providers/accounts can override.
- `defaultSpawnContext`: default native subagent context for thread-bound spawns (`"fork"` or `"isolated"`). Defaults to `"fork"`.
- **`sharing`**: controls which per-session collaboration modes owners and `operator.admin` connections may select. Every flag defaults to `true`; setting one to `false` removes that choice from the Control UI and makes create-time visibility or `session.visibility.set` reject it. New sessions start `shared` unless the Control UI starts one as a draft.
- `readOnly`: allow `read-only`, where non-members can watch but cannot send, steer, abort, approve, or mutate session state.
+9 -7
View File
@@ -165,17 +165,19 @@ inside every shard.
or `custom` lane profiles. Set `telegram_mode=mock-openai` or
`live-frontier` to run the Telegram QA workflow against the same
`package-under-test` artifact.
- For existing Telegram topic bindings across a published-driver update, select
- For legacy Telegram topic bindings across a published-driver update, select
`suite_profile=telegram`, `telegram_mode=mock-openai`, and the single scenario
`telegram-published-upgrade-bindings`. Supply `package_spec` as an exact
published baseline, such as `openclaw@2026.9.6`, and resolve the candidate
through `source=ref` or a verified tarball artifact. This scenario installs
the baseline before leasing Test Server credentials, creates a real bound
child session, and runs that installation's normal `openclaw update` against
the candidate. It checks the same binding after activation and another
Gateway restart, including all three orderly shutdowns. Raw credential,
session, and transport state stays in container scratch; uploaded evidence
contains the package identities and redacted outcome only.
the baseline before leasing Test Server credentials, lets it spawn a
thread-bound child that takes over the forum topic, and runs that
installation's normal `openclaw update` against the candidate. It checks that
the topic routes back to the parent session after activation and another
Gateway restart while the child session stays intact, including all three
orderly shutdowns. Raw credential, session, and transport state stays in
container scratch; uploaded evidence contains the package identities and
redacted outcome only.
- Latest beta product proof:
```bash
+17 -11
View File
@@ -9,19 +9,25 @@ read_when:
## Thread-bound sessions
When thread bindings are enabled for a channel, a sub-agent can stay bound
to a thread so follow-up user messages in that thread keep routing to the
same sub-agent session.
When thread bindings are enabled for a channel, a spawned sub-agent can get
its own new thread. Follow-up user messages in that thread keep routing to the
same sub-agent session, while the conversation you spawned it from stays with
your agent.
### Thread supporting channels
A channel supports persistent thread-bound subagent sessions
(`sessions_spawn` with `thread: true`) when it registers a conversation
binding adapter. Bundled channels with that support: **Discord**,
**iMessage**, **Matrix**, and **Telegram**. Discord and Matrix default to
creating a child thread; Telegram and iMessage default to binding the
current conversation. Use the per-channel `threadBindings` config keys for
enablement, timeouts, and `spawnSessions`.
`sessions_spawn` with `thread: true` always opens a new child thread; it never
hands the current conversation to the worker. Bundled channels that can open
one: **Discord** and **Matrix**. On channels that would bind the current
conversation instead (for example Telegram, iMessage, Feishu, and LINE),
`thread: true` is rejected; spawn with `mode: "run"` and the result is
announced back to the conversation. Use the per-channel `threadBindings` config
keys for enablement, timeouts, and `spawnSessions`.
Worker bindings created by older versions on the current conversation are
ignored: messages there route to your agent again, and the stale binding
expires through its normal idle timeout. To hand a conversation to an ACP
session deliberately, use `/acp spawn --bind here`.
### Quick flow
@@ -30,7 +36,7 @@ enablement, timeouts, and `spawnSessions`.
`sessions_spawn` with `thread: true` (and optionally `mode: "session"`).
</Step>
<Step title="Bind">
OpenClaw creates or binds a thread to that session target in the active channel.
OpenClaw opens a new child thread in the active channel and binds it to that session.
</Step>
<Step title="Route follow-ups">
Replies and follow-up messages in that thread route to the bound session.
@@ -289,7 +289,7 @@ describe("Feishu thread bindings", () => {
metadata: {
agentId: "previous-agent",
label: "child",
boundBy: "system",
boundBy: "ou_sender_1",
deliveryTo: "user:ou_sender_1",
deliveryThreadId: "om_topic_root",
pluginBindingOwner: "plugin",
@@ -346,7 +346,7 @@ describe("Feishu thread bindings", () => {
: {}),
agentId: replace ? "main" : "previous-agent",
label: "child",
boundBy: replace ? undefined : "system",
boundBy: replace ? undefined : "ou_sender_1",
deliveryTo: "user:ou_sender_1",
deliveryThreadId: "om_topic_root",
lastActivityAt: 1_700_000_100_000,
+2 -2
View File
@@ -148,10 +148,10 @@ export const telegramChannelConfigUiHints = {
},
"threadBindings.spawnSessions": {
label: "Telegram Thread-Bound Session Spawn",
help: "Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported.",
help: "Allow /acp spawn --thread to bind Telegram topics when supported. Agent sessions_spawn(thread=true) never binds a Telegram conversation; it needs a channel that opens a separate thread.",
},
"threadBindings.defaultSpawnContext": {
label: "Telegram Thread Spawn Context",
help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork".',
help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork". Telegram cannot host agent-spawned thread sessions, so this has no effect for Telegram requests.',
},
} satisfies Record<string, ChannelConfigUiHint>;
@@ -8,13 +8,12 @@ describe("registered sessions_spawn binding discovery", () => {
afterEach(() => resetPluginRuntimeStateForTest());
it.each([
{ placement: "current", supportsCurrent: true, spawnSessions: true, available: true },
{ placement: "current", supportsCurrent: false, spawnSessions: true, available: false },
{ placement: "current", supportsCurrent: true, spawnSessions: false, available: false },
{ placement: "child", supportsCurrent: false, spawnSessions: true, available: true },
{ placement: "current", spawnSessions: true, available: false },
{ placement: "child", spawnSessions: true, available: true },
{ placement: "child", spawnSessions: false, available: false },
] as const)(
"$placement, current support=$supportsCurrent, spawn policy=$spawnSessions",
({ placement, supportsCurrent, spawnSessions, available }) => {
"$placement placement, spawn policy=$spawnSessions",
({ placement, spawnSessions, available }) => {
setActivePluginRegistry(
createTestRegistry([
{
@@ -24,7 +23,7 @@ describe("registered sessions_spawn binding discovery", () => {
...createChannelTestPluginBase({ id: "binding-chat", label: "Binding chat" }),
conversationBindings: {
defaultTopLevelPlacement: placement,
supportsCurrentConversationBinding: supportsCurrent,
supportsCurrentConversationBinding: true,
},
},
},
+4 -1
View File
@@ -517,7 +517,10 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
context: "isolated",
});
expectErrorResultMessage(result, /only available on channels that expose thread bindings/i);
expectErrorResultMessage(
result,
/only available on channels that open a separate thread for the worker/i,
);
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
expectSessionsDeleteWithoutAgentStart();
});
+14 -8
View File
@@ -4,8 +4,8 @@ import {
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import {
resolveChannelDefaultBindingPlacement,
resolveInboundConversationResolution,
resolveSpawnThreadBindingPlacement,
} from "../channels/conversation-resolution.js";
import {
formatThreadBindingDisabledError,
@@ -31,7 +31,7 @@ type SpawnBackendKind = "subagent" | "acp";
export type PreparedSpawnThreadBinding = {
channel: string;
accountId: string;
placement: "current" | "child";
placement: "child";
conversationId: string;
parentConversationId?: string;
};
@@ -132,13 +132,13 @@ function buildThreadBindingUnavailableError(kind: SpawnBackendKind, mode: SpawnM
}
if (mode === "session") {
return (
'sessions_spawn(mode="session") is only available on channels that expose thread bindings (e.g. Discord threads, Slack threads, Telegram forum topics). ' +
'sessions_spawn(mode="session") is only available on channels that open a separate thread for the worker (e.g. Discord or Matrix threads). ' +
"This request is not running on a channel that can bind a subagent thread. " +
'Use mode="run" for one-shot subagent work.'
);
}
return (
"thread=true is only available on channels that expose thread bindings (e.g. Discord threads, Slack threads, Telegram forum topics). " +
"thread=true is only available on channels that open a separate thread for the worker (e.g. Discord or Matrix threads). " +
"This request is not running on a channel that can bind a subagent thread. " +
"Retry without thread=true, or re-run sessions_spawn from a channel that supports threads."
);
@@ -204,13 +204,19 @@ export function prepareSpawnThreadBinding(params: {
: buildThreadBindingUnavailableError(params.kind, params.mode),
};
}
const placement =
resolveChannelDefaultBindingPlacement(policy.channel) ??
(capabilities.placements.includes("child") ? "child" : "current");
const placement = resolveSpawnThreadBindingPlacement(policy.channel, capabilities.placements);
if (placement !== "child") {
return {
ok: false,
error:
`thread=true on ${policy.channel} would bind this conversation to the worker instead of opening a separate thread. ` +
'Retry without thread=true (mode="run"); the result is announced back here.',
};
}
if (!capabilities.bindSupported || !capabilities.placements.includes(placement)) {
return {
ok: false,
error: `Thread bindings do not support ${placement} placement for ${policy.channel}.`,
error: `Thread bindings do not support child placement for ${policy.channel}.`,
};
}
const fallback = resolveInboundConversationResolution({
+27 -174
View File
@@ -2172,45 +2172,38 @@ describe("spawnAcpDirect", () => {
}
});
it("binds LINE ACP sessions to the current conversation when the channel has no native threads", async () => {
enableLineCurrentConversationBindings();
mockConversationBinding("line");
const result = await spawnAcpDirect(
{
task: "Investigate flaky tests",
agentId: "codex",
mode: "session",
thread: true,
},
{
it.each([
{
channel: "line",
enable: enableLineCurrentConversationBindings,
ctx: {
agentSessionKey: "agent:main:line:direct:U1234567890abcdef1234567890abcdef",
agentChannel: "line",
agentAccountId: "default",
agentTo: "U1234567890abcdef1234567890abcdef",
},
},
{
channel: "telegram",
enable: enableTelegramCurrentConversationBindings,
ctx: {
agentSessionKey: "agent:main:telegram:direct:6098642967",
agentChannel: "telegram",
agentAccountId: "default",
agentTo: "telegram:6098642967",
},
},
])("refuses to hand the current $channel conversation to a spawned worker", async (row) => {
row.enable();
const result = await spawnAcpDirect(
{ task: "Investigate flaky tests", agentId: "codex", mode: "session", thread: true },
row.ctx,
);
expect(result.status, JSON.stringify(result)).toBe("accepted");
expectBindingCallFields({
placement: "current",
conversation: {
channel: "line",
accountId: "default",
conversationId: "U1234567890abcdef1234567890abcdef",
},
});
expectAgentGatewayCall({
deliver: true,
channel: "line",
to: "U1234567890abcdef1234567890abcdef",
threadId: undefined,
});
const transcriptCalls = hoisted.resolveSessionTranscriptFileMock.mock.calls.map(
(call: unknown[]) => call[0] as { threadId?: string },
);
expect(transcriptCalls).toHaveLength(1);
expect(transcriptCalls[0]?.threadId).toBeUndefined();
expect(result).toMatchObject({ status: "error", errorCode: "thread_binding_invalid" });
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
expect(gatewayRequests().some((request) => request.method === "agent")).toBe(false);
});
it("binds ACP sessions through the configured default account when accountId is omitted", async () => {
@@ -2235,7 +2228,7 @@ describe("spawnAcpDirect", () => {
},
},
});
registerBindingAdapter("custom", "work", ["current"]);
registerBindingAdapter("custom", "work", ["child"]);
mockConversationBinding("custom");
const result = await spawnAcpDirect(
@@ -2254,7 +2247,7 @@ describe("spawnAcpDirect", () => {
expect(result.status).toBe("accepted");
expectBindingCallFields({
placement: "current",
placement: "child",
conversation: {
channel: "custom",
accountId: "work",
@@ -2356,37 +2349,6 @@ describe("spawnAcpDirect", () => {
);
});
it("preserves LINE fallback conversation precedence when groupId is present", async () => {
enableLineCurrentConversationBindings();
mockConversationBinding("line");
const result = await spawnAcpDirect(
{
task: "Investigate flaky tests",
agentId: "codex",
mode: "session",
thread: true,
},
{
agentSessionKey: "agent:main:line:direct:R1234567890abcdef1234567890abcdef",
agentChannel: "line",
agentAccountId: "default",
agentTo: "line:user:U1234567890abcdef1234567890abcdef",
agentGroupId: "line:room:R1234567890abcdef1234567890abcdef",
},
);
expect(result.status).toBe("accepted");
expectBindingCallFields({
placement: "current",
conversation: {
channel: "line",
accountId: "default",
conversationId: "R1234567890abcdef1234567890abcdef",
},
});
});
it.each([
{
name: "does not inline delivery for run-mode spawns from non-subagent requester sessions",
@@ -3077,115 +3039,6 @@ describe("spawnAcpDirect", () => {
);
});
it("binds Telegram forum-topic ACP sessions to the current topic", async () => {
enableTelegramCurrentConversationBindings();
const result = await spawnAcpDirect(
{
task: "Investigate flaky tests",
agentId: "codex",
mode: "session",
thread: true,
},
{
agentSessionKey: "agent:main:telegram:group:-1003342490704:topic:2",
agentChannel: "telegram",
agentAccountId: "default",
agentTo: "telegram:-1003342490704",
agentThreadId: "2",
agentGroupId: "-1003342490704",
},
);
const accepted = expectAcceptedSpawn(result);
expect(accepted.mode).toBe("session");
const binding = expectBindingCallFields({
placement: "current",
conversation: {
channel: "telegram",
accountId: "default",
},
});
const conversation = expectRecordFields(binding.conversation, {});
const conversationId =
typeof conversation.conversationId === "string" ? conversation.conversationId : "";
const parentConversationId =
typeof conversation.parentConversationId === "string"
? conversation.parentConversationId
: undefined;
const canonicalTopicId = parentConversationId
? `${parentConversationId}:topic:${conversationId}`
: conversationId;
expect(canonicalTopicId).toBe("-1003342490704:topic:2");
const agentCall = hoisted.callGatewayMock.mock.calls
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
.find((request) => request.method === "agent");
expect(agentCall?.params?.deliver).toBe(true);
expect(agentCall?.params?.channel).toBe("telegram");
});
it("drops self-parent Telegram current-conversation refs before binding", async () => {
enableTelegramCurrentConversationBindings();
const result = await spawnAcpDirect(
{
task: "Investigate flaky tests",
agentId: "codex",
mode: "session",
thread: true,
},
{
agentSessionKey: "agent:main:telegram:direct:6098642967",
agentChannel: "telegram",
agentAccountId: "default",
agentTo: "telegram:6098642967",
},
);
const accepted = expectAcceptedSpawn(result);
expect(accepted.mode).toBe("session");
expectBindingCallFields({
placement: "current",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "6098642967",
},
});
const bindCall = latestBindingInput();
const conversation = expectRecordFields(bindCall.conversation, {});
expect(conversation.parentConversationId).toBeUndefined();
});
it("preserves topic-qualified Telegram targets without a separate threadId", async () => {
enableTelegramCurrentConversationBindings();
const result = await spawnAcpDirect(
{
task: "Investigate flaky tests",
agentId: "codex",
mode: "session",
thread: true,
},
{
agentSessionKey: "agent:main:telegram:group:-1003342490704:topic:2",
agentChannel: "telegram",
agentAccountId: "default",
agentTo: "telegram:group:-1003342490704:topic:2",
},
);
expect(result.status).toBe("accepted");
expectBindingCallFields({
placement: "current",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-1003342490704:topic:2",
},
});
});
it("disposes pre-registered parent relay when initial ACP dispatch fails", async () => {
const relayHandle = createRelayHandle();
hoisted.startAcpSpawnParentStreamRelayMock.mockReturnValueOnce(relayHandle);
+16 -11
View File
@@ -16,6 +16,7 @@ import {
} from "../infra/outbound/channel-target-prefix.js";
import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js";
import { normalizeConversationTargetRef } from "../infra/outbound/session-binding-normalization.js";
import type { SessionBindingPlacement } from "../infra/outbound/session-binding.types.js";
import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js";
import { getLoadedChannelPluginForRead } from "./plugins/registry-loaded.js";
import {
@@ -236,19 +237,23 @@ export function resolveChannelDefaultBindingPlacement(
return pluginPlacement ?? resolveBundledChannelThreadBindingDefaultPlacement(channel);
}
/**
* Placement for a binding created by an agent-spawned worker. Only "child" (a new thread)
* is admissible: "current" would hand the user's conversation to the worker.
*/
export function resolveSpawnThreadBindingPlacement(
rawChannel: string,
supportedPlacements: readonly SessionBindingPlacement[],
): SessionBindingPlacement {
return (
resolveChannelDefaultBindingPlacement(rawChannel) ??
(supportedPlacements.includes("child") ? "child" : "current")
);
}
/** Explicit spawn discovery is separate from automatic command placement. */
export function supportsThreadBindingSpawn(rawChannel: string): boolean {
const channel = resolveChannelId(rawChannel);
if (!channel) {
return false;
}
const placement = resolveChannelDefaultBindingPlacement(channel);
return (
placement === "child" ||
(placement === "current" &&
getLoadedChannelPluginForRead(channel)?.conversationBindings
?.supportsCurrentConversationBinding === true)
);
return resolveChannelDefaultBindingPlacement(rawChannel) === "child";
}
/**
File diff suppressed because one or more lines are too long
@@ -694,6 +694,50 @@ describe("session binding service", () => {
).toBeNull();
});
it("hides spawned-worker bindings that own the current conversation but keeps child threads", async () => {
const service = getSessionBindingService();
const current = { channel: "workspace", accountId: "default", conversationId: "user:U123" };
const workerKey = "agent:main:subagent:legacy-worker";
await service.bind({
targetSessionKey: workerKey,
targetKind: "subagent",
conversation: current,
metadata: { boundBy: "system" },
});
expect(service.resolveByConversation(current)).toBeNull();
await expect(service.resolveByConversationAsync(current)).resolves.toBeNull();
expect(inspectSessionBindingByConversation(current)).toMatchObject({ binding: null });
expect(service.listBySession(workerKey)).toEqual([]);
// A user's explicit bind of the same conversation still owns it.
await service.bind({
targetSessionKey: "agent:codex:acp:user-owned",
targetKind: "session",
conversation: current,
metadata: { boundBy: "U123" },
});
expect(service.resolveByConversation(current)?.targetSessionKey).toBe(
"agent:codex:acp:user-owned",
);
const childThread = {
channel: "adapter-chat",
accountId: "default",
conversationId: "thread-created",
};
registerSessionBindingAdapter({
...childThread,
capabilities: { bindSupported: true, placements: ["current", "child"] },
listBySession: () => [],
resolveByConversation: (ref) => ({
...createRecord({ targetSessionKey: workerKey, targetKind: "subagent", conversation: ref }),
metadata: { boundBy: "system" },
}),
});
expect(service.resolveByConversation(childThread)?.targetSessionKey).toBe(workerKey);
});
it("supports registered plugin channels through the generic current-conversation path", async () => {
const service = getSessionBindingService();
+28 -9
View File
@@ -1,6 +1,7 @@
// Session binding service multiplexes channel adapters and the generic current
// conversation store behind one bind/list/resolve/touch/unbind API.
import { uniqueValues } from "@openclaw/normalization-core/string-normalization";
import { resolveSpawnThreadBindingPlacement } from "../../channels/conversation-resolution.js";
import { getActivePluginChannelRegistrySnapshotFromState } from "../../plugins/runtime-channel-state.js";
import { resolveGlobalMap } from "../../shared/global-singleton.js";
import {
@@ -232,6 +233,23 @@ function assertAdapterSelectionCurrent(
}
}
/**
* Workers spawned before child-only placement could bind the conversation a user was
* talking in. Those persisted bindings stay invisible, so the conversation routes to its
* normal agent; untouched, they expire through their owner's idle/max-age lifecycle.
*/
function routableBinding(record: SessionBindingRecord | null): SessionBindingRecord | null {
if (record?.metadata?.boundBy !== "system") {
return record;
}
const adapter = resolveAdapterForChannelAccount(record.conversation);
// The generic current-conversation store never offers child placement.
const placements = adapter ? resolveAdapterCapabilities(adapter).placements : [];
return resolveSpawnThreadBindingPlacement(record.conversation.channel, placements) === "child"
? record
: null;
}
function captureConversationRef(ref: ConversationRef): ConversationRef {
return normalizeConversationRef({
channel: ref.channel,
@@ -315,7 +333,7 @@ export async function listSessionBindingsBySessionAsync(
results.push(...entries);
}
results.push(...generic);
return dedupeBindings(results);
return dedupeBindings(results).filter((record) => routableBinding(record));
}
export function inspectSessionBindingByConversation(
@@ -350,7 +368,7 @@ function availableBindingInspection(
binding: SessionBindingRecord | null,
) {
return withSessionBindingInspectionConversation(
{ status: "available" as const, binding },
{ status: "available" as const, binding: routableBinding(binding) },
conversation,
);
}
@@ -432,7 +450,7 @@ export async function readSessionBindingSelectionCurrent(
if (records.length !== conversations.length) {
throw new Error("Session binding owner returned an incomplete conversation selection");
}
return records;
return records.map(routableBinding);
}
function createDefaultSessionBindingService(): AsyncSessionBindingService {
@@ -526,7 +544,7 @@ function createDefaultSessionBindingService(): AsyncSessionBindingService {
}
}
results.push(...listGenericCurrentConversationBindingsBySession(key));
return dedupeBindings(results);
return dedupeBindings(results).filter((record) => routableBinding(record));
},
resolveByConversation: (ref) => {
const normalized = normalizeConversationRef(ref);
@@ -534,10 +552,11 @@ function createDefaultSessionBindingService(): AsyncSessionBindingService {
return null;
}
const adapter = resolveAdapterForChannelAccount(normalized);
if (!adapter) {
return resolveGenericCurrentConversationBinding(normalized);
}
return adapter.resolveByConversation(normalized);
return routableBinding(
adapter
? adapter.resolveByConversation(normalized)
: resolveGenericCurrentConversationBinding(normalized),
);
},
resolveByConversationAsync: async (ref) => {
const normalized = captureConversationRef(ref);
@@ -554,7 +573,7 @@ function createDefaultSessionBindingService(): AsyncSessionBindingService {
assertCurrent: () => assertAdapterSelectionCurrent(normalized, null),
});
assertAdapterSelectionCurrent(normalized, adapter);
return binding;
return routableBinding(binding);
},
touch: (bindingId, at, scope) => {
const normalizedBindingId = bindingId.trim();
@@ -1,4 +1,205 @@
{
"base": "codex-dynamic-tools.telegram-direct.json",
"replace": {}
"replace": {
"sessions_spawn": {
"description": "Spawn child session; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot; `mode=\"session\"` persistent/thread-bound only on supporting requester channel. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. Default to a hidden subagent for internal QA, research, coding, review, tests, and parallel work supporting the current task. This includes substantial, bounded API/service investigations that can be handed off with the needed context and capabilities. Omit `visible` or set it false, and report results through the parent. `visible=true`: durable visible session. Use only when the user requests a separate session or needs to revisit and steer the work independently. Shows in web UI sidebar; works without UI: announcing runs report back, progress checkable. `group` places it in a custom sidebar group (a new name creates the group); omission or an empty string leaves it ungrouped. Subagent only; omit `mode` (`mode=\"run\"` is also accepted), `thread`, `thinking`, and `lightContext`; `attachments=[]` and omitted/blank `attachAs.mountPath` are accepted, but nonempty attachment staging is unsupported; inherits the caller tool-policy ceiling; select a registered project with `projectId` or a managed GitHub clone with `projectGitUrl` (mutually exclusive with each other and `cwd`); may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. When its accepted result includes `sessionUrl`, channel acknowledgements put the session URL on the first line and `Owner: <label>` on the second line. Omit `placement` or use `{kind:\"local\"}` for local execution. `{kind:\"profile\",profileId,os?,machineClass?}` selects a configured cloud profile and requires `visible=true` and `worktree=true`. Cloud placement creates first, dispatches, then starts the task; failures retain the child for inspection, never fall back locally. Session listing/addressing obeys `tools.sessions.visibility` (all: all sessions, cross-agent per tools.agentToAgent). Inherits parent workspace. Native task arrives in the child's initial `[Subagent Task]` message. Native: explicit context=\"isolated\" starts clean; context=\"fork\" copies requester transcript and requires the same agent. Omitted context follows configured threadBindings.defaultSpawnContext policy (fork by default) with thread=true; without a thread it is isolated. A PR/report, long runtime, or isolated worktree alone does not justify a sidebar session. A request for a subagent does not request a separate session. No spawn for quick lookup/single read. Check spawns via `subagents`/`sessions_history`. After spawn, do non-overlap work; follow the receipt's completion mode. When diagnosing a missing result from an announcing child, use `subagents` to inspect execution and delivery status. Recover existing results or follow up within the still-authorized task; respect intentional cancellation and never loop-poll.",
"inputSchema": {
"properties": {
"agentId": {
"type": "string"
},
"attachAs": {
"description": "Attachment mount hint; visible=true accepts only an omitted or blank mountPath.",
"properties": {
"mountPath": {
"type": "string"
}
},
"type": "object"
},
"attachments": {
"description": "Inline snapshots; visible=true accepts only an empty array.",
"items": {
"properties": {
"content": {
"type": "string"
},
"encoding": {
"enum": ["utf8", "base64"],
"type": "string"
},
"mimeType": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": ["name", "content"],
"type": "object"
},
"maxItems": 50,
"type": "array"
},
"cleanup": {
"description": "Hidden session cleanup; visible=true always keeps the session.",
"enum": ["delete", "keep"],
"type": "string"
},
"completionTarget": {
"description": "parent: return results in a private requester turn; no automatic channel delivery. Native hidden run only; unavailable with ACP, collect, visible, thread, session mode, or expectsCompletionMessage=false.",
"enum": ["parent"],
"type": "string"
},
"context": {
"description": "Native: explicit context=\"isolated\" starts clean; context=\"fork\" copies requester transcript and requires the same agent. Omitted context follows configured threadBindings.defaultSpawnContext policy (fork by default) with thread=true; without a thread it is isolated.",
"enum": ["isolated", "fork"],
"type": "string"
},
"cwd": {
"description": "Child working directory. Visible paths outside configured agent workspaces require operator.admin. Mutually exclusive with projectId/projectGitUrl. With no source selector and worktree=true: inherit the same-agent parent managed repository; otherwise use the target agent workspace.",
"type": "string"
},
"expectsCompletionMessage": {
"description": "false: fire-and-forget; requester gets no completion handoff when the child finishes.",
"type": "boolean"
},
"fastMode": {
"anyOf": [
{
"type": "boolean"
},
{
"const": "auto",
"type": "string"
}
]
},
"group": {
"description": "Custom sidebar group for a visible session; a new name creates the group. Omit or pass an empty string to leave it ungrouped.",
"type": "string"
},
"label": {
"description": "Short task title shown in UI lists; name the work, not the agent.",
"type": "string"
},
"lightContext": {
"description": "Light bootstrap; subagent only; unavailable with visible=true.",
"type": "boolean"
},
"mode": {
"description": "\"run\" one-shot; \"session\" persistent/thread-bound. Visible sessions accept only omitted/default \"run\" and remain persistent.",
"enum": ["run", "session"],
"type": "string"
},
"model": {
"type": "string"
},
"placement": {
"~optional": true,
"anyOf": [
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "local",
"type": "string"
}
},
"required": ["kind"],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "profile",
"type": "string"
},
"machineClass": {
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"os": {
"maxLength": 64,
"minLength": 1,
"type": "string"
},
"profileId": {
"maxLength": 256,
"minLength": 1,
"pattern": "^\\S(?:.*\\S)?$",
"type": "string"
}
},
"required": ["kind", "profileId"],
"type": "object"
}
],
"description": "Execution placement: omitted or {kind: \"local\"} uses local execution for native and ACP runs. {kind: \"profile\", profileId, os?, machineClass?} selects a configured cloud profile and requires visible=true and worktree=true. Never supply placeholder selectors. Omitted cloud selectors use profile defaults; the first task starts only after cloud dispatch."
},
"projectGitUrl": {
"description": "GitHub HTTPS or git@github.com repository URL for a visible session's managed clone; mutually exclusive with projectId and cwd. Local paths and file URLs are not accepted.",
"maxLength": 2048,
"type": "string"
},
"projectId": {
"description": "Registered project for a visible session; mutually exclusive with projectGitUrl and cwd.",
"type": "string"
},
"runtime": {
"description": "Runtime; visible=true requires \"subagent\".",
"enum": ["subagent"],
"type": "string"
},
"runTimeoutSeconds": {
"description": "Per-run timeout in seconds; overrides the configured subagent default. Zero disables the timeout.",
"minimum": 0,
"type": "integer"
},
"sandbox": {
"description": "\"inherit\" parent sandbox policy; \"require\" fails unless child is sandboxed.",
"enum": ["inherit", "require"],
"type": "string"
},
"task": {
"type": "string"
},
"taskName": {
"description": "Stable later-target alias; starts lowercase letter; then lowercase/digit/_/-.",
"type": "string"
},
"thinking": {
"description": "Thinking override; unavailable with visible=true.",
"type": "string"
},
"thread": {
"description": "Bind to the current conversation or a new thread, as supported by the channel; true defaults mode=\"session\"; unavailable with visible=true.",
"type": "boolean"
},
"visible": {
"description": "Persistent sidebar session only when the user requests a separate session or needs to revisit and steer it independently. Internal QA/coding/review/test workers: omit or false. Subagent runtime only; default run mode and empty attachments accepted; no thread/thinking/lightContext or attachment staging.",
"type": "boolean"
},
"worktree": {
"description": "Visible session worktree",
"type": "boolean"
},
"worktreeBaseRef": {
"description": "Worktree base ref",
"type": "string"
},
"worktreeName": {
"description": "Worktree name",
"type": "string"
}
},
"required": ["task"],
"type": "object"
},
"name": "sessions_spawn",
"type": "function"
}
}
}
@@ -155,7 +155,7 @@
"type": "function"
},
{
"description": "Spawn child session; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot; `mode=\"session\"` persistent/thread-bound only on supporting requester channel. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. Default to a hidden subagent for internal QA, research, coding, review, tests, and parallel work supporting the current task. This includes substantial, bounded API/service investigations that can be handed off with the needed context and capabilities. Omit `visible` or set it false, and report results through the parent. `visible=true`: durable visible session. Use only when the user requests a separate session or needs to revisit and steer the work independently. Shows in web UI sidebar; works without UI: announcing runs report back, progress checkable. `group` places it in a custom sidebar group (a new name creates the group); omission or an empty string leaves it ungrouped. Subagent only; omit `mode` (`mode=\"run\"` is also accepted), `thread`, `thinking`, and `lightContext`; `attachments=[]` and omitted/blank `attachAs.mountPath` are accepted, but nonempty attachment staging is unsupported; inherits the caller tool-policy ceiling; select a registered project with `projectId` or a managed GitHub clone with `projectGitUrl` (mutually exclusive with each other and `cwd`); may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. When its accepted result includes `sessionUrl`, channel acknowledgements put the session URL on the first line and `Owner: <label>` on the second line. Omit `placement` or use `{kind:\"local\"}` for local execution. `{kind:\"profile\",profileId,os?,machineClass?}` selects a configured cloud profile and requires `visible=true` and `worktree=true`. Cloud placement creates first, dispatches, then starts the task; failures retain the child for inspection, never fall back locally. Session listing/addressing obeys `tools.sessions.visibility` (all: all sessions, cross-agent per tools.agentToAgent). Inherits parent workspace. Native task arrives in the child's initial `[Subagent Task]` message. Native: explicit context=\"isolated\" starts clean; context=\"fork\" copies requester transcript and requires the same agent. Omitted context follows configured threadBindings.defaultSpawnContext policy (fork by default) with thread=true; without a thread it is isolated. A PR/report, long runtime, or isolated worktree alone does not justify a sidebar session. A request for a subagent does not request a separate session. No spawn for quick lookup/single read. Check spawns via `subagents`/`sessions_history`. After spawn, do non-overlap work; follow the receipt's completion mode. When diagnosing a missing result from an announcing child, use `subagents` to inspect execution and delivery status. Recover existing results or follow up within the still-authorized task; respect intentional cancellation and never loop-poll.",
"description": "Spawn child session; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot background. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. Default to a hidden subagent for internal QA, research, coding, review, tests, and parallel work supporting the current task. This includes substantial, bounded API/service investigations that can be handed off with the needed context and capabilities. Omit `visible` or set it false, and report results through the parent. `visible=true`: durable visible session. Use only when the user requests a separate session or needs to revisit and steer the work independently. Shows in web UI sidebar; works without UI: announcing runs report back, progress checkable. `group` places it in a custom sidebar group (a new name creates the group); omission or an empty string leaves it ungrouped. Subagent only; omit `mode` (`mode=\"run\"` is also accepted), `thread`, `thinking`, and `lightContext`; `attachments=[]` and omitted/blank `attachAs.mountPath` are accepted, but nonempty attachment staging is unsupported; inherits the caller tool-policy ceiling; select a registered project with `projectId` or a managed GitHub clone with `projectGitUrl` (mutually exclusive with each other and `cwd`); may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. When its accepted result includes `sessionUrl`, channel acknowledgements put the session URL on the first line and `Owner: <label>` on the second line. Omit `placement` or use `{kind:\"local\"}` for local execution. `{kind:\"profile\",profileId,os?,machineClass?}` selects a configured cloud profile and requires `visible=true` and `worktree=true`. Cloud placement creates first, dispatches, then starts the task; failures retain the child for inspection, never fall back locally. Session listing/addressing obeys `tools.sessions.visibility` (all: all sessions, cross-agent per tools.agentToAgent). Inherits parent workspace. Native task arrives in the child's initial `[Subagent Task]` message. Native: explicit context=\"isolated\" starts clean; context=\"fork\" copies requester transcript and requires the same agent. Omitted context is isolated. A PR/report, long runtime, or isolated worktree alone does not justify a sidebar session. A request for a subagent does not request a separate session. No spawn for quick lookup/single read. Check spawns via `subagents`/`sessions_history`. After spawn, do non-overlap work; follow the receipt's completion mode. When diagnosing a missing result from an announcing child, use `subagents` to inspect execution and delivery status. Recover existing results or follow up within the still-authorized task; respect intentional cancellation and never loop-poll.",
"inputSchema": {
"properties": {
"agentId": {
@@ -205,7 +205,7 @@
"type": "string"
},
"context": {
"description": "Native: explicit context=\"isolated\" starts clean; context=\"fork\" copies requester transcript and requires the same agent. Omitted context follows configured threadBindings.defaultSpawnContext policy (fork by default) with thread=true; without a thread it is isolated.",
"description": "Native: explicit context=\"isolated\" starts clean; context=\"fork\" copies requester transcript and requires the same agent. Omitted context is isolated.",
"enum": ["isolated", "fork"],
"type": "string"
},
@@ -241,8 +241,8 @@
"type": "boolean"
},
"mode": {
"description": "\"run\" one-shot; \"session\" persistent/thread-bound. Visible sessions accept only omitted/default \"run\" and remain persistent.",
"enum": ["run", "session"],
"description": "\"run\" one-shot. Visible sessions accept omitted/default \"run\" and remain persistent.",
"enum": ["run"],
"type": "string"
},
"model": {
@@ -327,10 +327,6 @@
"description": "Thinking override; unavailable with visible=true.",
"type": "string"
},
"thread": {
"description": "Bind to the current conversation or a new thread, as supported by the channel; true defaults mode=\"session\"; unavailable with visible=true.",
"type": "boolean"
},
"visible": {
"description": "Persistent sidebar session only when the user requests a separate session or needs to revisit and steer it independently. Internal QA/coding/review/test workers: omit or false. Subagent runtime only; default run mode and empty attachments accepted; no thread/thinking/lightContext or attachment staging.",
"type": "boolean"
@@ -1,4 +1,4 @@
--- telegram-direct-codex-message-tool.md sha256=d806c7418acf970b2c78d63b1e23039858790b9a68a2dc301758845dc8e6a378
--- telegram-direct-codex-message-tool.md sha256=1c362d437c5500b68c784a2cb31156cb68791dd2bdea8446d8d07bbd010e2c42
+++ discord-group-codex-message-tool.md sha256=ba51e7393a05d30363b322f2f004c9c063796c12e66419fd8977331a86f746b8
@@ -1,1 +1,1 @@
-# Telegram Direct Codex Message Tool Turn
@@ -31,6 +31,11 @@
@@ -241,1 +241,1 @@
- "chars": 1348,
+ "chars": 1347,
@@ -261,2 +261,2 @@
- "chars": 67534,
- "roughTokens": 16884
+ "chars": 68132,
+ "roughTokens": 17033
@@ -265,2 +265,2 @@
- "chars": 2992,
- "roughTokens": 748
@@ -42,8 +47,8 @@
+ "chars": 28695,
+ "roughTokens": 7174
@@ -277,2 +277,2 @@
- "chars": 95512,
- "roughTokens": 23878
- "chars": 94914,
- "roughTokens": 23729
+ "chars": 96829,
+ "roughTokens": 24208
@@ -281,2 +281,2 @@
@@ -258,8 +258,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 68132,
"roughTokens": 17033
"chars": 67534,
"roughTokens": 16884
},
"openClawDeveloperInstructions": {
"chars": 2992,
@@ -274,8 +274,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6845
},
"totalWithDynamicToolsJson": {
"chars": 95512,
"roughTokens": 23878
"chars": 94914,
"roughTokens": 23729
},
"userInputText": {
"chars": 879,
@@ -1,5 +1,5 @@
--- telegram-direct-codex-message-tool.md sha256=d806c7418acf970b2c78d63b1e23039858790b9a68a2dc301758845dc8e6a378
+++ telegram-heartbeat-codex-tool.md sha256=9d70748f0cdfc706ec5ca2881fa890a89e718e639d3cf357f97f03d3274fce1d
--- telegram-direct-codex-message-tool.md sha256=1c362d437c5500b68c784a2cb31156cb68791dd2bdea8446d8d07bbd010e2c42
+++ telegram-heartbeat-codex-tool.md sha256=1189bd69d63c1d3aea34c963148a30a37439c2afb9d463efcdbf6110f1fd698b
@@ -1,1 +1,1 @@
-# Telegram Direct Codex Message Tool Turn
+# Telegram Direct Codex Heartbeat Tool Turn
@@ -37,20 +37,20 @@
+ "chars": 1218,
+ "roughTokens": 305
@@ -261,2 +258,2 @@
- "chars": 68132,
- "roughTokens": 17033
+ "chars": 69625,
+ "roughTokens": 17407
- "chars": 67534,
- "roughTokens": 16884
+ "chars": 69027,
+ "roughTokens": 17257
@@ -273,2 +270,2 @@
- "chars": 27378,
- "roughTokens": 6845
+ "chars": 27715,
+ "roughTokens": 6929
@@ -277,2 +274,2 @@
- "chars": 95512,
- "roughTokens": 23878
+ "chars": 97342,
+ "roughTokens": 24336
- "chars": 94914,
- "roughTokens": 23729
+ "chars": 96744,
+ "roughTokens": 24186
@@ -281,2 +278,2 @@
- "chars": 879,
- "roughTokens": 220
+1 -1
View File
@@ -83,7 +83,7 @@ describe("happy path prompt snapshots", () => {
it("reconstructs complete Codex tool catalogs from readable full-tool overrides", async () => {
const scenarios = [
{ name: "telegram-direct", replacements: [] },
{ name: "discord-group", replacements: [] },
{ name: "discord-group", replacements: ["sessions_spawn"] },
{ name: "heartbeat-turn", replacements: ["openclaw_direct"] },
];