Skip to content
Open
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
@@ -1,12 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { listTypedActions } from "./chat";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { listTypedActions, type TypedActionProposal } from "./chat";

/** Visible workspace readback only: no confirmation, dispatch or effect owner.
* Query keys fence scope changes; React Query serializes same-key requests,
* cancels superseded reads and suspends background interval polling. */
export function useTypedActionReadback(readOnly: boolean, goalId: string | null | undefined) {
return useQuery({
queryKey: ["typed-action-readback", goalId ?? "manager"],
const client = useQueryClient();
const queryKey = ["typed-action-readback", goalId ?? "manager"];
const query = useQuery({
queryKey,
queryFn: ({ signal }) => listTypedActions(goalId ? { goalId } : {},
AbortSignal.any([signal, AbortSignal.timeout(10_000)])),
enabled: !readOnly,
Expand All @@ -15,4 +17,18 @@ export function useTypedActionReadback(readOnly: boolean, goalId: string | null
refetchOnWindowFocus: "always",
retry: false,
});
return {
...query,
async acceptProposal(proposal: TypedActionProposal, replacedId?: string) {
if (readOnly) return;
// The validated mutation response is already canonical readback. Fence
// older reads before publishing it, so a cached preview or delayed poll
// cannot replace an acknowledged receipt during a Goal status refresh.
await client.cancelQueries({ queryKey, exact: true });
client.setQueryData<TypedActionProposal[]>(queryKey, current => current
? [proposal, ...current.filter(row => row.proposal_id !== proposal.proposal_id
&& row.proposal_id !== replacedId)]
: current);
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -1481,6 +1481,7 @@ export function PersonalWorkspacePage({
))) {
throw new ChatApiError(t("actionReview.targetChanged"), { error_code: "action_response_mismatch" });
}
await actionReadback.acceptProposal(result.proposal);
const applied = workspaceProposal(result.proposal, t);
setProposals((current) => ({ ...current, [proposal.previewId]: applied }));
if (showDrawer) setSelection({ item: applied, kind: "proposal" });
Expand Down Expand Up @@ -1596,14 +1597,18 @@ export function PersonalWorkspacePage({
});
try {
callbacks.onCancelProposal?.(proposal);
if (!callbacks.onCancelProposal) await cancelTypedAction(proposal.previewId);
if (!callbacks.onCancelProposal) {
await actionReadback.acceptProposal(await cancelTypedAction(proposal.previewId));
}
} catch (error) {
setProposals((current) => ({ ...current, [proposal.previewId]: proposal }));
setActionFeedback(t("feedback.cancelFailed", { error: error instanceof Error ? error.message : String(error) }));
}
},
onTransitionProposal: async (proposal, transition) => {
const transitioned = workspaceProposal(await transitionTypedAction(proposal.previewId, transition), t);
const result = await transitionTypedAction(proposal.previewId, transition);
await actionReadback.acceptProposal(result, transition === "regenerate" ? proposal.previewId : undefined);
const transitioned = workspaceProposal(result, t);
const managerOwned = managerSessionProposalIds.includes(proposal.previewId)
|| managerChannelProposalIds.includes(proposal.previewId);
rememberSessionProposal(transitioned.previewId, managerOwned ? null : proposal.goalId ?? selectedGoalId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ inventory-only row for the same provider id.

## Product surfaces

- CLI: `external-evidence discover|plan|receipt|admit|retire`.
- Managed Turn: the same five effect-runtime methods.
- Frontend/Lark: not changed in this Core slice. A companion slice should render
the same typed plan/admission projection and readback; it must not invent a
second registry or lifecycle.
- CLI: `external-evidence discover|plan|execute|receipt|admit|readback|retire`.
- Managed Turn: the same five typed effect-runtime methods; explicit provider
execution and ledger projection use the capability's CLI owner.
- Frontend/Lark: existing conversation answer/report and Markdown transports
render the shared validated readback. No independent registry or lifecycle.

## Acceptance

Expand All @@ -102,6 +102,29 @@ inventory-only row for the same provider id.
- retirement waits for downstream coverage of every admitted source;
- CLI and effect-runtime TypeScript tests pass from the source checkout.

## Delivery checkpoint (2026-10-02)

The public GitHub method now completes a bounded real journey: anonymous pinned
file reads, exact-plan receipt validation, a separate parent decision, projection
into the existing deepresearch source ledger, actual lineage readback and retirement.
Optional source refs and literal search terms are bound into the request/plan digest;
legacy requests retain their existing identity. The provider is bundled in extensions
under `method:public-github`; capability and ledger owners remain unchanged.

Passed: real public-provider/source CLI journey; negative cases for private or stale
readiness, malformed/unpinned sources, plan/admission mutation, partial/empty/failed
reads, independent admission and coverage, wrong-question projection, budget failure
and idempotent replay; packaged desktop/mobile conversation readback and reload;
existing Lark Markdown presentation. Source bodies are not persisted. The shared
Markdown readback uses existing answer/report and Lark transports; no frontend
configuration or parallel evidence authority is needed.

Commands are in the [versioned capability guide](../../../loopx/capabilities/external_research/README.md#public-github-method--公开-github-方法).
Live Lark delivery, authenticated connector execution and broader semantic research
quality remain untested; this checkpoint does not promote those providers or close
S6/S8. Failed or partial results preserve original-source fallback, and neither a
successful read nor a parent admission certifies evidence completeness.

## Non-goals

- a universal browser/search engine;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,11 @@ Connector registry 继续只拥有库存与遥测。`supported` 绝不映射为

## 产品入口

- CLI:`external-evidence discover|plan|receipt|admit|retire`;
- Managed Turn:复用同五个 effect-runtime 方法;
- Frontend/Lark:本 Core 切片不修改。后续 companion slice 只渲染同源 plan/admission
投影与读回,不建立第二个 registry 或生命周期。
- CLI:`external-evidence discover|plan|execute|receipt|admit|readback|retire`;
- Managed Turn:复用五个 typed effect-runtime 方法;显式 provider 执行与账本投影
使用能力的 CLI owner;
- Frontend/Lark:现有会话答复/报告和 Markdown 运输渲染同源校验回读,
不建立独立 registry 或生命周期。

## 验收

Expand All @@ -80,6 +81,24 @@ Connector registry 继续只拥有库存与遥测。`supported` 绝不映射为
- 全部被采纳来源完成下游覆盖前不得退休;
- CLI 与 effect-runtime TypeScript 测试在源码 checkout 中通过。

## 交付检查点(2026-10-02)

公开 GitHub method 已完成有界真实链路:匿名读取固定提交文件、精确 plan 回执校验、
独立父 Agent 决定、投影到现有 deepresearch 来源账本、实际 lineage 回读与退休。
可选 source refs 和字面检索词进入 request/plan digest;旧请求身份保持兼容。
provider 以 `method:public-github` 内置在 extensions,能力和账本 owner 不变。

通过:真实公开 provider/源码 CLI 链路;私有或过期 readiness、无效/未固定来源、
plan/admission 篡改、部分/空/失败读取、独立采纳与覆盖、问题不匹配、预算耗尽及
幂等重放等负向用例;打包桌面/移动会话回读与重载;现有 Lark Markdown 展示。
不持久化来源正文。同源 Markdown 沿用现有答复/报告和 Lark 运输路径,
无需新增前端配置或并行证据权威。

命令参见[版本化能力指南](../../../loopx/capabilities/external_research/README.md#public-github-method--公开-github-方法)。
真实 Lark 送达、带凭据 connector 执行和更广泛语义研究质量尚未验证;
该检查点不晋升这些 provider,也不关闭 S6/S8。失败或部分结果保留原始来源退路;
读取成功和父 Agent 采纳均不证明证据完整性。

## 非目标

- 通用浏览器或搜索引擎;
Expand Down
2 changes: 2 additions & 0 deletions examples/personal-workspace-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ scenarioCatalog.push(turnStepsScenario);
scenarioCatalog.push(goalWorkMapScenario);
scenarioCatalog.push(performanceDiagnosisScenario);
scenarioCatalog.push(blockedNoticeSettingsScenario);
import { externalEvidenceReadbackScenario } from "./personal-workspace-browser/external-evidence-readback.mjs";
scenarioCatalog.push(externalEvidenceReadbackScenario);
const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO;
const scenarios = requestedScenario
? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { resolveTestPython } from "../../scripts/test-python.mjs";
import { outputDir, repoRoot } from "./fixture.mjs";
import { openWorkspacePage } from "./scenario-context.mjs";

export const externalEvidenceReadbackScenario = {
id: "external-evidence-readback",
async run({browser, collectCoverage, url}) {
// Exercise the product's typed plan/admission and actual downstream ledger,
// then hand the resulting shared readback to the existing answer surface.
const result = spawnSync(resolveTestPython(), ["-X", "utf8", "-c", `
import tempfile
from pathlib import Path
from loopx.control_plane.effect_runtime import effect_runtime_result as effect
from loopx.capabilities.deep_research.runtime import start_research
from loopx.capabilities.external_research.projection import readback, render_readback
provider = {"provider_id":"method:public-github", "provider_kind":"method", "protocol":"external_evidence_research_v0", "declared":True, "installed":True, "enabled":True, "ready":True, "unavailable_reason":None}
ref = "https://github.com/example/public/blob/" + "a"*40 + "/README.md"
plan = effect("external_evidence.plan", {"request":{"objective":"Inspect public fixture", "user_activity":"Choose a source", "decision":"Whether to use the fixture", "evidence_kinds":["literal_match"], "source_refs":[ref]}, "providers":[provider]})
receipt = {"schema_version":"loopx_external_evidence_receipt_v0", "plan_id":plan["plan_id"], "request_id":plan["request"]["request_id"], "provider_id":provider["provider_id"], "provider_kind":"method", "status":"succeeded", "summary":"Read pinned public fixture", "completed_at":"2026-10-02T00:00:00Z", "sources":[{"source_ref":ref, "source_family":"github_repository_file", "basis":"observed", "finding":"Literal fixture marker observed at line 1", "content_digest":"sha256:"+"b"*64, "accessed_at":"2026-10-02T00:00:00Z", "limitation":"Retrieval only; completeness unverified"}]}
admission = effect("external_evidence.admit", {"plan":plan, "receipt":receipt, "decision":{"disposition":"admit", "reason":"Synthetic parent checked the direct source", "admitted_source_refs":[ref]}})
with tempfile.TemporaryDirectory(prefix="lxe-ui-") as folder:
project=Path(folder)
start_research(project, question=plan["request"]["objective"], max_sources=8, max_subquestions=4)
print(render_readback(readback(plan, receipt, admission, project=project, execute=True)))
`], {cwd:repoRoot, encoding:"utf8", env:{...process.env, PYTHONPATH:repoRoot}, timeout:45000});
assert.equal(result.status, 0, result.stderr);
const markdown = result.stdout;
const context = await openWorkspacePage(browser, url, {collectCoverage});
const {api, page} = context;
try {
api.answerForMessage = () => markdown;
await page.getByRole("navigation", {name:"管家视图"}).getByRole("button", {name:/^(Chat|对话)$/}).click();
await page.getByLabel("向 LoopX 发送消息").fill("Show the external evidence readback");
await page.getByRole("button", {name:"发送",exact:true}).click();
const answer = page.locator(".personal-channel-timeline .personal-message.is-assistant", {hasText:"External evidence"});
await answer.waitFor();
assert.match(await answer.innerText(), /retire_ready/u);
assert.match(await answer.innerText(), /Downstream coverage.*1 admitted/u);
assert.match(await answer.innerText(), /Evidence completeness is unverified/u);
await answer.scrollIntoViewIfNeeded();
await page.screenshot({path:resolve(outputDir,"external-evidence-desktop.png"),fullPage:false,animations:"disabled"});
await page.setViewportSize({width:390,height:844});
await answer.scrollIntoViewIfNeeded();
assert.equal(await answer.evaluate(el => el.scrollWidth <= el.clientWidth + 1), true);
await page.screenshot({path:resolve(outputDir,"external-evidence-mobile.png"),fullPage:false,animations:"disabled"});
await page.reload({waitUntil:"networkidle"});
await page.getByRole("navigation", {name:"管家视图"}).getByRole("button", {name:/^(Chat|对话)$/}).click();
await answer.waitFor();
assert.match(await answer.innerText(), /retire_ready/u);
assert.equal(context.errors.length, 0);
return {coverageEntries:context.coverageEntries, note:"Shared typed evidence readback survives conversation reload and fits desktop/mobile."};
} finally { await context.close(); }
},
};
30 changes: 29 additions & 1 deletion examples/personal-workspace-browser/steward-journey.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,22 @@ export const stewardJourneyScenario = {
animations: "disabled",
});

// Hold a read started before confirmation. A later status refresh must
// not replace the acknowledged result with the cached preview, and the
// delayed preview must not reopen confirmation when its response lands.
let releaseReadback;
const heldReadback = new Promise(resolveRead => { releaseReadback = resolveRead; });
const pendingRead = page.waitForRequest(request => request.method() === "GET"
&& new URL(request.url()).pathname === "/api/actions", { timeout: 10_000 });
const actionListRoute = /\/api\/actions(?:\?.*)?$/;
const holdReadback = async route => {
await heldReadback;
await route.fulfill({ contentType: "application/json", status: 200,
json: { ok: true, schema_version: "loopx_chat_action_list_v1", proposals: [teamPlanProposal()] } });
};
await page.route(actionListRoute, holdReadback);
await pendingRead;

// Beat 3: confirm, and record what the workspace actually reports after
// the canonical owner ran.
const confirm = drawer.getByRole("button", { name: "确认分配", exact: true });
Expand All @@ -269,16 +285,28 @@ export const stewardJourneyScenario = {
check(api.durableWriteCount === 1, "the confirmed apply performed exactly one durable write");
const applied = drawer.getByRole("heading", { name: "已分配 1 项,1 项待安排", exact: true });
await applied.waitFor({ state: "visible", timeout: 15_000 });
const outcomeText = await applied.innerText();
const resultText = await drawer.locator(".personal-team-plan-result").innerText();
const assignmentVisible = resultText.includes("agent-backend") && resultText.includes(READY_TODO);
const gapVisible = resultText.includes("agent-reviewer") && resultText.includes(GAP_TODO)
&& resultText.includes("待安排 · 尚未加入此目标");
check(assignmentVisible && gapVisible, "the result names assigned work and pending work with its reason");
check(await confirm.count() === 0, "the completed result removes its confirmation control");
await page.getByRole("button", { name: "刷新状态", exact: true }).click();
await page.getByText("刚刚更新", { exact: true }).waitFor({ state: "visible" });
check(await applied.count() === 1 && await confirm.count() === 0,
"a Goal status refresh retains the acknowledged assignment result");
releaseReadback();
await page.unroute(actionListRoute, holdReadback);
await page.waitForTimeout(200);
check(await applied.count() === 1 && await confirm.count() === 0,
"a read started before apply cannot reopen assignment confirmation");
check(api.actionApplies.length === 1 && api.durableWriteCount === 1,
"readback and status refresh never reapply the confirmed assignment");
record("3-confirm", {
applies: api.actionApplies.length,
durable_writes: api.durableWriteCount,
outcome_text: await applied.innerText(),
outcome_text: outcomeText,
outcome_fidelity: "assigned task and pending task with reason; execution remains unverified",
});
gaps.push({
Expand Down
Loading
Loading