mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 14:12:28 +08:00
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:
@@ -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.
|
||||
|
||||
+22
-2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
+202
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-8
@@ -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"
|
||||
|
||||
+8
-3
@@ -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 @@
|
||||
|
||||
test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md
Vendored
+4
-4
@@ -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,
|
||||
|
||||
test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md.diff
Vendored
+10
-10
@@ -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
|
||||
|
||||
@@ -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"] },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user