diff --git a/plugins/_goal/tests/test_goal_plugin.py b/plugins/_goal/tests/test_goal_plugin.py
index ad0f74355..9732b5921 100644
--- a/plugins/_goal/tests/test_goal_plugin.py
+++ b/plugins/_goal/tests/test_goal_plugin.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import uuid
+from pathlib import Path
from types import SimpleNamespace
import pytest
@@ -57,6 +58,56 @@ def test_goal_storage_round_trip(context_id: str):
assert goal.get_goal(context_id) is None
+def test_goal_changes_publish_state_revision(context_id: str, monkeypatch):
+ from agent import AgentContext
+ from helpers import state_monitor_integration
+
+ revisions = iter([1.0, 2.0, 3.0])
+ output_data = {}
+ dirty = []
+ context = SimpleNamespace(
+ set_output_data=lambda key, value: output_data.__setitem__(key, value)
+ )
+ monkeypatch.setattr(AgentContext, "get", lambda _context_id: context)
+ monkeypatch.setattr(goal.time, "time", lambda: next(revisions))
+ monkeypatch.setattr(
+ state_monitor_integration,
+ "mark_dirty_for_context",
+ lambda context_id, *, reason: dirty.append((context_id, reason)),
+ )
+
+ goal.create_goal(context_id, "Publish changes")
+ goal.update_goal(context_id, status="paused")
+ goal.delete_goal(context_id)
+
+ assert output_data["_goal_revision"] == 3.0
+ assert dirty == [(context_id, "plugins._goal")] * 3
+
+
+def test_goal_webui_uses_state_revisions_instead_of_polling():
+ plugin_root = Path(__file__).resolve().parents[1]
+ store = (plugin_root / "webui" / "goal-store.js").read_text()
+ strip = (
+ plugin_root
+ / "extensions"
+ / "webui"
+ / "chat-input-progress-start"
+ / "goal-strip.html"
+ ).read_text()
+ refresh = (
+ plugin_root
+ / "extensions"
+ / "webui"
+ / "apply_snapshot_before"
+ / "refresh-goal.js"
+ ).read_text()
+
+ assert "setInterval(() => this.refresh" not in store
+ assert "$watch('$store.chats.selected'" not in strip
+ assert "_goal_revision" in refresh
+ assert "goalStore.refresh(true)" in refresh
+
+
def test_goal_command_sets_pauses_resumes_and_deletes(context_id: str):
created = goal_command.run(_payload(context_id, "/goal Add current goal support"))
assert created["effects"][0]["message"] == "Goal set."
diff --git a/plugins/_goal/tools/goal.py b/plugins/_goal/tools/goal.py
index 9a5f14d14..0852125c0 100644
--- a/plugins/_goal/tools/goal.py
+++ b/plugins/_goal/tools/goal.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import re
+import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -141,7 +142,9 @@ def update_goal(
def delete_goal(context_id: str) -> None:
- files.delete_file(_goal_path(_require_context_id(context_id)))
+ context_id = _require_context_id(context_id)
+ files.delete_file(_goal_path(context_id))
+ _notify_goal_changed(context_id)
def public_goal(goal: dict[str, Any] | None) -> dict[str, Any] | None:
@@ -182,6 +185,23 @@ def _write_goal(goal: dict[str, Any]) -> None:
_goal_path(str(goal["context_id"])),
json.dumps(public_goal(goal), indent=2, ensure_ascii=False) + "\n",
)
+ _notify_goal_changed(str(goal["context_id"]))
+
+
+def _notify_goal_changed(context_id: str) -> None:
+ from agent import AgentContext
+
+ context = AgentContext.get(context_id)
+ if context is None:
+ return
+ context.set_output_data("_goal_revision", time.time())
+
+ try:
+ from helpers.state_monitor_integration import mark_dirty_for_context
+
+ mark_dirty_for_context(context_id, reason="plugins._goal")
+ except Exception:
+ pass
def _normalize_goal(raw: dict[str, Any], *, context_id: str) -> dict[str, Any]:
diff --git a/plugins/_goal/webui/goal-store.js b/plugins/_goal/webui/goal-store.js
index 6a3360501..e55d6ece4 100644
--- a/plugins/_goal/webui/goal-store.js
+++ b/plugins/_goal/webui/goal-store.js
@@ -15,7 +15,6 @@ const model = {
editing: false,
draft: "",
lastContextId: "",
- intervalId: null,
clockIntervalId: null,
goalChangedHandler: null,
now: Date.now(),
@@ -82,17 +81,15 @@ const model = {
document.getElementById("progress-bar-box")?.classList.add("has-goal-bar");
this.goalChangedHandler = (event) => {
const detail = event?.detail || {};
- if (detail.goal === null) {
- this.goal = null;
- }
- void this.refresh(true);
+ if (detail.context_id && detail.context_id !== this.contextId) return;
+ this.goal = detail.goal || null;
+ this.now = Date.now();
+ if (!this.goal) this.editing = false;
};
window.addEventListener("goal:changed", this.goalChangedHandler);
this.clockIntervalId = window.setInterval(() => {
this.now = Date.now();
}, 1000);
- this.intervalId = window.setInterval(() => this.refresh(), 3000);
- void this.refresh(true);
},
cleanup() {
@@ -100,14 +97,10 @@ const model = {
if (this.goalChangedHandler) {
window.removeEventListener("goal:changed", this.goalChangedHandler);
}
- if (this.intervalId) {
- window.clearInterval(this.intervalId);
- }
if (this.clockIntervalId) {
window.clearInterval(this.clockIntervalId);
}
this.goalChangedHandler = null;
- this.intervalId = null;
this.clockIntervalId = null;
},