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
38 changes: 35 additions & 3 deletions docs/product/periodic-reports.md
Original file line number Diff line number Diff line change
Expand Up @@ -1011,15 +1011,47 @@ The existing Goal capability configuration transaction accepts:
Replace the example with the actual existing identities and requested time.
Prepare through `/api/chat/goal-configuration/preview`, then apply the same
body and its `expected_plan_revision` through
`/api/chat/goal-configuration/apply`. The existing Goal settings JSON editor
uses this transaction; read back the saved configuration and the managed
Session's `scheduled_task` inspection. A stale preview or changed identity is
`/api/chat/goal-configuration/apply`. Read back the saved configuration and the
managed Session's `scheduled_task` inspection. A stale preview or changed identity is
rejected. This is an explicit Goal override: a machine default cannot enroll
another conversation. `session-task` is a calendar execution mode, not a report
renderer preset; it does not invoke the progress-report source or editorial
format. Task descriptions, scripts and validation argv cannot be supplied in
this configuration.

The current generic Goal editor does **not** register the `task` field. Its
form drops that source from the draft, and its JSON editor rejects unregistered
fields. It therefore cannot yet configure or edit this Task mode end to end;
the HTTP contract above and the read-only source catalog are partial product
delivery. Do not mistake a preconfigured fixture for a completed Task picker or
natural-language journey. Missing, empty, non-object or command-bearing Task
input is rejected as HTTP 400 before Session source inspection, without changing
the subscription, creating a window or invoking work. It does not infer a source
from an older configuration.

当前通用 Goal 编辑器尚未登记 `task` 字段:表单会在草稿中丢失原来源,JSON 编辑器也
拒绝未登记字段,因此完整任务配置界面仍未交付。上述 HTTP 合同和只读来源目录只是
阶段能力,预配置的合成场景不代表已完成 Task 选择器或自然语言旅程。缺失、空值、
非对象或夹带命令的 Task 输入在读取 Session 来源前即以 HTTP 400 拒绝,不改订阅、
不创建窗口、不执行业务,也不从旧配置推断来源。

For reproducible packaged checks, run the existing disposable browser fixture:

```sh
uv run --extra test python examples/personal-workspace-browser/steward_report_fixture.py \
--managed-calendar-task sqlite --output-dir /tmp/loopx-calendar-task-qa
```

Use `file` instead of `sqlite` for the other native backend. `ready.json` names
the isolated HTTP URL, original Session, completed source and future disabled
configuration. The model computes an independently validated synthetic sum;
no real identity or private provider is attached. The `start-existing-owners`
control starts the real idle wake owner. `stop-existing-wake` stops only that
owner; removing it resumes the same stored state through a new wake-service
instance. `observed.json` records artifact, completion, retained history and
actual post-setup model calls. This qualifies neither live-model understanding,
complete Chat-process restart nor production delivery.

Callers can discover eligible references on demand with
`GET /api/chat/sessions/<original-session-id>/native-turn?calendar_task_sources=true`.
The `calendar_task_sources` view reads the original Goal's canonical completed
Expand Down
66 changes: 54 additions & 12 deletions examples/personal-workspace-browser/steward_report_fixture.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Disposable packaged UI + native HTTP/Core fixture; model/provider are synthetic.

