diff --git a/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs b/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs
index 6eae2bb0b1..8984adf27e 100644
--- a/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs
+++ b/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs
@@ -239,9 +239,24 @@ try {
assert.equal(await english.getByRole('button', {name: 'Load next page', exact: true}).count(), 0);
assert.equal(await page.evaluate(() => document.activeElement?.textContent), '13 / 13 shown');
await english.getByRole('button', {name: 'Awaiting follow-up', exact: true}).click(); await rows(11);
+ // A useful reply can retain actual work even after delivery. Its later
+ // append-only result resolves this request, without settling its neighbors.
+ await fixture('report-partial');
+ await english.getByRole('button', {name: 'Refresh delegations', exact: true}).click();
+ await english.getByRole('searchbox', {name: 'Search delegations'}).fill('核对账单导出的字段映射');
+ await english.getByRole('searchbox', {name: 'Search delegations'}).press('Enter');
+ await rows(1);
+ await english.getByText('Partial result returned', {exact: true}).waitFor();
+ await english.locator('.conversation-request-row > summary').click();
+ await english.getByRole('heading', {name: 'Remaining work', exact: true}).waitFor();
+ assert.ok(await english.getByText('在隔离安装包中验证账单字段映射与失败恢复。', {exact: true}).isVisible());
+ await fixture('complete-partial');
+ await english.getByRole('button', {name: 'Refresh delegations', exact: true}).click(); await rows(0);
+ await english.getByRole('button', {name: 'All records', exact: true}).click(); await rows(1);
+ await english.getByText('Returned', {exact: true}).waitFor();
assert.equal(writes.some(path => /request-(?:inventory|control)|team-work/.test(path)), false);
assert.deepEqual(errors, []);
- console.log('PASS: packaged search, pre-pagination match, count, complete original across real pages, long body, corrupt journal and later-page failure/recovery, close/reopen, clear/paging, changed-snapshot More, returned history, stale response, Escape/focus, workspace isolation, narrow layout, English, source refusal/recovery and read-only receipts; original-reader POST delta zero, Chat/receipt state unchanged and no new Turns.');
+ console.log('PASS: packaged search, pre-pagination match, count, complete original across real pages, long body, corrupt journal and later-page failure/recovery, close/reopen, clear/paging, changed-snapshot More, partial/final returned history, stale response, Escape/focus, workspace isolation, narrow layout, English and source refusal/recovery; explicit fixture reports update return state, original-reader POST delta zero and no new Turns.');
}
} catch (error) {
if (page) {await page.screenshot({path: resolve(output, 'failure.png')}).catch(() => {}); await writeFile(resolve(output, 'failure.txt'), await page.locator('body').innerText());}
diff --git a/apps/presentation/dashboard/src/data/conversation-requests.ts b/apps/presentation/dashboard/src/data/conversation-requests.ts
index ade97f14ab..20238d59ee 100644
--- a/apps/presentation/dashboard/src/data/conversation-requests.ts
+++ b/apps/presentation/dashboard/src/data/conversation-requests.ts
@@ -3,7 +3,9 @@ import {requestJson} from "./chat";
import {requestBriefSchema} from "./request-control";
const rowSchema = z.object({request_id: z.string(), goal_id: z.string(), agent_id: z.string(),
- unfinished: z.boolean(), agenda: z.object({step: z.string()}).passthrough().nullable(),
+ unfinished: z.boolean(), agenda: z.object({step: z.string(),
+ pending_return_count: z.number().int().nonnegative().optional(),
+ remaining_work: z.object({summary: z.string(), resume_when: z.string()}).optional()}).passthrough().nullable(),
control_state: z.string().nullable(),
brief: requestBriefSchema.nullable().optional(), source_question: z.string(), request_revision: z.number().int().positive().nullable(),
read_status: z.string(), decision: z.string(), decision_reason: z.string().nullable().optional(),
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx b/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx
index 14113bcb06..afc888bbb6 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx
@@ -125,7 +125,9 @@ function requestStage(row: ConversationRequest, zh: boolean) {
case "reconcile_reservation": return zh ? "取消待收尾" : "Cancellation settling";
case "await_return_delivery": return zh ? "待送达" : "Awaiting delivery";
case "consume_peer_result": case "await_peer_consumption": return zh ? "结果待核对" : "Result awaiting readback";
- default: return row.decision === "adopt" ? (zh ? "待结论" : "Awaiting conclusion")
+ default: return row.agenda?.remaining_work ? ((row.agenda.pending_return_count ?? 0) > 0
+ ? (zh ? "部分结果待送达" : "Partial result awaiting delivery") : (zh ? "已回部分结果" : "Partial result returned"))
+ : row.decision === "adopt" ? (zh ? "待结论" : "Awaiting conclusion")
: row.decision === "defer" ? (zh ? "已暂缓" : "Deferred")
: (zh ? "待回复" : "Awaiting reply");
}
@@ -386,6 +388,10 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se
{row.brief?.purpose ?? (row.read_status === "unavailable" ? (zh ? "交办内容待恢复" : "Brief needs recovery") : row.source_question)}{requestStage(row, zh)}
{row.last_event_at ? : null}
+ {row.agenda?.remaining_work ?
+ {zh ? "仍需完成" : "Remaining work"}
{row.agenda.remaining_work.summary}
+ {zh ? "继续条件:" : "Continue when: "}{row.agenda.remaining_work.resume_when}
+ : null}
{row.decision_reason ?
{row.decision_reason}
: null}
{row.brief ? <>
{row.brief.context}
{row.brief.constraints.length ? <>
{zh ? "约束" : "Constraints"}
{row.brief.constraints.map((value, index) => - {value}
)}
> : null}
diff --git a/examples/personal-workspace-browser/steward_requests_fixture.py b/examples/personal-workspace-browser/steward_requests_fixture.py
index be861296ea..bb5eb215b5 100644
--- a/examples/personal-workspace-browser/steward_requests_fixture.py
+++ b/examples/personal-workspace-browser/steward_requests_fixture.py
@@ -5,7 +5,8 @@
decisions and return delivery below use the production owners in a temp runtime.
Commands on stdin: inspect, fault, fault-window, deliver-window, report-window, recover,
deliver, source-unavailable, source-recover, local-source-unavailable,
-local-source-recover, history-corrupt/history-recover (--long-history only), stop. Window faults/returns apply
+local-source-recover, history-corrupt/history-recover (--long-history only),
+report-partial, complete-partial, stop. Window faults/returns apply
after the first page to challenge multi-window refresh readback.
"""
from __future__ import annotations
@@ -222,6 +223,20 @@ def source_hashes():
drain(runtime, registry, store, lambda *_: None)
elif command == "deliver":
drain(runtime, registry, store, lambda *_: None)
+ elif command == "report-partial":
+ prior = _root(runtime) / "replies" / first_receipt["request_id"] / "conclusion.json"
+ report(runtime, "billing-exports", "builder", first_receipt["request_id"], "conclusion",
+ "字段映射的修改已完成,安装后的核验仍需继续。", registry=registry,
+ caller_goal_ref=first_receipt["goal_ref"],
+ update_id="remaining-installed-check" if prior.exists() else None,
+ remaining_work={"summary": "在隔离安装包中验证账单字段映射与失败恢复。",
+ "resume_when": "隔离安装包就绪后,由原负责人继续核验并返回实际结果。"})
+ drain(runtime, registry, store, lambda *_: None)
+ elif command == "complete-partial":
+ report(runtime, "billing-exports", "builder", first_receipt["request_id"], "conclusion",
+ "隔离包的字段映射与失败恢复已核验,原检查记录保留。", registry=registry,
+ caller_goal_ref=first_receipt["goal_ref"], update_id="qualified-installed-check")
+ drain(runtime, registry, store, lambda *_: None)
elif command == "conversation-arrival":
store.append_message(session["session_id"], role="assistant", text="新的委派阅读测试消息。", origin="manager_followup")
elif command == "source-unavailable":
diff --git a/loopx/capabilities/manager_context/README.md b/loopx/capabilities/manager_context/README.md
index 8dbb6c0f50..1a2bfef80c 100644
--- a/loopx/capabilities/manager_context/README.md
+++ b/loopx/capabilities/manager_context/README.md
@@ -622,8 +622,9 @@ before the last position. Peer results retain their separate consumption flow
and are not paginated by this cursor. `status --offset/--limit` remains separate.
After `acknowledge`, the request remains visible until the answer obligation is
-resolved. `pending.items` retains its existing receipt-only semantics: it stops
-including a request once its conclusion exists. The separate unfinished agenda
+resolved. `pending.items` describes the current reply obligation: an ordinary
+conclusion resolves it, while an explicitly reported remainder keeps it open.
+Neither case certifies fulfillment of the user's requirements. The unfinished agenda
keeps undelivered conclusions visible, and keeps peer work until the requester
explicitly consumes the current result. The worker uses `link` for canonical Todo/evidence lineage
and `report` to publish the answer intended for the original audience:
@@ -716,6 +717,46 @@ conclude with the adopted/rejected planning decision; deferred or blocked work
must explain the concrete condition and next action. Completion of this exchange
is separate from completion of the receiving Goal.
+A partial conclusion can return a useful result while explicitly retaining work
+on this request. Use the existing result publication, without creating a Todo:
+
+```sh
+loopx manager-inbox report --goal-id research --agent-id worker \
+ --request-id
--reply-text 'The implementation is ready; installed validation remains.' \
+ --remaining-work 'Validate the repair in the isolated installed package.' \
+ --resume-when 'The isolated package is available to the receiving Agent.'
+```
+
+MCP uses `return_result(..., remaining_work={"summary": "...", "resume_when": "..."})`.
+Both fields are required, each at most 600 Unicode characters; the rendered reply
+still fits the existing 20,000-character budget. They belong to the receiver's
+immutable result for its exact request revision. They do not transfer ownership,
+schedule execution or create a second completion state. The receiver retains the
+remainder under its existing authority; linked Todo status cannot resolve it.
+`resume_when` here describes a condition, unlike a typed Todo scheduler rule.
+
+The pending Inbox, Turn agenda and App delegation reader retain this request
+after the partial reply is delivered or consumed. The original conversation and
+provider reply also show the remainder and continuation condition. Queued replies
+remain distinct from delivered ones. A peer can consume the partial result once
+and still await the final result. Other requests sharing the same Todo retain
+their independent scope. Source correction and cancellation keep their existing
+request-control rules; an old revision's remainder cannot settle a newer request.
+
+When the remaining scope is actually resolved, append a conclusion using a stable
+`--update-id` and omit `--remaining-work`/`--resume-when`. This resolves the reply
+obligation after normal return processing, not independent acceptance. Retry with
+the same text and remainder: changing either under a committed result key is a
+conflict. Valid unmarked legacy results retain their ordinary reply state and
+unknown fulfillment qualification; unreadable or conflicting result chains stay
+unavailable. This path does not batch-close old work.
+Before rolling back to a reader without remainder support, stop new partial
+publications and reconcile the retained result chains with the compatible
+reader. Do not rewrite their receipts or treat an older reader's empty queue as
+fulfilled work. No installation, host wake or permission change is implied.
+普通短答仍可直接返回,无需创建任务;阶段结果保留原负责人和具体续接条件,
+不能用 Todo done、回传或送达替代原要求的验收。
+
If a returned blocker or draft is followed by a materially changed fact, append
a conclusion with a stable `--update-id`; do not replace the first conclusion or
create another request merely to return its result:
diff --git a/loopx/capabilities/manager_context/roundtrip.py b/loopx/capabilities/manager_context/roundtrip.py
index a5bb1a07a5..2f9cbb505d 100644
--- a/loopx/capabilities/manager_context/roundtrip.py
+++ b/loopx/capabilities/manager_context/roundtrip.py
@@ -7,6 +7,7 @@
from __future__ import annotations
import logging
+import json
import threading
from itertools import islice
from contextlib import contextmanager, ExitStack
@@ -179,6 +180,7 @@ def report(
attachment_refs=None,
workspace=None,
request_revision=None,
+ remaining_work=None,
):
"""Chat audience adapter; the shared Inbox owns result validation/persistence."""
if scope is None and registry is not None:
@@ -204,13 +206,15 @@ def report(
attachment_refs=attachment_refs,
workspace=workspace,
request_revision=request_revision,
+ remaining_work=remaining_work,
)
row = _entry(root, goal_id, agent_id, request_id, scope=scope)
route = _route(root, row, scope=scope)
if route.get("kind") == "peer" and phase != "conclusion":
raise ValueError("peer replies require a conclusion; report a concrete result or blocker")
+ audience_text = text + ("\n" + json.dumps(remaining_work, ensure_ascii=False) if remaining_work is not None else "")
if (route["channel_id"] != "peer" and not conversation_scope(route)["private_conversation"]
- and not scan_public_boundary_text(text)["ok"]):
+ and not scan_public_boundary_text(audience_text)["ok"]):
raise ValueError("reply contains private boundary material; write an audience-safe conclusion")
from ...control_plane.collaboration.inbox import record_result
attachments = None
@@ -234,6 +238,7 @@ def report(
update_id=update_id,
attachments=attachments,
request_revision=request_revision,
+ remaining_work=remaining_work,
),
"status": (
"queued_for_requester"
diff --git a/loopx/cli_commands/manager_inbox.py b/loopx/cli_commands/manager_inbox.py
index 2652620293..cbd6564818 100644
--- a/loopx/cli_commands/manager_inbox.py
+++ b/loopx/cli_commands/manager_inbox.py
@@ -79,6 +79,8 @@ def register_manager_inbox(subparsers, add_format):
"--phase", choices=("decision", "conclusion"), default="conclusion"
)
parser.add_argument("--reply-text")
+ parser.add_argument("--remaining-work", help="For report: accepted work still owed after this partial conclusion; pair with --resume-when. No Todo or execution is created.")
+ parser.add_argument("--resume-when", help="For report: concrete continuation condition for --remaining-work; descriptive only, not a scheduler rule.")
parser.add_argument("--attachment-ref", action="append", default=[],
help="For report: explicit relative file in the registered Goal workspace; bound-owner App only, at most four files.")
parser.add_argument("--update-id", help="For report: stable identity for a later conclusion; retry the same id and text.")
@@ -95,6 +97,11 @@ def register_manager_inbox(subparsers, add_format):
def handle_manager_inbox(args, registry_path, runtime_root):
try:
+ remainder = getattr(args, "remaining_work", None)
+ resume = getattr(args, "resume_when", None)
+ if remainder is not None or resume is not None:
+ if args.manager_inbox_action != "report" or remainder is None or resume is None:
+ raise ValueError("--remaining-work and --resume-when require report and each other")
if args.manager_inbox_action == "original-conversation":
from ..control_plane.collaboration.peers import original_conversation_request
if not args.conversation_operation or not args.conversation_file:
@@ -295,6 +302,7 @@ def handle_manager_inbox(args, registry_path, runtime_root):
update_id=getattr(args, "update_id", None),
attachment_refs=getattr(args, "attachment_ref", None),
request_revision=getattr(args, "request_revision", None),
+ remaining_work={"summary": remainder, "resume_when": resume} if remainder is not None else None,
workspace=Path.cwd(),
registry=registry_path,
)
diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py
index 7005b93eba..cd38bfc701 100644
--- a/loopx/collaboration_mcp.py
+++ b/loopx/collaboration_mcp.py
@@ -322,13 +322,18 @@ def request_peer(
"For a bound-owner App return, attachment_refs explicitly attaches up to four "
"relative Goal-workspace files; a filename in text does not attach a file. "
"Neither a blocker nor a returned result certifies completion of the work. "
+ "For a partial result, supply remaining_work with summary and resume_when; "
+ "it stays visible for the receiver without creating a Todo or wakeup. "
+ conversation_answer_instruction()
))
def return_result(request_id: str, text: str, update_id: str | None = None,
- attachment_refs: list[str] | None = None, request_revision: int | None = None) -> dict:
+ attachment_refs: list[str] | None = None, request_revision: int | None = None,
+ remaining_work: dict[str, str] | None = None) -> dict:
"""Return a conclusion to the original requester. For a later changed fact,
append an update with a stable update_id; retry with the same id and text.
Neither a blocker nor a returned result certifies completion of the work.
+ For a partial result, remaining_work contains summary and resume_when;
+ the receiving Agent still owns this remainder. It creates no Todo or wakeup.
"""
check_scope()
# The host adapter selects Chat/Lark transport; the shared collaboration
@@ -345,6 +350,7 @@ def return_result(request_id: str, text: str, update_id: str | None = None,
update_id=update_id,
attachment_refs=attachment_refs,
request_revision=request_revision,
+ remaining_work=remaining_work,
workspace=workspace,
registry=registry,
caller_goal_ref=caller_goal_ref,
diff --git a/loopx/control_plane/collaboration/inbox.py b/loopx/control_plane/collaboration/inbox.py
index 55d0912344..6a061ca544 100644
--- a/loopx/control_plane/collaboration/inbox.py
+++ b/loopx/control_plane/collaboration/inbox.py
@@ -435,6 +435,7 @@ def record_result(
update_id: str | None = None,
attachments: list[dict[str, Any]] | None = None,
request_revision: int | None = None,
+ remaining_work: dict[str, str] | None = None,
):
"""Persist a receiver conclusion; the transport adapter validates its audience."""
request_id = row["request_id"]
@@ -462,6 +463,7 @@ def record_result(
"decision": decision.get("decision"), "update_id": update_id,
"request_revision": request_revision, "legacy_assessment": decision or None,
"attachments": attachments,
+ "remaining_work": remaining_work,
"results": observations,
})
except EffectRuntimeRejected as exc:
@@ -499,6 +501,7 @@ def inspect_result_identity(reply, row, path):
"request": row, "phase": reply.get("phase"), "text": reply.get("text"),
"decision": reply.get("decision"), "update_id": reply.get("update_id"),
"attachments": reply.get("attachments"),
+ "remaining_work": reply.get("remaining_work"),
"request_revision": reply.get("request_revision"), "legacy_assessment": legacy.get("value"),
"results": observations,
})
@@ -521,7 +524,7 @@ def _result_observation(key, value):
"value": {field: value[field] for field in (
"request_id", "goal_id", "agent_id", "source_id", "goal_ref", "phase",
"decision", "result_key", "previous_result_key", "update_id", "attachments",
- "request_revision", "request_revision_digest", "recipient", "requester",
+ "request_revision", "request_revision_digest", "recipient", "requester", "remaining_work",
) if field in value},
"text_chars": len(text),
"text_sha256": hashlib.sha256(text.encode()).hexdigest(),
diff --git a/loopx/control_plane/collaboration/inbox_read.ts b/loopx/control_plane/collaboration/inbox_read.ts
index 40a3052134..76e43259b7 100644
--- a/loopx/control_plane/collaboration/inbox_read.ts
+++ b/loopx/control_plane/collaboration/inbox_read.ts
@@ -148,7 +148,7 @@ function resultObservation(key: string, reply: JsonObject): JsonObject {
if (/[\uD800-\uDFFF]/u.test(text)) fail("invalid reply text encoding");
return {key, value: Object.fromEntries(["request_id", "goal_id", "agent_id", "source_id", "goal_ref", "phase",
"decision", "result_key", "previous_result_key", "update_id", "attachments", "request_revision",
- "request_revision_digest", "recipient", "requester"].filter(field => Object.hasOwn(reply, field)).map(field => [field, reply[field]])),
+ "request_revision_digest", "recipient", "requester", "remaining_work"].filter(field => Object.hasOwn(reply, field)).map(field => [field, reply[field]])),
text_chars: [...text].length, text_sha256: createHash("sha256").update(text).digest("hex")};
}
function receiptState(root: string, row: JsonObject): JsonObject {
@@ -160,11 +160,17 @@ function receiptState(root: string, row: JsonObject): JsonObject {
if (Object.hasOwn(row, "request_control")) {
observation.request = row;
observation.read = observe(join(root, "reads", id + ".json"));
- observation.results = resultKeys(folder).map(key => {
- try { return resultObservation(key, readRecord(join(folder, key + ".json"))); }
- catch (error) { if (!recordFailure(error)) throw error; return {unavailable: true}; }
- });
}
+ // Later conclusions may retain an explicit remainder, also on legacy requests.
+ // Read their existing immutable chain; never infer scope from reply prose or Todos.
+ observation.results = resultKeys(folder).map(key => {
+ try {
+ const initial = jsonObject(observation.conclusion);
+ return resultObservation(key, key === "conclusion" && initial?.state === "read"
+ ? requireJsonObject(initial.value, "initial conclusion") : readRecord(join(folder, key + ".json")));
+ }
+ catch (error) { if (!recordFailure(error)) throw error; return {unavailable: true}; }
+ });
return (inspectCollaborationInboxReceipts({observations: [observation]}).items as JsonObject[])[0];
}
function inboxItem(row: JsonObject, state: JsonObject): JsonObject {
@@ -175,6 +181,7 @@ function inboxItem(row: JsonObject, state: JsonObject): JsonObject {
request_revision: view.revision, request_revision_digest: view.revision_digest,
source_request: {request_id: row.request_id, request_revision: view.revision, request_digest: view.revision_digest}});
item.recorded_decision = state.recorded_decision;
+ if (state.remaining_work) item.remaining_work = state.remaining_work;
if (state.recorded_decision !== null) Object.assign(item, {receiver_decision_recorded: true,
next_action: "Review this request's recorded decision and linked work before returning its actual result. A recorded adoption is not a completed task; preserve the current owner and do not reprioritize unrelated work."});
if ((state.warnings as unknown[]).length) Object.assign(item, {warnings: state.warnings,
@@ -218,15 +225,17 @@ async function pendingPage(source: InboxSource, params: JsonObject): Promise
- resultObservation(candidate, readRecord(join(folder, candidate + ".json")))) : [resultObservation(key, reply)];
+ const results = observations ?? (reply.update_id != null || reply.phase === "decision" ? resultKeys(folder).map(candidate =>
+ resultObservation(candidate, readRecord(join(folder, candidate + ".json")))) : [resultObservation(key, reply)]);
const legacy = Object.hasOwn(row, "request_control") || Object.hasOwn(reply, "request_revision")
? observe(join(root, "decisions", String(row.request_id) + ".json")) : {};
const inspected = planCollaborationResult({request: row, phase: reply.phase, text: reply.text,
decision: reply.decision, update_id: reply.update_id, attachments: reply.attachments,
+ remaining_work: reply.remaining_work,
request_revision: reply.request_revision, legacy_assessment: legacy.value, results});
return (reply.result_key ?? reply.phase) === key ? inspected : null;
} catch (error) { if (!recordFailure(error)) throw error; return null; }
@@ -281,7 +290,8 @@ async function collectReturns(source: InboxSource): Promise<{view: JsonObject; d
brief: inspected.request_brief ?? row.brief,
...(Object.hasOwn(inspected, "request_revision") ? {request_revision: inspected.request_revision,
current_request_revision: inspected.current_request_revision} : {}),
- text: reply.text, decision: reply.decision, created_at: reply.created_at});
+ text: reply.text, ...(reply.remaining_work ? {remaining_work: reply.remaining_work} : {}),
+ decision: reply.decision, created_at: reply.created_at});
if (items.length > 20) break;
deliveries.push({key, request: Object.fromEntries(["request_id", "goal_id", "agent_id", "goal_ref", "recipient", "requester"]
.filter(field => Object.hasOwn(row, field)).map(field => [field, row[field]])),
@@ -321,10 +331,13 @@ function replyStatuses(root: string, row: JsonObject): JsonObject[] {
function agendaResults(root: string, row: JsonObject): JsonObject[] {
const folder = join(root, "replies", requestId(row.request_id));
const statuses = new Map(replyStatuses(root, row).map(status => [status.result_key ?? status.phase, status.status]));
- return resultKeys(folder).flatMap(key => {
- const reply = readRecord(join(folder, key + ".json"));
+ const replies = resultKeys(folder).map(key => ({key, reply: readRecord(join(folder, key + ".json"))}));
+ // Reuse immutable request-local observations; each result still goes through
+ // the publication validator and its current delivery/consumption readback.
+ const observations = replies.map(({key, reply}) => resultObservation(key, reply));
+ return replies.flatMap(({key, reply}) => {
if (reply.phase !== "conclusion") return [];
- if (!inspectResult(root, row, key, reply)) fail("delegation result identity unavailable");
+ if (!inspectResult(root, row, key, reply, observations)) fail("delegation result identity unavailable");
return [{result_key: key, request_revision: reply.request_revision ?? 1, status: statuses.get(key),
consumed: row.source_kind === "peer" && returnConsumed(root, key, row, reply, row.source_agent_id)}];
});
@@ -364,6 +377,7 @@ async function turnContext(source: InboxSource, params: JsonObject): Promise
@@ -33,10 +33,10 @@ function receiverFollowthrough(value: unknown): JsonObject {
const evidenceUnavailable = requireBoolean(params.evidence_unavailable, "receiver evidence availability");
// Only explicit request links can establish its work. A busy Agent, a
// matching title, and an unrelated rolling review task are not such links.
- const unresolved = work.some((todo) => todo.status !== "done");
const step = kind === "receipt_unavailable" || evidenceUnavailable ? "recover_evidence"
+ : kind === "settled" ? "inspect_recorded_result"
: decision === null ? "assess_request"
- : decision === "adopt" && (unresolved || work.length === 0) ? "review_request_work"
+ : decision === "adopt" ? "review_request_work"
: "return_answer";
return {
step,
@@ -86,14 +86,18 @@ function inspect(value: unknown): JsonObject {
card_read_status: readUnavailable ? "unavailable" : readAt !== null ? "supplied" : "pending",
card_decision: recorded ?? (view.assessment_readback === "unavailable" ? "unavailable" : "pending")};
const results = collaborationResultObservations(request, row.results, recorded, legacy);
- const answered = results.some(result => result.value.phase === "conclusion"
- && (result.value.request_revision ?? 1) === view!.revision);
+ const latest = results.filter(result => result.value.phase === "conclusion"
+ && (result.value.request_revision ?? 1) === view!.revision)
+ .sort((a, b) => a.key.localeCompare(b.key)).at(-1);
+ const remaining = latest?.remainingWork;
+ const answered = latest !== undefined && !remaining;
const warnings: Warning[] = view.assessment_readback === "unavailable" ? ["decision_unreadable_or_conflicting"] : [];
const kind: InboxState = warnings.length ? "receipt_unavailable"
: view.control_state === "cancelled" ? "settled"
: view.active_reservation !== null ? "awaiting_conclusion"
: !recorded ? "pending" : answered ? "settled" : "awaiting_conclusion";
return {kind, recorded_decision: recorded, warnings, request_control_view: view, readback,
+ ...(remaining ? {remaining_work: remaining} : {}),
result_revisions: results.map(result => ({result_key: result.key, request_revision: result.value.request_revision ?? 1}))};
} catch (error) {
if (!(error instanceof EffectRuntimeRequestError)) throw error;
@@ -105,6 +109,7 @@ function inspect(value: unknown): JsonObject {
const recorded = decision.state === "read" && sameRequest(decision.value, request, false)
? receiverDecision(decision.value.decision) : null;
const warnings: Warning[] = [];
+ let remaining: JsonObject | null = null;
if (decision.state !== "absent" && !recorded) warnings.push("decision_unreadable_or_conflicting");
if (conclusion.state !== "absent") {
const result = conclusion.state === "read" ? conclusion.value : null;
@@ -116,12 +121,26 @@ function inspect(value: unknown): JsonObject {
}
if (decision.state === "absent") warnings.push("decision_missing_for_conclusion");
}
+ try {
+ // An explicit partial result stays owed even after its reply is delivered.
+ // Later append-only results supersede its scope, never its stored receipt.
+ if (row.results !== undefined) {
+ const latest = collaborationResultObservations(request, row.results, recorded)
+ .filter(result => result.value.phase === "conclusion")
+ .sort((a, b) => a.key.localeCompare(b.key)).at(-1);
+ remaining = latest?.remainingWork ?? null;
+ } else if (conclusion.state === "read" && conclusion.value) remaining = resultRemainingWork(conclusion.value.remaining_work);
+ } catch (error) {
+ if (!(error instanceof EffectRuntimeRequestError)) throw error;
+ if (!warnings.includes("conclusion_unreadable_or_conflicting")) warnings.push("conclusion_unreadable_or_conflicting");
+ }
// This is receipt/readback state only. Settled means a receiver reply exists
// (or a legacy ACK owes no return), never accepted work or verified delivery.
const kind: InboxState = warnings.length ? "receipt_unavailable"
: !recorded ? "pending"
+ : remaining ? "awaiting_conclusion"
: conclusion.state === "read" || !routePresent ? "settled" : "awaiting_conclusion";
- return { kind, recorded_decision: recorded, warnings };
+ return { kind, recorded_decision: recorded, warnings, ...(remaining ? {remaining_work: remaining} : {}) };
}
/** One bounded read-model batch; no store writes, execution or authority. */
diff --git a/loopx/control_plane/collaboration/peers.py b/loopx/control_plane/collaboration/peers.py
index 7d31fdca5e..7c15054291 100644
--- a/loopx/control_plane/collaboration/peers.py
+++ b/loopx/control_plane/collaboration/peers.py
@@ -654,6 +654,7 @@ def return_result(
scope=None,
update_id=None,
request_revision=None,
+ remaining_work=None,
):
"""Route by the saved recipient, never by an Agent's coordinator role."""
if scope is None and registry is not None:
@@ -672,6 +673,7 @@ def return_result(
scope=goal_scope,
update_id=update_id,
request_revision=request_revision,
+ remaining_work=remaining_work,
)
row = _entry(root, goal_id, agent_id, request_id, scope=scope)
if row.get("source_kind") != "peer":
@@ -702,6 +704,7 @@ def return_result(
route=route,
update_id=update_id,
request_revision=request_revision,
+ remaining_work=remaining_work,
),
"status": "queued_for_requester",
}
diff --git a/loopx/control_plane/collaboration/result_publication.ts b/loopx/control_plane/collaboration/result_publication.ts
index 1305b550ca..2a4175b2cf 100644
--- a/loopx/control_plane/collaboration/result_publication.ts
+++ b/loopx/control_plane/collaboration/result_publication.ts
@@ -17,6 +17,31 @@ function decisionKey(revision: number): string {
}
const UPDATE_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/;
+/** Receiver-authored scope on an immutable result, not a completion verdict or scheduler. */
+export function resultRemainingWork(value: unknown): JsonObject | null {
+ if (value === undefined || value === null) return null;
+ const work = requireJsonObject(value, "remaining work");
+ if (Object.keys(work).sort().join(",") !== "resume_when,summary") {
+ throw new EffectRuntimeRequestError("remaining work needs a summary and resume_when only");
+ }
+ const result: JsonObject = {};
+ for (const key of ["summary", "resume_when"]) {
+ const text = work[key];
+ if (typeof text !== "string" || !text.trim() || [...text].length > 600
+ || /[\uD800-\uDFFF]/u.test(text)) {
+ throw new EffectRuntimeRequestError("remaining work fields must contain 1-600 Unicode characters");
+ }
+ result[key] = text.trim();
+ }
+ return result;
+}
+
+/** The original conversation and peer return read the same bounded remainder. */
+export function collaborationResultText(reply: JsonObject): string {
+ const work = resultRemainingWork(reply.remaining_work);
+ return String(reply.text) + (work ? `\n\n仍需完成 / Remaining work: ${work.summary}\n继续条件 / Continue when: ${work.resume_when}` : "");
+}
+
export function resultAttachmentRefs(params: JsonObject): JsonObject {
if (params.workspace_current === false) {
throw new EffectRuntimeRequestError("result file workspace Goal instance changed");
@@ -63,6 +88,10 @@ export function collaborationResultObservations(request: JsonObject, observation
const results = observations.map((item) => {
const record = requireJsonObject(item, "result observation");
const value = requireJsonObject(record.value, "result receipt");
+ const remainingWork = resultRemainingWork(value.remaining_work);
+ if (remainingWork && value.phase !== "conclusion") {
+ throw new EffectRuntimeRequestError("remaining work requires a conclusion");
+ }
const historical = (request.request_control !== undefined || value.request_revision !== undefined) ? requestRevisionReceiptContext(request, value.request_revision, legacyAssessment) : null;
if (typeof record.key !== "string" || !sameRequest(value, request, true)
|| value.decision !== (historical === null ? decision : (historical.assessment as JsonObject | null)?.decision)
@@ -73,13 +102,16 @@ export function collaborationResultObservations(request: JsonObject, observation
throw new EffectRuntimeRequestError("result receipt identity or content conflict");
}
return { key: record.key, value, textSha256: record.text_sha256,
- attachments: resultAttachments(value.attachments) };
+ attachments: resultAttachments(value.attachments), remainingWork };
});
if (new Set(results.map((r) => r.key)).size !== results.length) {
throw new EffectRuntimeRequestError("duplicate result observation");
}
let previous = "conclusion";
const updates = results.filter((r) => UPDATE_KEY.test(r.key)).sort((a, b) => a.key.localeCompare(b.key));
+ if (updates.length && !results.some(result => result.key === "conclusion")) {
+ throw new EffectRuntimeRequestError("initial conclusion is required");
+ }
const ids = new Set();
for (const [index, receipt] of updates.entries()) {
if (receipt.key !== `conclusion-${String(index + 1).padStart(8, "0")}`
@@ -112,6 +144,10 @@ export function collaborationResultObservations(request: JsonObject, observation
export function planCollaborationResult(params: JsonObject): JsonObject {
const request = requireJsonObject(params.request, "result request");
const phase = requireStringLiteral(params.phase, ["decision", "conclusion"], "result phase");
+ const remainingWork = resultRemainingWork(params.remaining_work);
+ if (remainingWork && phase !== "conclusion") {
+ throw new EffectRuntimeRequestError("remaining work requires a conclusion");
+ }
const legacyOriginal = request.schema_version === "loopx_manager_context_entry_v1"
&& request.request_control === undefined;
if (legacyOriginal && params.request_revision != null && params.request_revision !== 1) {
@@ -127,11 +163,13 @@ export function planCollaborationResult(params: JsonObject): JsonObject {
if (typeof params.text !== "string" || !params.text.trim() || Array.from(params.text).length > 20000) {
throw new EffectRuntimeRequestError("bounded reply text is required");
}
+ if ([...collaborationResultText({text: params.text.trim(), remaining_work: remainingWork})].length > 20000) {
+ throw new EffectRuntimeRequestError("reply and remaining work must fit the existing 20000-character result budget");
+ }
const results = collaborationResultObservations(request, params.results, decision, params.legacy_assessment ?? null);
const updates = results.filter((r) => UPDATE_KEY.test(r.key)).sort((a, b) => a.key.localeCompare(b.key));
const previous = updates.at(-1)?.key ?? "conclusion";
const conclusion = results.find((r) => r.key === "conclusion");
- if (updates.length && !conclusion) throw new EffectRuntimeRequestError("initial conclusion is required");
const revision = context?.revision ?? 1;
const primaryUpdate = `request-revision-${revision}`;
const initialForRevision = conclusion && (conclusion.value.request_revision ?? 1) === revision;
@@ -150,12 +188,14 @@ export function planCollaborationResult(params: JsonObject): JsonObject {
Object.assign(value, { phase, decision, text: params.text.trim() });
if (context !== null) Object.assign(value, {request_revision: context.revision, request_revision_digest: context.revision_digest});
if (attachments.length) value.attachments = attachments;
+ if (remainingWork) value.remaining_work = remainingWork;
const key = old?.key ?? (isUpdate ? `conclusion-${String(updates.length + 1).padStart(8, "0")}` : initialKey);
if (phase === "decision" && key !== phase) value.result_key = key;
if (isUpdate) Object.assign(value, { result_key: key, update_id: updateId, previous_result_key: old?.value.previous_result_key ?? previous });
if (old && (old.textSha256 !== createHash("sha256").update(String(value.text)).digest("hex") || old.value.phase !== phase
|| (old.value.request_revision ?? 1) !== revision
- || JSON.stringify(old.attachments) !== JSON.stringify(attachments))) {
+ || JSON.stringify(old.attachments) !== JSON.stringify(attachments)
+ || JSON.stringify(old.remainingWork) !== JSON.stringify(remainingWork))) {
throw new EffectRuntimeRequestError("reply already committed; conflicting replacement rejected; use a stable update_id for a later conclusion");
}
const interimSuperseded = phase === "decision" && results.some(r =>
diff --git a/loopx/control_plane/collaboration/return_delivery.ts b/loopx/control_plane/collaboration/return_delivery.ts
index b7bb5a2e98..91fa6bdd9e 100644
--- a/loopx/control_plane/collaboration/return_delivery.ts
+++ b/loopx/control_plane/collaboration/return_delivery.ts
@@ -16,7 +16,7 @@ import { atomicWriteJson, withFileMutationLock } from "../effect_runtime_io.ts";
import { parseTodoTimestampMicros } from "../runtime_timestamp.ts";
import { decideCollaborationLifecycle } from "./goal_instance_lifecycle.ts";
import { resolveConversationScope } from "./conversation_scope.ts";
-import { planCollaborationResult, collaborationResultDeliveryReady } from "./result_publication.ts";
+import { planCollaborationResult, collaborationResultDeliveryReady, collaborationResultText } from "./result_publication.ts";
export const MANAGER_RETURN_DELIVERY_ATTEMPT_SCHEMA =
"manager_return_delivery_attempt_v0";
@@ -340,6 +340,7 @@ async function inspectReturnPublication(command: ReturnCommand): Promise<{ reply
catch { /* The shared historical-revision decoder rejects unavailable assessment evidence. */ }
const inspected = planCollaborationResult({ request: entry, phase: reply.phase, text: reply.text,
decision: reply.decision, update_id: reply.update_id, attachments: reply.attachments,
+ remaining_work: reply.remaining_work,
request_revision: reply.request_revision, legacy_assessment: legacy, results });
if ((reply.result_key ?? reply.phase) !== command.key || entry.request_id !== command.request.request_id) {
throw new EffectRuntimeRequestError("return reply identity mismatch");
@@ -435,7 +436,7 @@ async function prepareReturn(command: ReturnCommand, state: JsonObject): Promise
const verify = priorStatus === "verification_required" || command.exact && state.attempt != null;
const prefix = command.exact ? (reply.phase === "conclusion" ? "处理结论" : "处理进展")
: (reply.phase === "conclusion" ? "协作回复" : "协作进展");
- const text = `${prefix} · ${reply.agent_id} · 委托 ${String(reply.request_id).slice(0, 8)}\n\n${reply.text}`;
+ const text = `${prefix} · ${reply.agent_id} · 委托 ${String(reply.request_id).slice(0, 8)}\n\n${collaborationResultText(reply)}`;
return returnResponse({ action: privateConversation ? "private" : verify ? "verify" : "send",
text, message_id: returnMessageId(command), attempt: verify ? savedAttempt(state.attempt) : null, attachments: reply.attachments ?? [],
diff --git a/loopx/control_plane/collaboration/turn_context.ts b/loopx/control_plane/collaboration/turn_context.ts
index 5f69f64114..82e1c0d7fe 100644
--- a/loopx/control_plane/collaboration/turn_context.ts
+++ b/loopx/control_plane/collaboration/turn_context.ts
@@ -29,10 +29,10 @@ export function projectDelegationTurnItem(value: unknown): JsonObject | null {
let step: string;
if (kind === "receipt_unavailable" || row.evidence_unavailable === true) step = "recover_evidence";
else if (cancelled) step = "reconcile_reservation";
+ else if (direction === "outgoing" && unconsumed.length) step = "consume_peer_result";
else if (kind !== "settled") step = direction === "outgoing" ? "await_peer_result"
: receipt.recorded_decision == null ? "assess_request"
: receipt.recorded_decision === "adopt" ? "review_request_work" : "return_answer";
- else if (direction === "outgoing" && unconsumed.length) step = "consume_peer_result";
else if (incomplete.length) step = "await_return_delivery";
else if (request.source_kind === "peer" && unconsumed.length) step = "await_peer_consumption";
else return null;
@@ -49,6 +49,7 @@ export function projectDelegationTurnItem(value: unknown): JsonObject | null {
agent_id: request.agent_id,
purpose: [...purpose].slice(0, 120).join(""), purpose_truncated: [...purpose].length > 120,
receipt_state: kind, decision: receipt.recorded_decision ?? null, step,
+ ...(receipt.remaining_work ? {remaining_work: receipt.remaining_work} : {}),
...(todos.length ? {todo_ids: todos.slice(0, 4), additional_todo_count: Math.max(0, todos.length - 4)} : {}),
...(results.length ? {pending_return_count: incomplete.length, unconsumed_return_count: unconsumed.length,
returns: results.slice(-3).map(result => ({result_key: result.result_key,
diff --git a/tests/control_plane_ts/collaboration_inbox_receipts.test.ts b/tests/control_plane_ts/collaboration_inbox_receipts.test.ts
index 3ab82ccec4..3f1df19e19 100644
--- a/tests/control_plane_ts/collaboration_inbox_receipts.test.ts
+++ b/tests/control_plane_ts/collaboration_inbox_receipts.test.ts
@@ -1,5 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
+import {createHash} from "node:crypto";
+import type {JsonObject} from "../../loopx/control_plane/effect_program.ts";
+import {planRequestControl, requestControlView} from "../../loopx/control_plane/collaboration/request_control.ts";
+import {planCollaborationResult} from "../../loopx/control_plane/collaboration/result_publication.ts";
import { inspectCollaborationInboxReceipts, projectReceiverFollowthrough } from "../../loopx/control_plane/collaboration/inbox_receipts.ts";
const identity = { request_id: "a".repeat(64), goal_id: "delivery", agent_id: "receiver", source_id: "peer:request" };
@@ -77,9 +81,11 @@ test("receiver advice keeps assessment, linked work and return distinct", () =>
}
const done = project({ recorded_decision: "adopt", kind: "awaiting_conclusion",
linked_todos: [{ todo_id: "todo_delivery", status: "done" }] })[0];
- assert.equal(done.step, "return_answer");
+ assert.equal(done.step, "review_request_work");
assert.equal(done.answer_owed, true);
assert.equal(done.request_completion, "not_established_by_receipts_or_todo_status");
+ assert.equal(project({kind: "settled", recorded_decision: "adopt",
+ linked_todos: [{todo_id: "todo_delivery", status: "open"}]})[0].step, "inspect_recorded_result");
for (const decision of ["defer", "reject", "no_change"]) {
assert.equal(project({ recorded_decision: decision, kind: "awaiting_conclusion" })[0].step, "return_answer");
}
@@ -92,3 +98,63 @@ test("receiver advice keeps assessment, linked work and return distinct", () =>
assert.throws(() => project({ recorded_decision: "in_progress" }), /unsupported/);
assert.throws(() => projectReceiverFollowthrough({ observations: Array(21).fill({}) }), /at most 20/);
});
+
+test("partial scope follows the exact current request revision and append-only result", () => {
+ const at = "2026-01-01T00:00:00Z";
+ const goal_ref = {goal_id: "delivery", goal_instance_id: "ginst_" + "b".repeat(32)};
+ const brief = {schema_version: "collaboration_brief_v0", purpose: "Repair the export",
+ context: "Keep the installed validation", constraints: [], inputs: [],
+ acceptance: ["The installed repair works"], return_requirement: "Return the checked repair"};
+ let entry: JsonObject = {...identity, goal_ref, schema_version: "loopx_manager_context_entry_v2",
+ source_kind: "peer", source_agent_id: "requester", brief, message: brief.purpose, delivered_at: at};
+ const legacy = {...decision, goal_ref, reason: "Accept original scope", decided_at: at};
+ const change = (operation: string, patch: JsonObject, actor = "source") => {
+ const current = requestControlView(entry, legacy);
+ const result = planRequestControl({entry, input: {operation, ...(operation === "assess" ? {} : {
+ expected_revision: current.revision, expected_digest: current.revision_digest}), ...patch}, source: {actor,
+ actor_agent_id: actor === "source" ? "requester" : "receiver", observed_at: at,
+ goal_active: true, legacy_assessment: legacy}});
+ entry = result.entry as JsonObject;
+ };
+ change("revise", {operation_id: "correct-2", brief: {...brief, context: "Use the corrected export"}, reason: "Corrected input"});
+ const view = requestControlView(entry, legacy);
+ change("assess", {operation_id: "adopt-2", revision: view.revision, revision_digest: view.revision_digest,
+ decision: "adopt", reason: "Own this exact correction"}, "receiver");
+ const remaining_work = {summary: "Validate the installed export", resume_when: "The package is available"};
+ const results: JsonObject[] = [];
+ const publish = (update_id?: string, remainder: unknown = remaining_work) => {
+ const plan = planCollaborationResult({request: entry, request_revision: 2, legacy_assessment: legacy,
+ phase: "conclusion", text: update_id ? "The installed export passed" : "The code is ready",
+ remaining_work: remainder, update_id, results});
+ const value = plan.value as JsonObject;
+ results.push({key: plan.result_key, value, text_chars: [...String(value.text)].length,
+ text_sha256: createHash("sha256").update(String(value.text)).digest("hex")});
+ };
+ const inspect = () => (inspectCollaborationInboxReceipts({observations: [{request: entry,
+ route_present: true, decision: read(legacy), read: absent, results}]}).items as JsonObject[])[0];
+ publish();
+ assert.equal(inspect().kind, "awaiting_conclusion");
+ assert.deepEqual(inspect().remaining_work, remaining_work);
+ publish("checked", null);
+ assert.equal(inspect().kind, "settled");
+ assert.equal(inspect().remaining_work, undefined);
+ assert.deepEqual((results[0].value as JsonObject).remaining_work, remaining_work);
+ change("revise", {operation_id: "correct-3", brief: {...brief, purpose: "Check the new export"}, reason: "New requirement"});
+ assert.equal(inspect().kind, "pending");
+ assert.equal(inspect().remaining_work, undefined);
+ change("cancel", {operation_id: "cancel-3", reason: "Stop only this request"});
+ assert.equal(inspect().kind, "settled");
+});
+
+test("an orphaned or unreadable update cannot hide an outstanding request", () => {
+ const original = {...conclusion, remaining_work: {summary: "Validate the repair", resume_when: "Package ready"}};
+ const update = {...identity, decision: "adopt", phase: "conclusion", text: "Later result",
+ result_key: "conclusion-00000001", previous_result_key: "conclusion", update_id: "later"};
+ const observed = {key: update.result_key, value: update, text_chars: update.text.length,
+ text_sha256: createHash("sha256").update(update.text).digest("hex")};
+ for (const results of [[observed], [{unavailable: true}]]) {
+ const row = (state({decision: read(decision), conclusion: absent, results}) as JsonObject[])[0];
+ assert.equal(row.kind, "receipt_unavailable");
+ }
+ assert.equal((state({decision: read(decision), conclusion: read(original)}) as JsonObject[])[0].kind, "awaiting_conclusion");
+});
diff --git a/tests/control_plane_ts/collaboration_result_publication.test.ts b/tests/control_plane_ts/collaboration_result_publication.test.ts
index 2bef755a84..0190231487 100644
--- a/tests/control_plane_ts/collaboration_result_publication.test.ts
+++ b/tests/control_plane_ts/collaboration_result_publication.test.ts
@@ -106,3 +106,25 @@ test("explicit result files participate in immutable retry identity", () => {
assert.throws(() => planCollaborationResult({...source, attachments: [{...attachment, ...patch}]}), /attachment/);
}
});
+
+test("a reported remainder is bounded immutable result scope, not a completion switch", () => {
+ const remaining_work = {summary: "Qualify the installed repair", resume_when: "The isolated package is available"};
+ const source = {...input, update_id: null, results: [], remaining_work};
+ const plan = planCollaborationResult(source);
+ const replay = {...source, results: [observation("conclusion", plan.value as JsonObject)]};
+ assert.equal(planCollaborationResult(replay).replay, true);
+ assert.deepEqual((plan.value as JsonObject).remaining_work, remaining_work);
+ for (const change of [null, {...remaining_work, summary: "Everything done"}, {...remaining_work, resume_when: "Now"}]) {
+ assert.throws(() => planCollaborationResult({...replay, remaining_work: change}), /conflicting/);
+ }
+ for (const invalid of [{summary: "Only a summary"}, {...remaining_work, owner: "another-agent"},
+ {...remaining_work, summary: " "}, {...remaining_work, resume_when: "😀".repeat(601)},
+ {...remaining_work, summary: "\ud800"}]) {
+ assert.throws(() => planCollaborationResult({...source, remaining_work: invalid}), /remaining work/);
+ }
+ assert.throws(() => planCollaborationResult({...source, phase: "decision"}), /conclusion/);
+ assert.throws(() => planCollaborationResult({...source, text: "x".repeat(20000)}), /result budget/);
+ const final = planCollaborationResult({...input, results: replay.results});
+ assert.equal(Object.hasOwn(final.value as JsonObject, "remaining_work"), false);
+ assert.deepEqual((replay.results[0].value as JsonObject).remaining_work, remaining_work);
+});
diff --git a/tests/control_plane_ts/inbox_read_transaction.test.ts b/tests/control_plane_ts/inbox_read_transaction.test.ts
index 6aeeee0ce3..c9ea35acf2 100644
--- a/tests/control_plane_ts/inbox_read_transaction.test.ts
+++ b/tests/control_plane_ts/inbox_read_transaction.test.ts
@@ -220,3 +220,48 @@ test("a large private agenda lets another runtime request finish before the scan
assert.equal(finished, false);
assert.equal(((await scanning).unfinished_delegations as JsonObject).incoming_count, 256);
});
+
+for (const profile of ["legacy", "exact"] as const) {
+ test(`${profile}: a complete result chain preserves its remainder, delivery and corruption boundaries`, async t => {
+ const f = fixture(t, profile), row = f.publish(1);
+ const identity = Object.fromEntries(["request_id", "goal_id", "agent_id", "source_id", "goal_ref"]
+ .filter(key => Object.hasOwn(row, key)).map(key => [key, row[key as keyof typeof row]]));
+ const replies = join(f.base, "replies", row.request_id);
+ for (const folder of [replies, join(f.base, "decisions"), join(f.base, "roundtrips")]) mkdirSync(folder, {recursive: true});
+ writeFileSync(join(f.base, "decisions", row.request_id + ".json"), JSON.stringify({...identity, decision: "adopt"}));
+ writeFileSync(join(f.base, "roundtrips", row.request_id + ".json"), JSON.stringify(identity));
+ const remaining_work = {summary: "Validate the packaged repair", resume_when: "The isolated package is available"};
+ const key = (n: number) => n ? "conclusion-" + String(n).padStart(8, "0") : "conclusion";
+ for (let n = 0; n < 4; n++) {
+ writeFileSync(join(replies, key(n) + ".json"), JSON.stringify({...identity, phase: "conclusion", decision: "adopt",
+ text: "Recorded evidence " + n + "😀".repeat(2000), remaining_work,
+ ...(n ? {result_key: key(n), previous_result_key: key(n - 1), update_id: "evidence-" + n} : {})}));
+ writeFileSync(join(replies, key(n) + ".delivery.json"), JSON.stringify({status: "delivered"}));
+ }
+ const original = sourceBytes(replies);
+ const pending = (await f.call("pending")).result as JsonObject;
+ assert.deepEqual((pending.items as JsonObject[])[0].remaining_work, remaining_work);
+ const agenda = (await f.call("turn_context")).unfinished_delegations as JsonObject;
+ assert.equal(agenda.incoming_count, 1);
+ assert.equal((agenda.items as JsonObject[])[0].step, "review_request_work");
+ assert.deepEqual(sourceBytes(replies), original);
+
+ const damaged = join(replies, key(2) + ".json"), bytes = readFileSync(damaged, "utf8");
+ const corrupt = JSON.parse(bytes);
+ corrupt.previous_result_key = "conclusion";
+ writeFileSync(damaged, JSON.stringify(corrupt));
+ const failed = (await f.call("pending")).result as JsonObject;
+ assert.equal((failed.items as JsonObject[])[0].inbox_state, "receipt_unavailable");
+ const unavailable = (await f.call("turn_context")).unfinished_delegations as JsonObject;
+ assert.equal((unavailable.items as JsonObject[])[0].step, "recover_evidence");
+ writeFileSync(damaged, bytes);
+ assert.deepEqual(sourceBytes(replies), original);
+
+ writeFileSync(join(replies, key(4) + ".json"), JSON.stringify({...identity, phase: "conclusion", decision: "adopt",
+ text: "The requested package passed validation.", result_key: key(4), previous_result_key: key(3), update_id: "validated"}));
+ const queued = (await f.call("turn_context")).unfinished_delegations as JsonObject;
+ assert.equal((queued.items as JsonObject[])[0].step, "await_return_delivery");
+ writeFileSync(join(replies, key(4) + ".delivery.json"), JSON.stringify({status: "delivered"}));
+ assert.deepEqual(((await f.call("turn_context")).unfinished_delegations as JsonObject).items, []);
+ });
+}
diff --git a/tests/extensions/test_lark_private_manager_returns.py b/tests/extensions/test_lark_private_manager_returns.py
index 1b7ff7ca09..87f6130dd9 100644
--- a/tests/extensions/test_lark_private_manager_returns.py
+++ b/tests/extensions/test_lark_private_manager_returns.py
@@ -1,5 +1,6 @@
"""Worker returns use the original Core-bound private App, including recovery."""
import json
+from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
@@ -11,6 +12,7 @@
from loopx.chat_store import ChatSessionStore
from loopx.capabilities.manager_context import acknowledge
from loopx.capabilities.manager_context.roundtrip import drain, report, reply_status
+from loopx.control_plane.collaboration.turn_context_observation import read_delegation_turn_context
@pytest.fixture(params=["shared", "separate"])
@@ -82,7 +84,7 @@ def runner(args, cwd=None, timeout=None):
_write(_root(return_root) / "policy.json", {"schema_version": POLICY_SCHEMA, "sources": {}})
else:
replies.append(list(args))
- ref = "om_out_worker"
+ ref = f"om_out_worker_{len(replies)}"
provider.messages[ref] = {"message_id": ref, "msg_type": "post", "body": {"content": content}}
data = {"data": {"message_id": ref}}
return {"returncode": 0, "stdout": json.dumps(data)}
@@ -192,7 +194,6 @@ def test_production_pump_returns_to_original_private_source_after_restart(privat
assert reply_status(sender.root, route)[0]["status"] == "verification_required"
provider.verify_replies = True
# Reload the actual Chat store; the persisted attempt drives read-only recovery.
- from datetime import datetime, timedelta, timezone
recovered = ChatSessionStore(store.root.parent)
drain(sender.root, controller.registry_path, recovered, sender, now=datetime.now(timezone.utc) + timedelta(minutes=10))
assert reply_status(sender.root, route)[0]["status"] == "delivered"
@@ -208,3 +209,84 @@ def test_production_pump_returns_to_original_private_source_after_restart(privat
assert "Receiver independently" not in returned[0]["text"]
assert sorted((store.root / "sessions" / session["session_id"] / "turns").glob("*.json")) == before_turns
assert capture.read_bytes() == before_host_calls
+
+
+@pytest.mark.parametrize("revoke_before_recovery", [False, True])
+def test_partial_return_survives_saved_attempt_recovery_until_final_delivery(private_return, revoke_before_recovery):
+ sender, session, turn, route, _, provider, transport, replies = private_return
+ controller = transport.core.controller
+ store = controller.store
+ capture = store.root.parent.parent / "requests.jsonl"
+ before_host_calls = capture.read_bytes()
+ original_turn = store._turn_path(session["session_id"], turn["turn_id"]).read_bytes()
+ turns = sorted((store.root / "sessions" / session["session_id"] / "turns").glob("*.json"))
+ remainder = {"summary": "Validate the installed repair", "resume_when": "The isolated package is available"}
+ rid = route["request_id"]
+ acknowledge(sender.root, route["goal_id"], route["agent_id"], rid, "adopt", "Repair and validate")
+ report(sender.root, route["goal_id"], route["agent_id"], rid, "conclusion",
+ "The source repair is ready.", remaining_work=remainder)
+ partial = _root(sender.root) / "replies" / rid / "conclusion.json"
+ original_result = partial.read_bytes()
+ provider.verify_replies = False
+ drain(sender.root, controller.registry_path, store, sender)
+ assert len(replies) == 1
+ assert reply_status(sender.root, route)[0]["status"] == "verification_required"
+ partial_content = replies[0][replies[0].index("--content") + 1]
+ assert all(value in partial_content for value in remainder.values())
+
+ # Reload the saved attempt. Revocation still wins over that historical proof.
+ recovered = ChatSessionStore(store.root.parent)
+ resumed_sender = LarkManagerReturnTransport(sender.server, sender.root)
+ provider.verify_replies = True
+ clock = datetime.now(timezone.utc) + timedelta(minutes=10)
+ if revoke_before_recovery:
+ policy_path = _root(sender.root) / "policy.json"
+ policy = policy_path.read_bytes()
+ _write(policy_path, {"schema_version": POLICY_SCHEMA, "sources": {}})
+ drain(sender.root, controller.registry_path, recovered, resumed_sender, now=clock)
+ status = reply_status(sender.root, route)[0]
+ assert (status["status"], status["error"]) == ("explicit_unverified", "return_authorization_unavailable")
+ assert len(replies) == 1
+ policy_path.write_bytes(policy)
+ # Restoring access does not automatically resume a stopped publication.
+ clock += timedelta(minutes=10)
+ drain(sender.root, controller.registry_path, recovered, resumed_sender, now=clock)
+ partial_status = "explicit_unverified" if revoke_before_recovery else "delivered"
+ assert reply_status(sender.root, route)[0]["status"] == partial_status
+ assert len(replies) == 1
+ agenda = read_delegation_turn_context(sender.root, controller.registry_path,
+ route["goal_id"], route["agent_id"])["unfinished_delegations"]
+ assert [(item["request_id"], item["step"], item.get("remaining_work")) for item in agenda["items"]] == [
+ (rid, "review_request_work", remainder)]
+
+ report(sender.root, route["goal_id"], route["agent_id"], rid, "conclusion",
+ "The isolated repair passed the agreed validation.", update_id="qualified-result")
+ drain(sender.root, controller.registry_path, recovered, resumed_sender, now=clock + timedelta(minutes=20))
+ drain(sender.root, controller.registry_path, ChatSessionStore(store.root.parent), resumed_sender,
+ now=clock + timedelta(minutes=30))
+ statuses = [item["status"] for item in reply_status(sender.root, route)]
+ agenda = read_delegation_turn_context(sender.root, controller.registry_path,
+ route["goal_id"], route["agent_id"])["unfinished_delegations"]["items"]
+ returned = [item for item in recovered.messages(session["session_id"])
+ if item.get("origin") == "manager_followup"]
+ if revoke_before_recovery:
+ # A newer result cannot overtake an uncertain predecessor, even after
+ # access returns. Preserve the exact failure instead of resending it.
+ assert len(replies) == 1 and statuses == ["explicit_unverified", "queued"]
+ assert [(item["request_id"], item["step"], item["pending_return_count"]) for item in agenda] == [
+ (rid, "await_return_delivery", 2)]
+ assert len(returned) == 1
+ else:
+ assert len(replies) == 2 and statuses == ["delivered", "delivered"]
+ assert agenda == []
+ assert len(returned) == 2
+ final_content = replies[1][replies[1].index("--content") + 1]
+ assert "The isolated repair passed" in final_content
+ assert "The isolated repair passed" in returned[1]["text"]
+ assert remainder["summary"] not in returned[1]["text"]
+ assert all(item["turn_id"] == turn["turn_id"] for item in returned)
+ assert all(value in returned[0]["text"] for value in remainder.values())
+ assert partial.read_bytes() == original_result
+ assert store._turn_path(session["session_id"], turn["turn_id"]).read_bytes() == original_turn
+ assert sorted((store.root / "sessions" / session["session_id"] / "turns").glob("*.json")) == turns
+ assert capture.read_bytes() == before_host_calls
diff --git a/tests/test_collaboration_mcp.py b/tests/test_collaboration_mcp.py
index 724af737bb..088e9cd1ef 100644
--- a/tests/test_collaboration_mcp.py
+++ b/tests/test_collaboration_mcp.py
@@ -216,10 +216,15 @@ async def call(name, args):
cli("todo", "archive-completed", "--goal-id", "goal-a",
"--max-active-done", "0", "--execute")
resumed = (await call("read_context", {}))["items"][0]["receiver_followthrough"]
- assert resumed["step"] == "return_answer" and resumed["answer_owed"]
+ assert resumed["step"] == "review_request_work" and resumed["answer_owed"]
assert [(t["todo_id"], t["status"]) for t in resumed["linked_todos"]] == [(own, "done")]
assert path.read_bytes() == original
- await call("return_result", {"request_id": rid, "text": conclusion})
+ remainder = {"summary": "Check the installed repair", "resume_when": "The isolated package is ready"}
+ await call("return_result", {"request_id": rid, "text": "Implementation ready", "remaining_work": remainder})
+ pending = (await call("read_context", {}))["items"][0]
+ assert pending["remaining_work"] == remainder
+ assert pending["receiver_followthrough"]["step"] == "review_request_work"
+ await call("return_result", {"request_id": rid, "text": conclusion, "update_id": "checked-repair"})
assert (await call("read_context", {}))["items"] == []
# A short factual answer requires neither a fabricated task nor a link.
quick = request(root, registry, "goal-a", "agent-b", "agent-a", "short-answer", brief)["request_id"]
@@ -239,7 +244,7 @@ async def call(name, args):
asyncio.run(exercise())
received = read_inbox(root, registry, "goal-a", "agent-b")["peer_returns"]["items"]
- assert len(received) == 2
- assert next(item for item in received if item["request_id"] == rid)["text"] == conclusion
+ assert len(received) == 3
+ assert next(item for item in received if item.get("result_key") == "conclusion-00000001")["text"] == conclusion
assert [row["todo_id"] for row in cli("todo", "list", "--goal-id", "goal-a")["todos"]] == [foreign]
assert cli("todo", "list", "--goal-id", "goal-a", "--todo-id", own)["todo"]["archive_state"] == "archive"
diff --git a/tests/test_delegation_remaining_work.py b/tests/test_delegation_remaining_work.py
new file mode 100644
index 0000000000..6a685739fc
--- /dev/null
+++ b/tests/test_delegation_remaining_work.py
@@ -0,0 +1,136 @@
+"""A returned partial result retains its request without creating work authority."""
+
+import json
+import subprocess
+import sys
+
+import pytest
+
+from loopx.capabilities.manager_context import _root, acknowledge
+from loopx.capabilities.manager_context.roundtrip import drain, report
+from loopx.control_plane.collaboration.peers import consume_return, read_inbox, request
+from loopx.control_plane.collaboration.turn_context_observation import read_delegation_turn_context
+import test_manager_context_roundtrip as roundtrip_tests
+
+
+REMAINDER = {"summary": "Validate the installed repair", "resume_when": "The isolated package is available"}
+
+
+@pytest.fixture
+def flow(tmp_path):
+ return roundtrip_tests.flow.__wrapped__(tmp_path)
+
+
+def agenda(root, registry, agent="worker"):
+ return read_delegation_turn_context(root, registry, "research", agent)["unfinished_delegations"]
+
+
+@pytest.mark.parametrize("external", [False, True], ids=["app", "provider"])
+def test_partial_return_keeps_its_scope_after_delivery_and_restart(flow, external):
+ root, registry, store, create = flow
+ session, _, receipt = create(external=external)
+ rid = receipt["request_id"]
+ acknowledge(root, "research", "worker", rid, "adopt", "Repair and validate the original request")
+ report(root, "research", "worker", rid, "conclusion", "Implementation is ready.", remaining_work=REMAINDER)
+ original = (_root(root) / "replies" / rid / "conclusion.json").read_bytes()
+ sent = []
+
+ def sender(_route, _session, _turn, text):
+ sent.append(text)
+ return {"reply_verified": True, "external_write_performed": True}
+
+ drain(root, registry, store, sender)
+ # A fresh native read, independently of a model or a Todo, retains the work.
+ for _ in range(2):
+ item = agenda(root, registry)["items"][0]
+ assert item["request_id"] == rid and item["step"] == "review_request_work"
+ assert item["remaining_work"] == REMAINDER
+ assert item["pending_return_count"] == 0
+ returned = [m for m in store.messages(session["session_id"]) if m.get("origin") == "manager_followup"]
+ assert len(returned) == 1 and REMAINDER["resume_when"] in returned[0]["text"]
+ if external:
+ assert len(sent) == 1 and REMAINDER["summary"] in sent[0]
+ report(root, "research", "worker", rid, "conclusion", "The isolated repair passed the agreed validation.", update_id="qualified-result")
+ assert agenda(root, registry)["items"][0]["step"] == "await_return_delivery"
+ drain(root, registry, store, sender)
+ assert agenda(root, registry)["items"] == []
+ assert (_root(root) / "replies" / rid / "conclusion.json").read_bytes() == original
+ assert len([m for m in store.messages(session["session_id"]) if m.get("origin") == "manager_followup"]) == 2
+
+
+def test_partial_peer_result_is_consumed_once_while_work_stays_owed(tmp_path):
+ root, registry, _, _ = roundtrip_tests.flow.__wrapped__(tmp_path)
+ data = json.loads(registry.read_text())
+ data["goals"][0]["coordination"]["registered_agents"].append("reviewer")
+ registry.write_text(json.dumps(data))
+ brief = {"schema_version": "collaboration_brief_v0", "purpose": "Repair and independently validate",
+ "context": "Preserve the acceptance requirement", "constraints": [], "inputs": [],
+ "acceptance": ["The installed repair works"], "return_requirement": "Return the checked repair"}
+ rid = request(root, registry, "research", "worker", "reviewer", "repair", brief)["request_id"]
+ acknowledge(root, "research", "reviewer", rid, "adopt", "Own the repair and validation")
+ report(root, "research", "reviewer", rid, "conclusion", "The implementation is ready.", remaining_work=REMAINDER)
+ assert agenda(root, registry)["items"][0]["step"] == "consume_peer_result"
+ result = read_inbox(root, registry, "research", "worker")["peer_returns"]["items"][0]
+ assert result["remaining_work"] == REMAINDER
+ consume_return(root, "research", "worker", rid, registry=registry)
+ assert agenda(root, registry)["items"][0]["step"] == "await_peer_result"
+ assert agenda(root, registry, "reviewer")["items"][0]["step"] == "review_request_work"
+
+
+@pytest.mark.parametrize("provider", ["file", "sqlite"])
+def test_cli_partial_request_and_shared_todo_have_independent_lifetimes(tmp_path, monkeypatch, provider):
+ from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime, promoted_create_fixture
+ from loopx.capabilities.manager_context import deliver
+
+ isolate_sqlite_runtime(tmp_path, monkeypatch)
+ registry, root, _ = promoted_create_fixture(tmp_path, provider=provider)
+
+ def cli(*args, ok=True):
+ process = subprocess.run([sys.executable, "-m", "loopx.entrypoint", "--format", "json",
+ "--registry", str(registry), "--runtime-root", str(root), *args], capture_output=True, text=True, timeout=45)
+ assert process.returncode == (0 if ok else 1), (process.stdout, process.stderr)
+ result = json.loads(process.stdout)
+ assert result["ok"] is ok
+ return result
+
+ work = cli("todo", "add", "--goal-id", "goal-a", "--role", "agent", "--text", "Ongoing umbrella work", "--claimed-by", "agent-a")
+ tid = work["todo_id"]
+ request_ids = []
+ for index in range(2):
+ rid = deliver(root, registry, session={"session_id": "original", "channel_id": "manager"},
+ turn={"client_turn_id": f"request-{index}", "origin": "web", "message": "Return this request's actual result"},
+ request={"goal_id": "goal-a", "agent_id": "agent-a"})["request_id"]
+ request_ids.append(rid)
+ scope = ("--goal-id", "goal-a", "--agent-id", "agent-a", "--request-id", rid)
+ cli("manager-inbox", "acknowledge", *scope, "--decision", "adopt", "--reason", "Keep each request independent")
+ cli("manager-inbox", "link", *scope, "--related-todo-id", tid)
+ cli("manager-inbox", "report", *scope, "--reply-text", "A partial result", "--remaining-work", REMAINDER["summary"], "--resume-when", REMAINDER["resume_when"])
+ cli("todo", "complete", "--goal-id", "goal-a", "--todo-id", tid, "--agent-id", "agent-a", "--evidence", "The umbrella's separate scope passed", "--no-follow-up")
+ pending = cli("manager-inbox", "read", "--goal-id", "goal-a", "--agent-id", "agent-a")["items"]
+ assert {item["request_id"] for item in pending} == set(request_ids)
+ assert all(item["remaining_work"] == REMAINDER and item["receiver_followthrough"]["step"] == "review_request_work" for item in pending)
+ scope = ("--goal-id", "goal-a", "--agent-id", "agent-a", "--request-id", request_ids[0])
+ cli("manager-inbox", "report", *scope, "--reply-text", "First request's checked result", "--update-id", "verified-first")
+ pending = cli("manager-inbox", "read", "--goal-id", "goal-a", "--agent-id", "agent-a")["items"]
+ assert [item["request_id"] for item in pending] == [request_ids[1]]
+ assert cli("todo", "list", "--goal-id", "goal-a", "--todo-id", tid)["todo"]["status"] == "done"
+ failure = cli("manager-inbox", "report", *scope, "--reply-text", "Invalid", "--remaining-work", "Unpaired", ok=False)
+ assert "require" in failure["error"]
+
+
+def test_ordinary_short_answer_needs_no_work_record_and_external_remainder_is_scanned(flow):
+ root, registry, store, create = flow
+ _, _, receipt = create()
+ rid = receipt["request_id"]
+ acknowledge(root, "research", "worker", rid, "no_change", "A direct answer is sufficient")
+ report(root, "research", "worker", rid, "conclusion", "The current public PR is merged.")
+ drain(root, registry, store, lambda *_: pytest.fail("local return"))
+ assert agenda(root, registry)["items"] == []
+ assert not (_root(root) / "links" / f"{rid}.json").exists()
+ _, _, external = create(external=True)
+ rid = external["request_id"]
+ acknowledge(root, "research", "worker", rid, "adopt", "An audience-safe return")
+ with pytest.raises(ValueError, match="private boundary"):
+ report(root, "research", "worker", rid, "conclusion", "Safe visible answer", remaining_work={
+ "summary": "/Users/private/customer/raw.txt", "resume_when": "Ready"})
+ assert not (_root(root) / "replies" / rid / "conclusion.json").exists()
diff --git a/tests/test_delegation_turn_context.py b/tests/test_delegation_turn_context.py
index cd88e6be24..b44e98cba6 100644
--- a/tests/test_delegation_turn_context.py
+++ b/tests/test_delegation_turn_context.py
@@ -96,7 +96,8 @@ def test_separate_agent_has_no_access_to_another_receivers_agenda(flow):
@pytest.mark.parametrize("provider", ["file", "sqlite"])
@pytest.mark.parametrize("delivery", ["host_envelope", pytest.param("native_files",
marks=pytest.mark.skipif(os.name != "posix", reason="native context delivery requires POSIX"))])
-def test_actual_cli_guards_and_host_envelopes_preserve_the_agenda(tmp_path, monkeypatch, provider, delivery):
+@pytest.mark.parametrize("partial", [False, True], ids=["unanswered", "partial"])
+def test_actual_cli_guards_and_host_envelopes_preserve_the_agenda(tmp_path, monkeypatch, provider, delivery, partial):
from control_plane.canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime
from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection
from loopx.control_plane.testing.canary_harness import run_json_cli_result, write_fixture_registry
@@ -125,6 +126,10 @@ def test_actual_cli_guards_and_host_envelopes_preserve_the_agenda(tmp_path, monk
acknowledge(root, "research", "worker", receipt["request_id"], "adopt", "Continue the linked work", registry=registry)
from loopx.control_plane.collaboration.links import link
link(root, registry, "research", "worker", receipt["request_id"], ["todo_artifact"], [])
+ remaining_work = {"summary": "Validate the packaged repair", "resume_when": "The isolated package is available"}
+ if partial:
+ report(root, "research", "worker", receipt["request_id"], "conclusion", "The implementation is ready.",
+ remaining_work=remaining_work, registry=registry)
before = {p.relative_to(root): p.read_bytes() for p in _root(root).rglob("*.json")}
if delivery == "native_files":
from loopx.control_plane.turn_driver.codex_context import codex_context_directory, codex_context_hook
@@ -168,6 +173,7 @@ def test_actual_cli_guards_and_host_envelopes_preserve_the_agenda(tmp_path, monk
row = content["unfinished_delegations"]["items"][0]
assert row["request_id"] == receipt["request_id"]
assert row["todo_ids"] == ["todo_artifact"] and row["step"] == "review_request_work"
+ assert row.get("remaining_work") == (remaining_work if partial else None)
assert any("manager-inbox read" in item["command"] for item in required_reads)
assert "Continue the linked work" not in json.dumps(content)
assert {p.relative_to(root): p.read_bytes() for p in _root(root).rglob("*.json")} == before
diff --git a/tests/test_manager_context_tracking.py b/tests/test_manager_context_tracking.py
index ee95dcfb10..c1a426d04d 100644
--- a/tests/test_manager_context_tracking.py
+++ b/tests/test_manager_context_tracking.py
@@ -346,7 +346,7 @@ def add(owner, goal="goal-a"):
assert archived["todo"]["archive_state"] == "archive"
assert archived["todo"]["status"] == "done"
follow = cli("manager-inbox", "read", "--goal-id", "goal-a", "--agent-id", "agent-a")["items"][0]["receiver_followthrough"]
- assert follow["step"] == "return_answer"
+ assert follow["step"] == "review_request_work"
assert [(t["todo_id"], t["title"], t["status"]) for t in follow["linked_todos"]] == [
(tid, "[P1] Verified current work", "done")
]