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
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import {renderToStaticMarkup} from "react-dom/server";
import {resolveTestPython} from "../../../../scripts/test-python.mjs";
import {readAnalysisSnapshot, ResearchAnalysisPanel} from "../src/features/personal-workspace/goal-research-analysis.tsx";
import {ResearchCaseStudy} from "../src/features/personal-workspace/research-case-study.tsx";
import {fetchExploreResults} from "../src/data/chat.ts";
import {ResearchGoalNavigation} from "../src/features/personal-workspace/goal-research-workbench.tsx";

// A case belongs to a source-scoped child Goal. Parent navigation must never
// combine private-source siblings or turn organization into execution.
// The persisted directory can organize explicitly linked physical sources.
// Navigation must not turn organization into shared evidence or execution.
const caseFixture = JSON.parse(await readFile(resolve('src/data/fixtures/case-study-child.json'), 'utf8'));
const study = caseFixture.analyses[0].case_study;
const renderedStudy = renderToStaticMarkup(createElement(ResearchCaseStudy, {
Expand All @@ -33,9 +34,21 @@ const navigate = goalId => renderToStaticMarkup(createElement(ResearchGoalNaviga
goalId, goals, zh:false, onSelect: () => {throw Error('read navigated');},
}));
assert.ok(navigate('parent').includes('Local case'));
assert.ok(!navigate('parent').includes('Foreign case'));
assert.ok(navigate('parent').includes('Foreign case'));
assert.ok(navigate('child').includes('Benchmark'));
assert.equal(navigate('foreign'), '', 'An absent same-source parent is not a navigation grant');
assert.ok(navigate('foreign').includes('Benchmark'), 'Explicit directory links retain their local parent');

// Transport uses the workspace view; immutable findings still identify the native Goal.
const sourceFetch = globalThis.fetch, sourceWindow = globalThis.window;
try {
globalThis.window = {location: {href: "http://127.0.0.1:12345/chat/"}};
globalThis.fetch = async input => {
assert.equal(new URL(input, window.location.href).searchParams.get("goal_id"), "view-a");
return Response.json({ok: true, goal_id: "native", total: 0, items: [], next_cursor: null});
};
assert.equal((await fetchExploreResults("view-a", undefined, undefined, "native")).goal_id, "native");
await assert.rejects(fetchExploreResults("view-a"), /mismatch/);
} finally { globalThis.fetch = sourceFetch; globalThis.window = sourceWindow; }

// A language-specific cache miss must not hide the same registered pack's
// older, unspecified-language result or turn a read into model execution.
Expand Down
6 changes: 3 additions & 3 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3017,12 +3017,12 @@ const exploreResultPageSchema = z.object({
})),
});
export type ExploreResultPage = z.infer<typeof exploreResultPageSchema>;
export async function fetchExploreResults(goalId: string, cursor?: string, readSourceUrl?: string) {
export async function fetchExploreResults(goalId: string, cursor?: string, readSourceUrl?: string, originalGoalId = goalId) {
const query = new URLSearchParams({goal_id: goalId, view: "findings", ...(cursor ? {cursor} : {})});
const page = exploreResultPageSchema.parse(await requestJson<unknown>(sourceReadApiUrl(readSourceUrl, `/api/chat/explore-results?${query}`)));
if (page.goal_id !== goalId) throw new Error("Explore result Goal mismatch");
if (page.goal_id !== originalGoalId) throw new Error("Explore result Goal mismatch");
for (const item of page.items) if (item.work_yardstick) normalizeWorkYardstickCapture({
capture: item.work_yardstick, goal_id: goalId, agent_id: item.agent_id, evidence_refs: item.evidence_refs,
capture: item.work_yardstick, goal_id: originalGoalId, agent_id: item.agent_id, evidence_refs: item.evidence_refs,
});
return page;
}
2 changes: 1 addition & 1 deletion apps/presentation/dashboard/src/data/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ export const runRecordSchema = z.object({
});

export const runGoalSchema = z.object({
source_access: z.object({namespace: z.string(), label: z.string(), read_only: z.literal(true)}).optional(),
source_access: z.object({namespace: z.string(), label: z.string(), read_only: z.literal(true), original_goal_id: z.string().optional()}).optional(),
parent_goal_id: z.string().nullable().optional().catch(null),
goal_objective: z.string().nullable().optional().catch(null),
zcode_goal_eligible_agent_ids: z.array(z.string()).optional().default([]).catch([]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const directorySchema = z.object({
goals: z.array(z.object({
id: z.string(),
parent_goal_id: z.string().nullable().optional().catch(null),
source_access: z.object({namespace: z.string(), label: z.string(), read_only: z.literal(true)}).optional(),
source_access: z.object({namespace: z.string(), label: z.string(), read_only: z.literal(true), original_goal_id: z.string().optional()}).optional(),
display_name: z.string(),
activation_state: z.enum(["active", "stopped"]),
registry_member: z.literal(true),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export function ChannelHeader({
const sourceNamedByTitle = selectedGoal?.title === readOnlySourceLabel;
const runtimeControl = scopeUnavailable || agentWorkActive ? null : readOnlySourceLabel ? (
<div className="personal-read-only-source">
<ContextHelp icon={Eye} label={sourceNamedByTitle ? t("header.readOnlySourceHelp") : `${readOnlySourceLabel} · ${t("common.readOnly")}`}><p>{t("header.readOnlySourceDescription", { source: readOnlySourceLabel })}</p></ContextHelp>
<ContextHelp icon={Eye} label={sourceNamedByTitle ? t("header.readOnlySourceHelp") : `${readOnlySourceLabel} · ${t("common.readOnly")}`}><p>{t("header.readOnlySourceDescription", { source: readOnlySourceLabel })}</p>{selectedGoal?.sourceAccess?.original_goal_id ? <p>{locale === "zh-CN" ? "原始 Goal:" : "Original Goal: "}<code>{selectedGoal.sourceAccess.original_goal_id}</code></p> : null}</ContextHelp>
{sourceNamedByTitle ? null : <span className="personal-read-only-source-label">{readOnlySourceLabel}</span>}
<small>{t("common.readOnly")}</small>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevisio
setViewport({ top: 0, height: scroll.current?.clientHeight ?? 600 });
}
const refreshSnapshot = useEffectEvent(() => resetSnapshot(historySearch));
useEffect(() => { refreshSnapshot(); }, [refreshRevision]);
useEffect(() => { refreshSnapshot(); }, [refreshRevision, goal.sourceAccess?.namespace]);
const needsPage = cursor === undefined || viewport.top + viewport.height >= rows.length * rowHeight - rowHeight * 2;
useEffect(() => {
if (!enabled || !visible || (!needsPage && !more) || cursor === null || error || request.current) return;
Expand All @@ -77,6 +77,7 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevisio
setBusy(true);
setMore(false);
const query = new URLSearchParams({ goal_id: goal.goalId });
if (goal.sourceAccess) query.set("source_namespace", goal.sourceAccess.namespace);
if (agentId !== "all") query.set("agent_id", agentId);
if (cursor) query.set("cursor", cursor);
if (historySearch) query.set("q", historySearch);
Expand All @@ -93,7 +94,7 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevisio
}).catch(() => { if (!controller.signal.aborted) setError(true); }).finally(() => {
if (!controller.signal.aborted) { request.current = null; setBusy(false); }
});
}, [enabled, visible, needsPage, cursor, error, retry, goal.goalId, agentId, busy, more, historySearch]);
}, [enabled, visible, needsPage, cursor, error, retry, goal.goalId, goal.sourceAccess?.namespace, agentId, busy, more, historySearch]);
useEffect(() => {
if (scroll.current) scroll.current.scrollTop = 0;
setViewport({ top: 0, height: scroll.current?.clientHeight ?? 600 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function LinkedFindingTask({goalId, todo, statuses, zh, readSourceUrl}: {
}

/** Saved evidence remains readable after Explore is disabled. No execution here. */
export function GoalExploreResults({goalId, zh, active = true, readSourceUrl}: {goalId: string; zh: boolean; active?: boolean; readSourceUrl?: string}) {
export function GoalExploreResults({goalId, originalGoalId = goalId, zh, active = true, readSourceUrl}: {goalId: string; originalGoalId?: string; zh: boolean; active?: boolean; readSourceUrl?: string}) {
const [page, setPage] = useState<ExploreResultPage | null>(null);
const [selectedId, setSelectedId] = useState("");
const [cursor, setCursor] = useState<string>();
Expand All @@ -48,17 +48,17 @@ export function GoalExploreResults({goalId, zh, active = true, readSourceUrl}: {
const loadedQuery = useRef("");
useEffect(() => {
if (!active) return;
const queryKey = JSON.stringify([goalId, readSourceUrl, cursor, revision]);
const queryKey = JSON.stringify([goalId, originalGoalId, readSourceUrl, cursor, revision]);
if (loadedQuery.current === queryKey) return;
let current = true;
setBusy(true); setError(false); setPage(null);
void fetchExploreResults(goalId, cursor, readSourceUrl).then(next => {
void fetchExploreResults(goalId, cursor, readSourceUrl, originalGoalId).then(next => {
if (!current) return;
loadedQuery.current = queryKey;
setPage(next); setSelectedId(id => next.items.some(item => item.finding_id === id) ? id : next.items[0]?.finding_id ?? "");
}, () => {if (current) setError(true);}).finally(() => {if (current) setBusy(false);});
return () => {current = false;};
}, [goalId, cursor, revision, active, readSourceUrl]);
}, [goalId, originalGoalId, cursor, revision, active, readSourceUrl]);
const selected = page?.items.find(item => item.finding_id === selectedId);
const sections = selected ? parseExploreFindingSections(selected.summary) : null;
const findingCopy = exploreFindingCopy(zh);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,19 @@ const viewFromUrl = (goal: string) => {

/** An effect-free history consumer. Its source is Explore, not raw sessions or
* a judge. All recorded updates survive; unsupported analysis stays unavailable. */
export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, readSourceUrl}: {goalId: string; active: boolean; zh: boolean; refreshRevision?: number; readSourceUrl?: string}) {
export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHistory = false, active, zh, refreshRevision = 0, readSourceUrl}: {goalId: string; originalGoalId?: string; preferHistory?: boolean; active: boolean; zh: boolean; refreshRevision?: number; readSourceUrl?: string}) {
const analysis = useResearchAnalysis(goalId, active, zh, readSourceUrl);
const readCaseMode = () => {
const query = new URLSearchParams(window.location.search);
return query.get("research_goal") !== goalId || query.get("research_reading") !== "history";
if (query.get("research_goal") === goalId && query.has("research_reading")) return query.get("research_reading") === "case";
return !preferHistory;
};
const [caseReading, setCaseReading] = useState(readCaseMode);
useEffect(() => {
const changed = () => setCaseReading(readCaseMode());
changed(); window.addEventListener("popstate", changed);
return () => window.removeEventListener("popstate", changed);
}, [goalId, readSourceUrl]);
}, [goalId, preferHistory, readSourceUrl]);
const setCaseMode = (reading: boolean) => {
const url = new URL(window.location.href);
url.searchParams.set("research_goal", goalId);
Expand All @@ -79,16 +80,16 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
useEffect(() => {
if (!read.complete || !display || !displayHashSupported) return;
let current = true;
const bytes = new TextEncoder().encode(trajectoryDisplaySnapshotText(read.events, goalId, displayNamespace));
const bytes = new TextEncoder().encode(trajectoryDisplaySnapshotText(read.events, originalGoalId, displayNamespace));
void crypto.subtle.digest("SHA-256", bytes).then(hash => {
if (current) setDisplayIdentity({events: read.events, namespace: displayNamespace,
key: Array.from(new Uint8Array(hash), value => value.toString(16).padStart(2, "0")).join("")});
}).catch(() => {if (current) setDisplayIdentity({events: read.events, namespace: displayNamespace, key: ""});});
return () => {current = false;};
}, [read, goalId, displayNamespace, display?.snapshot_key, displayHashSupported]);
}, [read, originalGoalId, displayNamespace, display?.snapshot_key, displayHashSupported]);
const displayKey = displayIdentity?.events === read.events && displayIdentity.namespace === displayNamespace ? displayIdentity.key : "";
const displayCheckFailed = !displayHashSupported || (displayIdentity?.events === read.events && displayIdentity.namespace === displayNamespace && !displayIdentity.key);
const currentDisplay = analysis.pack?.applicability === "current" && analysis.pack.scope.goal_id === goalId
const currentDisplay = analysis.pack?.applicability === "current" && analysis.pack.scope.goal_id === originalGoalId
&& analysis.pack.result?.analysis_key === analysis.pack.analysis_key && analysis.pack.result.method_version === analysis.pack.method_version
&& JSON.stringify(analysis.pack.result.scope) === JSON.stringify(analysis.pack.scope)
&& display?.snapshot_key === displayKey && display.source_namespace === displayNamespace && display.language === (zh ? "zh" : "en") ? display : undefined;
Expand Down Expand Up @@ -198,7 +199,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
let cursor = "";
do {
const query = new URLSearchParams({goal_id: goalId, view: "history", ...(cursor ? {cursor} : {})});
const page = parseExploreHistoryPage(await requestJson<unknown>(sourceReadApiUrl(readSourceUrl, `/api/chat/explore-results?${query}`), {signal: controller.signal}), goalId);
const page = parseExploreHistoryPage(await requestJson<unknown>(sourceReadApiUrl(readSourceUrl, `/api/chat/explore-results?${query}`), {signal: controller.signal}), originalGoalId);
if (total !== -1 && total !== page.total) throw new Error("Explore history snapshot changed");
total = page.total; events.push(...page.items);
if (events.length > total || (page.next_cursor && (!page.items.length || cursors.has(page.next_cursor)))) throw new Error("Invalid Explore history pagination");
Expand All @@ -209,7 +210,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
} while (cursor && current);
if (current) {
if (cursor || events.length !== total) throw new Error("Explore history coverage mismatch");
projectExploreHistory(events, goalId); // Verify replay identities before replacing a valid snapshot.
projectExploreHistory(events, originalGoalId); // Verify replay identities before replacing a valid snapshot.
setRead({events, total, complete: true});
}
} catch (failure) {
Expand All @@ -220,14 +221,14 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
} finally {if (current) setBusy(false);}
})();
return () => {current = false; controller.abort();};
}, [goalId, active, revision, refreshRevision, readSourceUrl]);
}, [goalId, originalGoalId, active, revision, refreshRevision, readSourceUrl]);
const history = useMemo(() => {
try {
const canonical = projectExploreHistory(read.events, goalId);
return {canonical, value: displayProjection.events === read.events ? canonical : projectExploreHistory(displayProjection.events, goalId), error: ""};
const canonical = projectExploreHistory(read.events, originalGoalId);
return {canonical, value: displayProjection.events === read.events ? canonical : projectExploreHistory(displayProjection.events, originalGoalId), error: ""};
}
catch (failure) {return {canonical: null, value: null, error: String(failure)};}
}, [read.events, displayProjection, goalId]);
}, [read.events, displayProjection, originalGoalId]);
const value = history.value;
const measurementFeedback = value?.measurementFeedback ?? [];
const measurementSummary = (nodeId: string) => {
Expand Down Expand Up @@ -629,7 +630,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
{value?.unknownKindCount ? <p className="research-history-note">{zh ? "未识别类型保留于已读记录" : "Unrecognized types remain in loaded records"} · {value.unknownKindCount}</p> : null}
</ContextHelp> : null}
{value?.events.length ? <details ref={records} className="research-history-all-records" open={recordsOpen} onToggle={event => setRecordsOpen(event.currentTarget.open)}><summary>{zh ? "全部已读记录" : "All loaded records"} · {value.events.length}</summary>
{recordsOpen ? <ResearchRecordSearch key={JSON.stringify([goalId, readSourceUrl])} goalId={goalId} events={history.canonical?.events ?? []}
{recordsOpen ? <ResearchRecordSearch key={JSON.stringify([goalId, readSourceUrl])} goalId={originalGoalId} events={history.canonical?.events ?? []}
displayEvents={currentDisplay && !originalDisplay ? value.events : undefined}
displaySections={currentDisplay && !originalDisplay ? displayProjection.sections : undefined}
renderBody={findingBody}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import './research-case-study.css';

type ResearchGoal = Pick<WorkspaceGoal, 'goalId' | 'parentGoalId' | 'title' | 'sourceAccess'>;

/** Parentage organizes research within a source; it never combines evidence or execution scope. */
/** Parentage organizes explicit source views; it never combines evidence or execution scope. */
export function ResearchGoalNavigation({goalId, goals, zh, onSelect}: {
goalId: string; goals: ResearchGoal[]; zh: boolean; onSelect: (goalId: string) => void;
}) {
const selected = goals.find(g => g.goalId === goalId);
if (!selected) return null;
const tree = goalHierarchy(goals.filter(g => g.sourceAccess?.namespace === selected.sourceAccess?.namespace));
// The directory owns these explicit presentation links. Different source
// namespaces remain isolated by each destination's read transport.
const tree = goalHierarchy(goals);
const parentId = tree.parents.get(goalId), parent = parentId ? tree.byId.get(parentId) : undefined;
const children = tree.children.get(goalId) ?? [];
if (!parent && !children.length) return null;
Expand All @@ -26,13 +28,15 @@ export function ResearchGoalNavigation({goalId, goals, zh, onSelect}: {

/** 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}) {
const source = props.goals?.find(goal => goal.goalId === props.goalId)?.sourceAccess;
const [evidenceOpen, setEvidenceOpen] = useState(false);
return <div className="goal-research-workbench">
{props.goals && props.onSelectGoal ? <ResearchGoalNavigation goalId={props.goalId} goals={props.goals} zh={props.zh} onSelect={props.onSelectGoal}/> : null}
<GoalResearchHistory {...props}/>
<GoalResearchHistory {...props} originalGoalId={source?.original_goal_id}
preferHistory={Boolean(source)}/>
<details className="research-saved-evidence" open={evidenceOpen} onToggle={event => setEvidenceOpen(event.currentTarget.open)}>
<summary>{props.zh ? "阅读已保存的结论" : "Read saved findings"}</summary>
{evidenceOpen ? <GoalExploreResults goalId={props.goalId} zh={props.zh} active={props.active} readSourceUrl={props.readSourceUrl}/> : null}
{evidenceOpen ? <GoalExploreResults originalGoalId={source?.original_goal_id} goalId={props.goalId} zh={props.zh} active={props.active} readSourceUrl={props.readSourceUrl}/> : null}
</details>
</div>;
}
Loading
Loading