Run with the project's development dependencies and --output-dir outside Git.
The two control files change synthetic provider readiness or start the existing
Control files change synthetic provider readiness or start the existing
host wake/outbound owners. No real account, Goal, workspace or listener is used.
"""
from __future__ import annotations
Expand All @@ -23,13 +23,15 @@ def main() -> None:
parser.add_argument("--baseline-assets-dir", type=Path)
parser.add_argument("--invalid-editorial", action="store_true", help="Return a known invalid synthetic report to inspect owner cancellation and retained failure.")
parser.add_argument("--host-failure", action="store_true", help="Fail the synthetic native model before an editorial result; retain the original window without retry authority.")
parser.add_argument("--managed-session", action="store_true", help="Exercise ordinary Goal Session native work with the existing task validator, instead of a scheduled report.")
parser.add_argument("--managed-report", action="store_true", help="Configure a calendar report for an existing local Agent Session through the packaged Goal settings.")
journey = parser.add_mutually_exclusive_group()
journey.add_argument("--managed-session", action="store_true", help="Exercise ordinary Goal Session native work with the existing task validator, instead of a scheduled report.")
journey.add_argument("--managed-report", action="store_true", help="Configure a calendar report for an existing local Agent Session through the packaged Goal settings.")
journey.add_argument("--managed-calendar-task", choices=("file", "sqlite"), help="Repeat a completed, independently validated Task through the real calendar and packaged Goal settings.")
args = parser.parse_args()
if args.managed_session and args.managed_report:
parser.error("select one managed Session fixture journey")
if args.host_failure and (args.invalid_editorial or args.managed_session):
if args.host_failure and (args.invalid_editorial or args.managed_session or args.managed_calendar_task):
parser.error("select the pre-editorial report failure journey alone")
if args.invalid_editorial and (args.managed_session or args.managed_calendar_task):
parser.error("editorial failure requires a report journey")
repo = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(repo), str(repo / "tests")]
from test_native_steward_private import steward
Expand All @@ -50,13 +52,32 @@ def main() -> None:
data = next(fixture)
store, runtime, provider, transport, binding, capture, workspace = data
managed_session = None
if args.managed_report:
local_owner = None
if args.managed_calendar_task:
import pytest
from test_managed_session_calendar_tasks import setup_task
from loopx.configure_goal import configure_goal
from loopx.capabilities.native_chat.session_calendar_tasks import SessionCalendarTasks
monkeypatch = pytest.MonkeyPatch()
managed_session, actor, _, capture, task_workspace, configuration = setup_task(
data, root, monkeypatch, args.managed_calendar_task)
sid, managed = managed_session["session_id"], transport.managed_steward
local_tasks = SessionCalendarTasks(runtime)
local_owner = local_tasks
managed_todo_id = configuration["task"]["source_todo_id"]
configuration = {**configuration, "schedule": {**configuration["schedule"], "start_date": "2999-01-01"}}
configure_goal(registry_path=runtime.registry_path, goal_id=actor["goal_id"],
periodic_report_configuration={**configuration, "enabled": False}, execute=True)
artifact = task_workspace / "total.json"
artifact.unlink() # The next period must produce and validate its own artifact.
elif args.managed_report:
import pytest
from test_native_steward_reports import configured_local_report
from loopx.configure_goal import configure_goal
monkeypatch = pytest.MonkeyPatch()
managed_session, local_reports, configuration, managed_todo_id = configured_local_report(
data, root, monkeypatch, "sqlite", start_date="2999-01-01")
local_owner = local_reports
sid = managed_session["session_id"]
actor = local_reports.selected(sid)["actor"]
managed = transport.managed_steward
Expand All @@ -76,6 +97,13 @@ def main() -> None:
actor = managed.prepare(binding["binding_id"], session_id=sid, execute=True,
expected_plan_id=plan["plan_id"])["actor"]
install_model(data, root, sid)
if managed_session:
from loopx.capabilities.native_chat.steward_reports import ManagedSessionReports
# A seed helper also prepares a private steward. This journey uses
# the returned local Session, not that separate preparation actor.
local_owner = local_owner or ManagedSessionReports(runtime)
sid = managed_session["session_id"]
actor = local_owner.selected(sid)["actor"]
transport.reconcile()
if args.host_failure:
from loopx.chat_agent import CodexChatAgentError
Expand Down Expand Up @@ -118,34 +146,48 @@ def fail_report(_adapter, _prompt, event_sink):
"fixture_pid": os.getpid(),
"binding_id": binding["binding_id"], "session_id": sid,
**({"managed_session_id": managed_session["session_id"], "goal_id": actor["goal_id"],
"managed_todo_id": managed_todo_id} if managed_session else {})}))
"managed_todo_id": managed_todo_id} if managed_session else {}),
**({"authority_provider": args.managed_calendar_task,
"calendar_task_configuration": configuration} if args.managed_calendar_task else {})}))
wake, outbound, delivery = None, None, None
owner_start_count = 0
try:
while not stop.wait(.2):
server.assets_dir = current_assets if (output / "use-current-assets").exists() else baseline_assets
provider.owner_ready = not (output / "provider-unavailable").exists()
if wake is None and (output / "start-existing-owners").exists():
owners_stopped = (output / "stop-existing-wake").exists()
if owners_stopped and wake:
wake.close()
wake = None
if wake is None and not owners_stopped and (output / "start-existing-owners").exists():
wake = DelegationWakeService(runtime, interval=.1).start()
if not args.managed_report:
owner_start_count += 1
if not (args.managed_report or args.managed_calendar_task) and outbound is None:
outbound = LarkGoalTopicRuntimeService(snapshot_provider=lambda: {},
runtime_root=runtime.coordination_runtime_root, runtime_controller=runtime,
private_conversations=transport)
delivery = threading.Thread(target=outbound._reconcile_private)
delivery.start()
calendar_health = local_tasks.health(sid) if args.managed_calendar_task else None
(output / "observed.json").write_text(json.dumps({
"report": local_reports.health(sid) if args.managed_report else StewardReports(runtime).health(binding["binding_id"]),
"same_actor": local_reports.selected(sid)["actor"] == actor if args.managed_report else managed.health(binding["binding_id"])["session_id"] == actor["session_id"],
"report": local_reports.health(sid) if args.managed_report else None if args.managed_calendar_task else StewardReports(runtime).health(binding["binding_id"]),
"same_actor": local_owner.selected(sid)["actor"] == actor if local_owner else managed.health(binding["binding_id"])["session_id"] == actor["session_id"],
"history_prefix_retained": store.messages(sid)[:len(before_messages)] == before_messages,
"model_call_count": sum(json.loads(line).get("method") == "turn/start"
for line in capture.read_bytes()[len(before_calls):].splitlines()),
"provider_writes": len(provider.writes),
"private_outbound_alive": delivery.is_alive() if delivery else None,
"private_ingress": transport.ingress.health(binding["binding_id"]),
"provider_history_pages": sum("+chat-messages-list" in call for call in provider.calls),
"existing_wake_owner_running": wake is not None,
"existing_wake_owner_starts": owner_start_count,
**({"managed_source_present": artifact.exists(),
"managed_session": store.load_session(managed_session["session_id"])} if args.managed_session else {}),
**({"managed_report": True,
"managed_session": store.load_session(managed_session["session_id"])} if args.managed_report else {}),
**({"calendar_task": calendar_health,
"calendar_artifact": json.loads(artifact.read_text()) if calendar_health.get("completion_verified") else None,
"managed_session": store.load_session(sid)} if args.managed_calendar_task else {}),
"synthetic_only": True}, ensure_ascii=False))
finally:
if wake:
Expand Down
4 changes: 3 additions & 1 deletion loopx/chat_goal_configuration_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,9 @@ def _goal_capability_options(
return {option: cadence["count"], **horizon, **({"execution_stride_strategy": cadence["stride_strategy"]}
if "stride_strategy" in cadence else {})}
if capability_id == "periodic_report":
return {"periodic_report_configuration": config}
from .capabilities.periodic_report.goal_configuration import normalize_configuration
# Validate the payload before the Session guard reads native sources.
return {"periodic_report_configuration": normalize_configuration(config)}
if capability_id == "reward_memory":
config_path = config.get("config_path")
if config_path is not None and not isinstance(config_path, str):
Expand Down
25 changes: 25 additions & 0 deletions tests/test_managed_session_calendar_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,31 @@ def test_task_declarations_never_enroll_other_routes_or_implicit_commands(fault)
normalize_configuration(config)


@pytest.mark.parametrize("provider", ["file", "sqlite"])
@pytest.mark.parametrize("fault", ["missing-task", "empty-task", "array-task", "injected-command"])
def test_settings_reject_invalid_task_input_without_execution_or_config_write(steward, tmp_path, monkeypatch, provider, fault):
session, actor, _, capture, _, config = setup_task(steward, tmp_path, monkeypatch, provider)
store, runtime, bot, *_ = steward
if fault == "missing-task":
config.pop("task")
elif fault == "empty-task":
config["task"] = {}
elif fault == "array-task":
config["task"] = []
else:
config["task"]["validation_command"] = ["untrusted-command"]
before = runtime.registry_path.read_bytes(), capture.read_bytes(), store.messages(session["session_id"]), list(bot.writes)
with settings_server(steward) as request:
body = {"goal_id": actor["goal_id"], "capability_id": "periodic_report", "configuration": config}
for path, payload in (("preview", body), ("apply", {**body, "expected_plan_revision": "not-a-plan"})):
status, rejected = request("/api/chat/goal-configuration/" + path, payload)
assert status == 400, rejected
assert rejected["error_code"] == "invalid_goal_configuration"
assert "session task requires one existing source_todo_id" in rejected["error"]
assert (runtime.registry_path.read_bytes(), capture.read_bytes(), store.messages(session["session_id"]), list(bot.writes)) == before
assert read_cadence_journal(runtime_root=runtime.coordination_runtime_root, goal_id=actor["goal_id"]) is None


@pytest.mark.parametrize("provider", ["file", "sqlite"])
def test_known_failed_repeat_is_retained_without_replay_then_explicitly_cancelled(steward, tmp_path, monkeypatch, provider):
session, actor, _, capture, workspace, config = setup_task(steward, tmp_path, monkeypatch, provider)
Expand Down
Loading