From bc84107b1b8e26374acb5bb298453883539bc628 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:20:17 +0800 Subject: [PATCH 1/2] Show sourced clocks on child Goal research cards Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 27 +++++ .../personal-workspace/goal-activity.test.mjs | 14 ++- .../personal-workspace/goal-activity.ts | 16 +++ .../goal-research-workbench.tsx | 16 ++- .../personal-workspace-page.tsx | 8 +- .../research-case-study.css | 8 +- .../research-child-goals.tsx | 106 ++++++++++++++++++ .../dashboard/src/views/dashboard-page.tsx | 1 + loopx/presentation/explore_results_api.py | 85 +++++++++++++- .../presentation/test_linked_goal_sources.py | 21 +++- tests/test_chat_explore_results.py | 26 ++++- 11 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/research-child-goals.tsx diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index b2138a47c0..407205102a 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -3026,3 +3026,30 @@ export async function fetchExploreResults(goalId: string, cursor?: string, readS }); return page; } + +const researchHistoryTimingSchema = z.object({ + ok: z.literal(true), + goal_id: z.string(), + source_namespace: z.string().nullable(), + first_recorded_at: z.iso.datetime().nullable(), + last_recorded_at: z.iso.datetime().nullable(), + untimed_record_count: z.number().int().nonnegative(), + strategy_recorded_at: z.iso.datetime().nullable(), +}).refine(value => value.first_recorded_at === null + ? value.last_recorded_at === null + : value.last_recorded_at !== null && Date.parse(value.first_recorded_at) <= Date.parse(value.last_recorded_at)); +export type ResearchHistoryTiming = z.infer; + +export async function fetchResearchHistoryTiming( + goalId: string, originalGoalId: string, namespace: string | null, directoryUrl: string | undefined, signal: AbortSignal, +): Promise { + const statusUrl = namespace ? `/status.json?source_namespace=${namespace}` : directoryUrl; + const query = new URLSearchParams({goal_id: goalId, view: "history-timing"}); + const timing = researchHistoryTimingSchema.parse(await requestJson( + sourceReadApiUrl(statusUrl, `/api/chat/explore-results?${query}`), {signal}, + )); + if (timing.goal_id !== originalGoalId || timing.source_namespace !== namespace) { + throw new Error("Research timing source mismatch"); + } + return timing; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.test.mjs index b82799533a..a4b967ac15 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { goalExecution, goalObservationGaps, goalWorkKind, presentGoalActivity, taskSessionStatus } from "../../../node_modules/.cache/loopx-goal-activity/goal-activity.js"; +import { goalExecution, goalObservationGaps, goalWorkKind, observedLiveTurnClock, presentGoalActivity, taskSessionStatus } from "../../../node_modules/.cache/loopx-goal-activity/goal-activity.js"; // One Goal's session facts, mixed by mode. `updated_at` stands for the time the // owner last recorded anything for that session. @@ -184,3 +184,15 @@ for (const [scenario, activeTurnId, status, done, hasResult, expected] of [ } console.log("Goal and task execution read-model invariants passed"); + +const liveHost = {completeness: "complete", threads: [{ + hostSurface: "codex-app", state: "turn_open", lastEventAt: recent, turnStartedAt: minutesAgo(8), +}]}; +const liveGoal = {activationState: "active", state: "已安排", hostThreadActivity: liveHost, + execution: goalExecution([], "goal-a", liveHost, now)}; +assert.deepEqual(observedLiveTurnClock(liveGoal, now), {lastActivityAt: recent, startedAt: minutesAgo(8)}); +assert.equal(observedLiveTurnClock(liveGoal, now + 16 * 60_000), null, "An old open observation cannot keep a card live"); +assert.equal(observedLiveTurnClock({...liveGoal, activationState: "stopped"}, now), null); +assert.equal(observedLiveTurnClock({...liveGoal, execution: claim}, now), null, "A claim does not begin execution"); +assert.deepEqual(observedLiveTurnClock({...liveGoal, hostThreadActivity: undefined}, now), + {lastActivityAt: recent, startedAt: null}, "A session age cannot substitute for a current turn start"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts b/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts index 0150d3c5a3..ea1b2eabe5 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts @@ -48,6 +48,7 @@ export type GoalHostThread = { /** Host-owned read failure, distinct from the cadence window's reason. */ reason?: string | null; lastEventAt: string | null; + turnStartedAt?: string | null; deliveryWindow?: { state: "fresh" | "stale" | "missing" | "unknown"; reason?: string | null; @@ -244,6 +245,21 @@ export function presentGoalActivity(goal: GoalActivityInput): GoalActivity { const identityHues = [212, 262, 330, 24, 150, 190, 44, 290] as const; /** Stable, readable identity for scanning; never a status signal. */ +/** The most recent live fact, with a start only if tied to that observed host turn. */ +export function observedLiveTurnClock(goal: GoalActivityInput, now = Date.now()) { + if (!presentGoalActivity(goal).live || goal.execution?.kind !== "running") return null; + const parseClock = (value: string | null | undefined) => value && /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value) + ? Date.parse(value) : Number.NaN; + const lastActivityAt = goal.execution.lastActivityAt; + const last = parseClock(lastActivityAt); + if (!lastActivityAt || !Number.isFinite(last) || last > now || now - last > quietTurnMinutes * 60_000) return null; + const matchingThreads = openHostTurns(goal.hostThreadActivity?.threads ?? [], now) + .filter(thread => thread.lastEventAt === lastActivityAt); + const start = matchingThreads.length === 1 ? matchingThreads[0]?.turnStartedAt : null; + const startMs = parseClock(start); + return {lastActivityAt, startedAt: Number.isFinite(startMs) && startMs <= last ? start ?? null : null}; +} + export function goalIdentity(goalId: string, title: string): { glyph: string; hue: number } { let hash = 2_166_136_261; for (const character of goalId) { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx index 8952259aaf..c9da132892 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx @@ -3,13 +3,15 @@ import {GoalResearchHistory} from "./goal-research-history"; import {GoalExploreResults} from "./goal-explore-results"; import {goalHierarchy} from "./goal-hierarchy"; import type {WorkspaceGoal} from "./personal-workspace-model"; +import {ResearchChildGoals, type ResearchChildGoal} from "./research-child-goals"; import './research-case-study.css'; -type ResearchGoal = Pick; +type ResearchGoal = ResearchChildGoal & Pick; /** Parentage organizes explicit source views; it never combines evidence or execution scope. */ -export function ResearchGoalNavigation({goalId, goals, zh, onSelect}: { +export function ResearchGoalNavigation({goalId, goals, zh, onSelect, directoryUrl, active, refreshRevision}: { goalId: string; goals: ResearchGoal[]; zh: boolean; onSelect: (goalId: string) => void; + directoryUrl?: string; active: boolean; refreshRevision: number; }) { const selected = goals.find(g => g.goalId === goalId); if (!selected) return null; @@ -21,18 +23,20 @@ export function ResearchGoalNavigation({goalId, goals, zh, onSelect}: { if (!parent && !children.length) return null; return ; } /** One read surface for the selected original Goal; opening it never starts research. */ -export function GoalResearchWorkbench(props: {goalId: string; active: boolean; zh: boolean; refreshRevision: number; readSourceUrl?: string; goals?: ResearchGoal[]; onSelectGoal?: (goalId: string) => void; onOpenTasks?: () => void}) { +export function GoalResearchWorkbench(props: {goalId: string; active: boolean; zh: boolean; refreshRevision: number; readSourceUrl?: string; directoryReadSourceUrl?: string; goals?: ResearchGoal[]; onSelectGoal?: (goalId: string) => void; onOpenTasks?: () => void}) { const selected = props.goals?.find(goal => goal.goalId === props.goalId); const source = selected?.sourceAccess; const [evidenceOpen, setEvidenceOpen] = useState(false); return
- {props.goals && props.onSelectGoal ? : null} + {props.goals && props.onSelectGoal ? : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 556752b149..b299561a41 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -915,7 +915,11 @@ export function PersonalWorkspacePage({ const { locale, t } = useWorkspaceI18n(); const [localGoalId, setLocalGoalId] = useState(controlledGoalId ?? null); const sourceGoal = model.goals.find(goal => goal.goalId === (controlledGoalId === undefined ? localGoalId : controlledGoalId)); - const researchReadSourceUrl = sourceGoal?.sourceAccess ? `/status.json?source_namespace=${sourceGoal.sourceAccess.namespace}` : statusSourceControl?.activeSource.kind === "runtime" && statusSourceControl.connectionState !== "loading" && statusSourceControl.connectionState !== "error" ? statusSourceControl.activeSource.statusUrl : undefined; + const directoryReadSourceUrl = statusSourceControl?.activeSource.kind === "runtime" + && statusSourceControl.connectionState !== "loading" && statusSourceControl.connectionState !== "error" + ? statusSourceControl.activeSource.statusUrl : undefined; + const researchReadSourceUrl = sourceGoal?.sourceAccess + ? `/status.json?source_namespace=${sourceGoal.sourceAccess.namespace}` : directoryReadSourceUrl; const readOnly = sourceReadOnly || sourceGoal?.sourceAccess?.readOnly === true; const hostReadOnly = statusSourceControl?.activeSource.readOnly ?? sourceReadOnly; const [localAgentId, setLocalAgentId] = useState(controlledAgentId ?? agents.find((agent) => agent.available)?.agentId ?? "codex"); @@ -2972,7 +2976,7 @@ export function PersonalWorkspacePage({ historyRefreshRevision={historyRefreshRevision} proposals={proposals} onPreview={createPreview} />, tasks: taskBoard(!settingsOpen && selectedGoalTab === "tasks"), research: !readOnly || researchReadSourceUrl - ? selectGoal(id, "research")} onOpenTasks={() => setSelectedGoalTab("tasks")} active={!settingsOpen && selectedGoalTab === "research"} zh={locale === "zh-CN"} refreshRevision={historyRefreshRevision} readSourceUrl={researchReadSourceUrl} /> + ? selectGoal(id, "research")} onOpenTasks={() => setSelectedGoalTab("tasks")} active={!settingsOpen && selectedGoalTab === "research"} zh={locale === "zh-CN"} refreshRevision={historyRefreshRevision} readSourceUrl={researchReadSourceUrl} directoryReadSourceUrl={directoryReadSourceUrl} /> :

{locale === "zh-CN" ? "此来源尚未提供研究记录的读取入口。" : "This source does not provide a research history endpoint."}

, files: goalResults(selectedGoalTab === "files" && !settingsOpen), chat: agentWorkActive ? span, .research-child-timing { color: var(--pw-muted); font-size: var(--text-caption); } .research-child-goals { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(240px,100%), 1fr)); gap: 16px; } -.research-child-goals button { display: flex; align-items: center; justify-content: space-between; gap: 16px; border: 1px solid var(--pw-line); border-radius: 8px; padding: 20px; overflow-wrap: anywhere; } +.research-child-goals button { display: flex; align-items: start; justify-content: space-between; gap: 12px; border: 1px solid var(--pw-line); border-radius: 8px; padding: 16px; overflow-wrap: anywhere; } +.research-child-body, .research-child-timing { display: grid; min-width: 0; } +.research-child-body { gap: 6px; } +.research-child-timing { gap: 2px; font-variant-numeric: tabular-nums; } +.research-child-timing time { white-space: nowrap; } .research-goal-navigation button:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 3px; } .case-reader-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; border-bottom: 1px solid var(--pw-line); padding-block: 8px 16px; } .case-reader-toolbar label { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; font-size: var(--text-body); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-child-goals.tsx b/apps/presentation/dashboard/src/features/personal-workspace/research-child-goals.tsx new file mode 100644 index 0000000000..6ef32d2c22 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-child-goals.tsx @@ -0,0 +1,106 @@ +import {useEffect, useState} from "react"; +import {fetchResearchHistoryTiming, type ResearchHistoryTiming} from "../../data/chat"; +import {observedLiveTurnClock} from "./goal-activity"; +import type {WorkspaceGoal} from "./personal-workspace-model"; + +export type ResearchChildGoal = Pick; + +type SourceTarget = [goalId: string, originalGoalId: string, namespace: string | null]; +type TimingRead = {targets: SourceTarget[]; directoryUrl?: string; refreshRevision: number}; + +function useChildTiming(goals: ResearchChildGoal[], directoryUrl: string | undefined, refreshRevision: number, active: boolean) { + const request: TimingRead = { + targets: goals.map(({goalId, sourceAccess}) => + [goalId, sourceAccess?.original_goal_id ?? goalId, sourceAccess?.namespace ?? null]), + directoryUrl, refreshRevision, + }; + // Status polling can reconstruct the same directory. Only identity or a + // deliberate refresh should restart these reads. + const key = JSON.stringify(request); + const [snapshot, setSnapshot] = useState<{key: string; byGoal: Map}>(); + useEffect(() => { + if (!active) return; + const {targets, directoryUrl} = JSON.parse(key) as TimingRead; + const controller = new AbortController(); + const pending = targets.values(); + async function read() { + for (const [goalId, originalGoalId, namespace] of pending) { + if (controller.signal.aborted) return; + const timing = await fetchResearchHistoryTiming(goalId, originalGoalId, namespace, directoryUrl, controller.signal) + .catch(() => null); + if (controller.signal.aborted) return; + setSnapshot(previous => ({ + key, + byGoal: new Map(previous?.key === key ? previous.byGoal : undefined).set(goalId, timing), + })); + } + } + // Keep a populated directory responsive without opening every source at once. + for (let worker = 0; worker < Math.min(targets.length, 3); worker++) void read(); + return () => controller.abort(); + }, [active, key]); + return snapshot?.key === key ? snapshot.byGoal : undefined; +} + +const beijingClock = new Intl.DateTimeFormat("sv-SE", { + dateStyle: "short", timeStyle: "short", timeZone: "Asia/Shanghai", +}); + +function Clock({value}: {value: string}) { + return ; +} + +function spanText(ms: number, zh: boolean) { + const minutes = Math.floor(ms / 60_000); + if (minutes === 0) return zh ? "不足 1 分钟" : "under 1 min"; + const hours = Math.floor(minutes / 60); + const remainder = minutes % 60; + return [hours > 0 && (zh ? `${hours} 小时` : `${hours} hr`), + remainder > 0 && (zh ? `${remainder} 分钟` : `${remainder} min`)].filter(Boolean).join(" "); +} + +function Timing({goal, timing, zh}: {goal: ResearchChildGoal; timing?: ResearchHistoryTiming | null; zh: boolean}) { + const now = Date.now(); + const live = observedLiveTurnClock(goal, now); + if (live?.startedAt) { + return <>{zh ? "回合开始" : "Turn started"} + {zh ? "进行中" : "In progress"} · {spanText(now - Date.parse(live.startedAt), zh)}; + } + if (live) { + return <>{zh ? "最近活动" : "Last activity"} + {zh ? "进行中" : "In progress"}; + } + if (timing === undefined) return zh ? "读取时间…" : "Reading time…"; + if (timing === null) return zh ? "无法读取时间" : "Time unavailable"; + const {first_recorded_at: start, last_recorded_at: end, untimed_record_count: untimed, strategy_recorded_at: strategy} = timing; + if (start && end) { + const label = untimed ? (zh ? "已知记录" : "Dated records") : (zh ? "研究记录" : "Research records"); + if (start === end) return {label} ; + return <>{label} + {zh ? "至" : "to"} + {zh ? "记录跨度" : "Recorded span"} {spanText(Date.parse(end) - Date.parse(start), zh)}; + } + if (strategy) return {zh ? "策略记录" : "Saved strategy"} ; + return untimed ? (zh ? "记录时间未知" : "Record time unknown") : (zh ? "暂无时间记录" : "No dated records"); +} + +export function ResearchChildGoals({goals, directoryUrl, refreshRevision, active, zh, onSelect}: { + goals: ResearchChildGoal[]; directoryUrl?: string; refreshRevision: number; active: boolean; zh: boolean; + onSelect: (goalId: string) => void; +}) { + const timing = useChildTiming(goals, directoryUrl, refreshRevision, active); + return <> +
+

{zh ? "子 Goal 研究" : "Research in child Goals"}

+ {zh ? "北京时间" : "Beijing time (UTC+8)"} +
+
{goals.map(goal => )}
+ ; +} diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 21d1777b95..842a61de6c 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1129,6 +1129,7 @@ function buildPersonalHomeModel( agentId: thread.agent_id ?? null, hostSurface: thread.host_surface, lastEventAt: thread.last_event_at ?? null, + turnStartedAt: thread.turn_started_at ?? null, state: thread.state, reason: thread.reason ?? null, deliveryWindow: thread.delivery_window ? { diff --git a/loopx/presentation/explore_results_api.py b/loopx/presentation/explore_results_api.py index a3f5004331..ae9e06c75a 100644 --- a/loopx/presentation/explore_results_api.py +++ b/loopx/presentation/explore_results_api.py @@ -1,7 +1,8 @@ """Loopback read adapter for the canonical Explore result projection.""" -from urllib.parse import parse_qs, urlparse +from datetime import datetime, timezone import subprocess +from urllib.parse import parse_qs, urlparse from ..control_plane.todos.list_readback import list_goal_todos from ..control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable @@ -71,6 +72,55 @@ def _history_rows(runtime_root, goal_id): )) +def _explicit_clock(value): + if not isinstance(value, str): + return None + try: + clock = datetime.fromisoformat(value.strip()) + return clock.astimezone(timezone.utc) if clock.utcoffset() is not None else None + except (ValueError, OverflowError): + return None + + +def _utc_text(clock): + return clock.isoformat().replace("+00:00", "Z") if clock is not None else None + + +def _saved_strategy_clock(goal): + def field(value, name): + return value.get(name) if isinstance(value, dict) else None + + agents = field(field(goal, "semantic_history"), "agents") + clocks = [] + for agent in agents if isinstance(agents, list) else (): + run = field(agent, "latest_agent_vision_run") + summary = field(field(field(run, "agent_vision"), "vision_patch"), "vision_summary") + if isinstance(summary, str) and summary.strip(): + clock = _explicit_clock(field(run, "generated_at")) + if clock is not None: + clocks.append(clock) + return max(clocks, default=None) + + +def _history_timing(events, *, goal_id, source_namespace=None, native_goal=None): + """Read clocks from the saved records, without using them as execution times.""" + recorded = {} + for event in events: + recorded[event["event_id"]] = _explicit_clock(event["recorded_at"]) + times = [clock for clock in recorded.values() if clock is not None] + # A strategy is an independent saved state fact. Its clock is useful even + # when a source predates the Explore log. + return { + "ok": True, + "goal_id": goal_id, + "source_namespace": source_namespace, + "first_recorded_at": _utc_text(min(times, default=None)), + "last_recorded_at": _utc_text(max(times, default=None)), + "untimed_record_count": len(recorded) - len(times), + "strategy_recorded_at": _utc_text(_saved_strategy_clock(native_goal)) if not times else None, + } + + class ExploreResultsRequestMixin: def _explore_results(self): query = parse_qs(urlparse(self.path).query, keep_blank_values=True) @@ -80,22 +130,37 @@ def _explore_results(self): self._send_error("Choose one Goal, source and Explore view.", status=400) return view = query.get("view", ["findings"])[0] - if view not in {"findings", "history"}: + if view not in {"findings", "history", "history-timing"}: self._send_error("Unknown Explore view.", status=400) return + if view == "history-timing" and "cursor" in query: + self._send_error("The timing view has no pages.", status=400) + return from .goal_source_routing import linked_goal_source resolved = linked_goal_source(self.server, goal_id) if resolved is not None: if not self._require_loopback_origin(): return - row, reader = resolved + _, reader = resolved if query.get("source_namespace") != [reader.source.namespace]: self._send_error("Source identity changed; refresh the Goal.", status=409) return cursor = query.get("cursor", [""])[0] original_goal_id = reader.source.goal_id - page = (reader.history(original_goal_id, cursor) if view == "history" else - reader.findings(original_goal_id, cursor, projector=_finding_rows)) + if view == "history-timing": + status = reader.registered_status(original_goal_id, fresh=True) + events = ( + validate_explore_result_event(event, expected_goal_id=original_goal_id) + for event in reader.source.read_events(original_goal_id) + ) + page = _history_timing( + events, goal_id=original_goal_id, source_namespace=reader.source.namespace, + native_goal=status["run_history"]["goals"][0], + ) + elif view == "history": + page = reader.history(original_goal_id, cursor) + else: + page = reader.findings(original_goal_id, cursor, projector=_finding_rows) latest = linked_goal_source(self.server, goal_id) if latest is None or latest[1].source.namespace != reader.source.namespace: self._send_error("Source identity changed; refresh the Goal.", status=409) @@ -108,6 +173,16 @@ def _explore_results(self): runtime_root = self._goal_result_scope(goal_id) if runtime_root is None: return + if view == "history-timing": + events = load_explore_result_events_strict( + explore_result_log_path(runtime_root, goal_id), goal_id=goal_id, + ) + # A Goal can become a different linked source during the read. + if linked_goal_source(self.server, goal_id) is not None: + self._send_error("Source identity changed; refresh the Goal.", status=409) + return + self._send_json(_history_timing(events, goal_id=goal_id)) + return # Local HTTP view selection; canonical event vocabularies are unchanged. page = self.server.completed_todo_pages.page( scope=("explore_history" if view == "history" else "explore_results", goal_id), diff --git a/tests/presentation/test_linked_goal_sources.py b/tests/presentation/test_linked_goal_sources.py index 0884169f22..fe72d96c35 100644 --- a/tests/presentation/test_linked_goal_sources.py +++ b/tests/presentation/test_linked_goal_sources.py @@ -51,9 +51,10 @@ def native_source(w, name='original', task_text='Check the original question aga build_todo_runtime_shadow_projection(goal_id=goal, todos=[], handoff_mode='soft_claim'), state_path=w['tmp_path'] / 'active.md', provider='sqlite') log = explore_result_log_path(runtime, 'solver-goal') + clock = '2026-01-03T00:15:00Z' if done else '2026-01-02T00:15:00Z' for name in ('question', 'attempt'): append_explore_result_event(log, build_explore_node_event(goal_id='solver-goal', - node_id=name, node_kind='question', title=name, summary='Original native record')) + node_id=name, node_kind='question', title=name, summary='Original native record', recorded_at=clock)) append_explore_result_event(log, build_explore_edge_event(goal_id='solver-goal', from_node='attempt', to_node='question', edge_type='depends_on')) raw = {'id': name, 'label': 'Original experiment', @@ -194,9 +195,18 @@ def test_source_failure_preserves_links_peers_and_native_recovery(workspace): bound = w['server'].registry_path.read_bytes() source_registry = Path(raw['registry']) request('/status.json?goal_id=solver-goal') + timing_url = '/api/chat/explore-results?' + urlencode({ + 'goal_id': 'solver-goal', 'source_namespace': GoalSource.parse(raw).namespace, 'view': 'history-timing', + }) + assert request(timing_url)['first_recorded_at'] == '2026-01-02T00:15:00Z' # A real native CLI failure, including private stderr, must not become a # generic workspace failure or borrow a local Goal's snapshot. source_registry.unlink() + # Even the warm native status snapshot cannot authorize the timing read. + with pytest.raises(HTTPError) as unavailable_timing: + request(timing_url) + assert unavailable_timing.value.code == 503 + assert str(w['tmp_path']).encode() not in unavailable_timing.value.read() reader = w['server'].goal_source_catalog.resolve('solver-goal')[1] reader._read_at = 0 with pytest.raises(HTTPError) as unavailable: @@ -216,6 +226,7 @@ def test_source_failure_preserves_links_peers_and_native_recovery(workspace): assert removed.value.code == 409 source_registry.write_bytes(before[source_registry]) assert request('/status.json?goal_id=solver-goal')['run_history']['goals'][0]['registry_member'] + assert request(timing_url)['first_recorded_at'] == '2026-01-02T00:15:00Z' assert not w['count'].exists() @@ -435,6 +446,10 @@ def test_same_named_native_goals_have_isolated_workspace_views(workspace): history = request('/api/chat/explore-results?' + query + '&view=history') assert history['goal_id'] == 'solver-goal' and history['total'] == 3 assert all(item['goal_id'] == 'solver-goal' for item in history['items']) + timing = request('/api/chat/explore-results?' + query + '&view=history-timing') + expected_start = '2026-01-03T00:15:00Z' if view == 'case-b' else '2026-01-02T00:15:00Z' + assert timing['first_recorded_at'] == expected_start + assert timing['goal_id'] == 'solver-goal' and timing['source_namespace'] == namespace assert request('/api/chat/research-analysis?' + query)['catalog']['packs'] == [] completed = request('/api/chat/completed-todos?' + query) assert completed['total'] == (1 if view == 'case-b' else 0) @@ -444,6 +459,10 @@ def test_same_named_native_goals_have_isolated_workspace_views(workspace): with pytest.raises(HTTPError): request('/api/chat/explore-results?' + urlencode({ 'goal_id': 'case-a', 'source_namespace': namespaces[1], 'view': 'history'})) + with pytest.raises(HTTPError) as wrong_timing_source: + request('/api/chat/explore-results?' + urlencode({ + 'goal_id': 'case-a', 'source_namespace': namespaces[1], 'view': 'history-timing'})) + assert wrong_timing_source.value.code == 409 with pytest.raises(HTTPError): request('/api/chat/completed-todos?' + urlencode({ 'goal_id': 'case-a', 'source_namespace': namespaces[1]})) diff --git a/tests/test_chat_explore_results.py b/tests/test_chat_explore_results.py index d857add417..48a98ee22a 100644 --- a/tests/test_chat_explore_results.py +++ b/tests/test_chat_explore_results.py @@ -32,12 +32,16 @@ def test_ninth_link_readback_pagination_scope_failure_and_recovery(tmp_path, mon log = explore_result_log_path(runtime, "evidence-goal") append_explore_result_event(log, build_explore_node_event( goal_id="evidence-goal", node_id="question", title="Bounded input hypothesis", - node_kind="question", summary="Inputs with a finite prefix", + node_kind="question", summary="Inputs with a finite prefix", recorded_at="2026-01-02T08:00:00+08:00", )) for index in range(41): append_explore_result_event(log, build_explore_finding_event( goal_id="evidence-goal", node_id="question", finding_id=f"finding_{index}", title=f"Observation {index}", status="refuted", + recorded_at=( + "2026-02-01T12:00:00" if index == 0 + else "2026-01-03T00:45:00Z" if index == 40 else "2026-01-02T00:30:00Z" + ), summary="Counterexample at input revision A " + "c" * 1700 + "; do not transfer beyond the tested scope.", evidence_refs=["artifact:counterexample"], agent_id="worker", )) @@ -103,6 +107,22 @@ def read(url): assert page["total"] == 41 and len(page["items"]) == 40 history = read(base + "evidence-goal&view=history") assert history["total"] == 42 and len(history["items"]) == 40 + timing_url = base + "evidence-goal&view=history-timing" + assert read(timing_url) == { + "ok": True, "goal_id": "evidence-goal", "source_namespace": None, + "first_recorded_at": "2026-01-02T00:00:00Z", + "last_recorded_at": "2026-01-03T00:45:00Z", + "untimed_record_count": 1, "strategy_recorded_at": None, + } + empty = read(base + "other-goal&view=history-timing") + assert empty["first_recorded_at"] is None and empty["last_recorded_at"] is None + assert empty["untimed_record_count"] == 0 + with pytest.raises(HTTPError) as no_pages: + read(timing_url + "&cursor=stale") + assert no_pages.value.code == 400 + with pytest.raises(HTTPError) as denied_timing: + read(Request(timing_url, headers={"Origin": "https://unrelated.example"})) + assert denied_timing.value.code == 403 item = page["items"][0] assert item["scope"] == "Inputs with a finite prefix" assert item["status"] == "refuted" @@ -129,8 +149,12 @@ def read(url): read(base + "evidence-goal") assert corrupt.value.code == 409 assert str(tmp_path).encode() not in corrupt.value.read() + with pytest.raises(HTTPError) as corrupt_timing: + read(timing_url) + assert corrupt_timing.value.code == 409 log.write_bytes(before) assert read(base + "evidence-goal")["total"] == 41 + assert read(timing_url)["last_recorded_at"] == "2026-01-03T00:45:00Z" state.unlink() assert len(read(base + "evidence-goal")["items"][0]["linked_todos"]) == 2 finally: From ec13f27e27564e638231795f447a1d99851fdc65 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:45:31 +0800 Subject: [PATCH 2/2] test(workspace): timestamp stored conversation smoke messages Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../conversation-activity.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/personal-workspace-browser/conversation-activity.mjs b/examples/personal-workspace-browser/conversation-activity.mjs index 2eba84897f..3410ffd3c5 100644 --- a/examples/personal-workspace-browser/conversation-activity.mjs +++ b/examples/personal-workspace-browser/conversation-activity.mjs @@ -216,7 +216,10 @@ export const conversationActivityScenario = { const answer = "检查完成,下一步已列出。"; const runtime = page.__loopxRuntime; runtime.sessions.set(next.sessionId, { ...runtime.sessions.get(next.sessionId), active_turn_id: null, status: "ready" }); - runtime.messages.get(next.sessionId).push({ message_id: `${next.turnId}-assistant`, turn_id: next.turnId, role: "assistant", text: answer }); + runtime.messages.get(next.sessionId).push({ + message_id: `${next.turnId}-assistant`, turn_id: next.turnId, role: "assistant", text: answer, + created_at: await page.evaluate(() => new Date().toISOString()), + }); response.end(event(5, "turn.completed", { response: { schema_version: "loopx_chat_agent_response_v0", message: answer, proposals: [], gate: null } })); await route.fulfill({ json: { ok: true, session_id: next.sessionId, turn_id: next.turnId, status: "completed" } }); }); @@ -246,7 +249,10 @@ export const conversationActivityScenario = { const runtime = page.__loopxRuntime; runtime.turnMessages.set(turnId, body.message); runtime.sessions.set(sessionId, {...runtime.sessions.get(sessionId), active_turn_id: turnId, status: "busy"}); - runtime.messages.get(sessionId).push({message_id: `${turnId}-user`, turn_id: turnId, role: "user", text: body.message}); + runtime.messages.get(sessionId).push({ + message_id: `${turnId}-user`, turn_id: turnId, role: "user", text: body.message, + created_at: await page.evaluate(() => new Date().toISOString()), + }); api.turnRequests.push({message: body.message, sessionId, turnId}); let holdFirstRead = true; await page.route(`**/api/chat/sessions/${sessionId}`, snapshot => {