Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions loopx/capabilities/manager_context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,11 @@ superseded or out-of-scope sources cancel unsent notices. A local typed planner
owns subject identity, meaningful-content changes and reopening generations;
this is a local notification journal, not another Todo authority.

Notices preserve the complete authored task text, explanation, reason and
evidence. Structured decision scopes stay in canonical change detection and
authority checks instead of appearing as protocol JSON in the human message.
Presentation updates do not rewrite frozen intents or resend unchanged tasks.

The existing four-worker outbound pool reserves at most one slot for bounded
notification reconciliation only when a notification setting is enabled;
the default-off path retains all four reply slots and performs no Todo scan.
Expand Down
6 changes: 3 additions & 3 deletions loopx/control_plane/presentation/owner_attention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,10 @@ export function planOwnerAttention(input: JsonObject): JsonObject {
if (state.initialized === true && changed) {
const id = digest([row.key, generation, row.digest]);
const terms = row.terms as JsonObject;
// Structured scopes remain identity/authority inputs, not operator copy.
const body = [`待你处理 · ${row.goal_id}`, String(terms.text),
...["note", "reason", "evidence", "decision_scope", "required_decision_scopes"].flatMap(key =>
terms[key] == null ? [] : [`${({note: "说明", reason: "原因", evidence: "依据",
decision_scope: "决定范围", required_decision_scopes: "决定范围"} as Record<string, string>)[key]}:${
...["note", "reason", "evidence"].flatMap(key =>
terms[key] == null ? [] : [`${({note: "说明", reason: "原因", evidence: "依据"} as Record<string, string>)[key]}:${
typeof terms[key] === "string" ? terms[key] : JSON.stringify(terms[key])}`]),
"可在原管家对话中查看或答复这项待办。"].join("\n\n");
outbox.push({id, key: row.key, generation, digest: row.digest, goal_id: row.goal_id,
Expand Down
38 changes: 38 additions & 0 deletions tests/control_plane_ts/owner_attention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {observeOwnerAttention, ownerAttentionHealth, ownerAttentionReferences, planOwnerAttention} from "../../loopx/control_plane/presentation/owner_attention.ts";
import type {JsonObject} from "../../loopx/control_plane/effect_program.ts";
import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts";

const empty = () => ({schema_version: "lark_owner_attention_v0", revision: 0, enabled: true, initialized: false, subjects: [], outbox: []});
const task = {todo_id: "todo_one", role: "user", status: "open", task_class: "user_action", text: "Inspect the preview"};
Expand Down Expand Up @@ -75,3 +76,40 @@ test("reference discovery and planning share active-subject semantics without a
const todos = [...tasks, {...task, todo_id: "todo_done", done: true}, {...task, role: "agent"}];
assert.deepEqual(ownerAttentionReferences({goal_id: "alpha", todos}).todo_ids, tasks.map(row => row.todo_id));
});

const decision = {schema_version: "decision_scope_v0", kind: "direction", granularity: "action", scope_key: "preview"};
const reviewTask = {...task, task_class: "user_gate", text: "Approve only the documented preview.",
note: "The proposal includes a keyboard-accessible preview.", reason: "Publication awaits your decision.",
evidence: "https://example.com/proposal", decision_scope: decision, required_decision_scopes: [decision]};

test("owner notices retain authored terms while structured scopes remain identity inputs", () => {
const state = plan(plan(empty(), []), [reviewTask]);
const notice = rows(state)[0];
assert.equal(notice.body, ["待你处理 · alpha", reviewTask.text,
`说明:${reviewTask.note}`, `原因:${reviewTask.reason}`, `依据:${reviewTask.evidence}`,
"可在原管家对话中查看或答复这项待办。"].join("\n\n"));
const {text, note, reason, evidence, decision_scope, required_decision_scopes} = reviewTask;
assert.equal(notice.digest, canonicalAuthoritySha256({text, note, reason, evidence, decision_scope, required_decision_scopes}));
const changed = plan(state, [{...reviewTask, decision_scope: {...decision, scope_key: "another-preview"}}]);
assert.equal(rows(changed).length, 2, "scope-only changes remain meaningful for discovery");
assert.notEqual(rows(changed)[1].id, notice.id);
assert.notEqual(rows(changed)[1].digest, notice.digest);
});

test("readable rendering preserves legacy pending, unknown and delivered notices across restart", () => {
const state = plan(plan(empty(), []), [reviewTask]);
const notice = rows(state)[0];
// Persisted pre-upgrade bodies and intents are delivery facts, not templates
// to regenerate when presentation changes.
const legacyBody = `${reviewTask.text}\n\n决定范围:${JSON.stringify(decision)}`;
notice.body = legacyBody;
const observe = (s: JsonObject, event: string, extra = {}) =>
observeOwnerAttention({state: s, id: notice.id, event, now: 100, ...extra});
const started = observe(state, "write_started", {intent: {text: legacyBody, route: {source: "original"}}});
const unknown = observe(started, "write_observed", {message_id: "om_original"});
const delivered = observe(unknown, "readback_verified", {readback: {message_id: "om_original"}});
for (const persisted of [state, unknown, delivered]) {
const restored = plan(JSON.parse(JSON.stringify(persisted)), [reviewTask]);
assert.deepEqual(restored, persisted, "unchanged source cannot re-notify or rewrite an old intent");
}
});
Loading