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
9 changes: 9 additions & 0 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ jobs:
npm ci --ignore-scripts
npm run typecheck:control-plane
npm run test:control-plane:coverage
npm run test:dashboard:coverage

- name: Upload TypeScript control-plane coverage
uses: actions/upload-artifact@v7
Expand All @@ -97,6 +98,14 @@ jobs:
if-no-files-found: error
retention-days: 3

- name: Upload dashboard coverage
uses: actions/upload-artifact@v7
with:
name: dashboard-coverage
path: coverage/dashboard/lcov.info
if-no-files-found: error
retention-days: 3

- name: Lint test suite
run: >-
python -m ruff check
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/sonarcloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ jobs:
name: typescript-control-plane-coverage
path: coverage/control-plane

- name: Download this run's dashboard coverage
if: steps.sonar-token.outputs.available == 'true'
uses: actions/download-artifact@v7
with:
name: dashboard-coverage
path: coverage/dashboard

- name: SonarCloud scan
if: steps.sonar-token.outputs.available == 'true'
# Analysis-only: findings are reported to the PR/dashboard but never
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ assert.equal(agentFamily("codexplorer"), "codexplorer");
assert.equal(agentFamily("claudeflow"), "claudeflow");

// The typed adapter kind wins over the operator-chosen id when present, and the
// status projection placeholder is not treated as a family signal.
// generic transport/projection placeholders are not treated as family signals.
assert.equal(presentedAgentFamily("kiroscope-worker", "kiro-cli"), "kiro");
assert.equal(presentedAgentFamily("kiroscope-worker", null), "kiroscope-worker");
assert.equal(presentedAgentFamily("kiroscope-worker", ""), "kiroscope-worker");
assert.equal(
presentedAgentFamily("kiroscope-worker", "status_projection"),
"kiroscope-worker",
);
assert.equal(presentedAgentFamily("kiro-cli", "acp"), "kiro");
assert.equal(presentedAgentFamily("custom-worker", "acp"), "custom-worker");

console.log("Agent family presentation invariants passed");
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
* kept it independent, which is wrong owner attribution wherever the label is
* rendered — diagnostics, run timelines, evidence and report cards.
*
* A capability row's typed `display_name` / `adapter_kind` stays authoritative;
* this module is the fallback used when no row is in scope.
* A capability row's typed `display_name` stays authoritative. Provider-shaped
* adapter kinds can identify a family, while generic transport/projection kinds
* cannot; this module falls back to the agent id for those rows.
*/

export const AGENT_FAMILY_ROOTS = [
Expand All @@ -23,6 +24,8 @@ export const AGENT_FAMILY_ROOTS = [

export type AgentFamilyRoot = (typeof AGENT_FAMILY_ROOTS)[number];

const GENERIC_ADAPTER_KINDS = new Set(["acp", "status_projection"]);

/** Return the bounded family root for an id, or the normalized id itself. */
export function agentFamily(agentId: string): string {
const token = agentId.trim().toLowerCase().replace(/_/gu, "-");
Expand All @@ -36,14 +39,15 @@ export function agentFamily(agentId: string): string {

/**
* Resolve the family a capability row should be presented as, preferring the
* typed adapter kind over the operator-chosen id when the row carries one.
* typed provider adapter kind over the operator-chosen id when the row carries
* one. Generic transport/projection kinds are not provider identities.
*/
export function presentedAgentFamily(
agentId: string,
adapterKind?: string | null,
): string {
const typed = adapterKind?.trim().toLowerCase() ?? "";
if (typed.length > 0 && typed !== "status_projection") {
if (typed.length > 0 && !GENERIC_ADAPTER_KINDS.has(typed)) {
return agentFamily(typed);
}
return agentFamily(agentId);
Expand Down
80 changes: 77 additions & 3 deletions examples/loopx-chat-acp-adapter-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@
elif method == "session/prompt":
active_prompt_id = request_id
prompt = request["params"]["prompt"][0]["text"]
if "verify execution mode" in prompt:
assert "execution agent for a confirmed LoopX Task" in prompt, prompt
assert "planning agent inside LoopX Chat" not in prompt, prompt
assert "Do not edit files" not in prompt, prompt
if "verify planning mode" in prompt:
assert "planning agent inside LoopX Chat" in prompt, prompt
assert "execution agent for a confirmed LoopX Task" not in prompt, prompt
assert "Do not edit files" in prompt, prompt
if "wait for cancel" in prompt:
continue
if "activity renew" in prompt:
Expand Down Expand Up @@ -206,13 +214,14 @@ def main() -> None:
work_dir=root,
objective="Exercise the ACP route.",
mode="resume_latest",
channel_id="task.fixture-acp",
)
assert was_resumed is False
assert session["upstream_thread_id"] == "acp:fixture/session"
turn, created = first.submit_turn(
session_id=str(session["session_id"]),
client_turn_id="fixture-turn",
message="检查状态",
message="verify execution mode",
work_dir=root,
objective="Exercise the ACP route.",
)
Expand All @@ -235,6 +244,7 @@ def main() -> None:
work_dir=root,
objective="Exercise the ACP route.",
mode="resume_latest",
channel_id="task.fixture-acp",
)
assert was_resumed is True
assert restored["session_id"] == session["session_id"]
Expand All @@ -260,20 +270,21 @@ def main() -> None:
assert row["adapter_kind"] == "acp", row
assert row["display_name"] == "Kiro CLI", row
assert row["available"] is True, row
assert row["trust_scope"] == "read_only", row
assert row["trust_scope"] == "workspace_write", row
kiro_session, kiro_resumed = kiro.open_session(
goal_id="fixture-goal",
agent_id=KIRO_CLI_CHAT_AGENT_ID,
work_dir=root,
objective="Exercise the built-in Kiro CLI ACP route.",
mode="resume_latest",
channel_id="task.fixture-task",
)
assert kiro_resumed is False
assert kiro_session["upstream_thread_id"] == "acp:fixture/session"
kiro_turn, kiro_created = kiro.submit_turn(
session_id=str(kiro_session["session_id"]),
client_turn_id="kiro-turn",
message="检查状态",
message="verify execution mode",
work_dir=root,
objective="Exercise the built-in Kiro CLI ACP route.",
)
Expand All @@ -286,6 +297,69 @@ def main() -> None:
assert kiro_completed["status"] == "completed", kiro_completed
kiro.close()

# Persisted task channels must retain execution mode after process
# recovery; otherwise the first turn can execute while the next one
# silently falls back to planning-only instructions.
kiro_resumed_controller = ChatRuntimeController(
store=kiro_store,
codex_bin="missing-codex-for-fixture",
kiro_cli_bin=str(fake),
)
resumed_session, was_resumed = kiro_resumed_controller.open_session(
goal_id="fixture-goal",
agent_id=KIRO_CLI_CHAT_AGENT_ID,
work_dir=root,
objective="Exercise the resumed Kiro CLI ACP route.",
mode="resume_latest",
channel_id="task.fixture-task",
)
assert was_resumed is True
resumed_turn, created = kiro_resumed_controller.submit_turn(
session_id=str(resumed_session["session_id"]),
client_turn_id="kiro-resumed-turn",
message="verify execution mode",
work_dir=root,
objective="Exercise the resumed Kiro CLI ACP route.",
)
assert created is True
resumed_completed = kiro_resumed_controller.wait_for_turn(
session_id=str(resumed_session["session_id"]),
turn_id=str(resumed_turn["turn_id"]),
timeout_sec=3,
)
assert resumed_completed["status"] == "completed", resumed_completed
kiro_resumed_controller.close()

# Goal/manager chat remains planning-only: task execution authority must
# not leak into ordinary conversation through the shared ACP adapter.
planning = ChatRuntimeController(
store=ChatSessionStore(root / "kiro-planning"),
codex_bin="missing-codex-for-fixture",
kiro_cli_bin=str(fake),
)
planning_session, _ = planning.open_session(
goal_id="fixture-goal",
agent_id=KIRO_CLI_CHAT_AGENT_ID,
work_dir=root,
objective="Exercise the planning-only Kiro CLI ACP route.",
mode="new",
)
planning_turn, created = planning.submit_turn(
session_id=str(planning_session["session_id"]),
client_turn_id="kiro-planning-turn",
message="verify planning mode",
work_dir=root,
objective="Exercise the planning-only Kiro CLI ACP route.",
)
assert created is True
planning_completed = planning.wait_for_turn(
session_id=str(planning_session["session_id"]),
turn_id=str(planning_turn["turn_id"]),
timeout_sec=3,
)
assert planning_completed["status"] == "completed", planning_completed
planning.close()

# A built-in id must not be claimable by an owner-local endpoint, or the
# registry row would silently shadow the built-in adapter.
try:
Expand Down
14 changes: 12 additions & 2 deletions loopx/chat_acp.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class ACPStdioAdapter:
work_dir: Path
agent_work_dir: Path
agent_capabilities: dict[str, Any]
execution_mode: bool = False
startup_timeout_sec: float = 30.0
idle_timeout_sec: float = 180.0
hard_timeout_sec: float = 900.0
Expand Down Expand Up @@ -86,6 +87,7 @@ def start(
startup_timeout_sec: float = 30.0,
idle_timeout_sec: float = 180.0,
hard_timeout_sec: float = 900.0,
execution_mode: bool = False,
) -> "ACPStdioAdapter":
if not command:
raise ValueError("ACP command is required")
Expand Down Expand Up @@ -122,6 +124,7 @@ def start(
work_dir=work_dir.expanduser().resolve(),
agent_work_dir=agent_work_dir or work_dir.expanduser().resolve(),
agent_capabilities={},
execution_mode=execution_mode,
startup_timeout_sec=startup_timeout_sec,
idle_timeout_sec=idle_timeout_sec,
hard_timeout_sec=hard_timeout_sec,
Expand Down Expand Up @@ -376,7 +379,15 @@ def on_activity() -> None:
"session/prompt",
{
"sessionId": self.session_id,
"prompt": [{"type": "text", "text": _turn_prompt(message)}],
"prompt": [
{
"type": "text",
"text": _turn_prompt(
message,
execution_mode=self.execution_mode,
),
}
],
},
request_id=request_id,
timeout_sec=self.hard_timeout_sec,
Expand All @@ -386,7 +397,6 @@ def on_activity() -> None:
)
except TimeoutError as exc:
elapsed = time.monotonic() - started_at
idle = time.monotonic() - last_activity_at
error_code = "hard_timeout" if elapsed >= self.hard_timeout_sec else "idle_timeout"
summary = (
"ACP Chat turn reached its hard time limit."
Expand Down
7 changes: 6 additions & 1 deletion loopx/chat_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,10 @@ def capabilities(self) -> list[dict[str, Any]]:
"resume": True,
"interrupt": True,
"tool_calls": True,
"trust_scope": "read_only",
# Kiro owns its persistent permission rules. LoopX cancels
# interactive ACP permission requests, but cannot turn an
# existing host-level `allow` rule into a read-only sandbox.
"trust_scope": "workspace_write",
"source": "builtin",
},
{
Expand Down Expand Up @@ -397,6 +400,7 @@ def _start_adapter(
startup_timeout_sec=self.startup_timeout_sec,
idle_timeout_sec=self.idle_timeout_sec,
hard_timeout_sec=self.hard_timeout_sec,
execution_mode=execution_mode,
)
endpoint = self.endpoint_registry.get(agent_id)
if endpoint is not None:
Expand All @@ -408,6 +412,7 @@ def _start_adapter(
startup_timeout_sec=self.startup_timeout_sec,
idle_timeout_sec=self.idle_timeout_sec,
hard_timeout_sec=self.hard_timeout_sec,
execution_mode=execution_mode,
)
raise ValueError(f"unknown Agent endpoint: {agent_id}")

Expand Down
13 changes: 8 additions & 5 deletions loopx/kiro_cli_goal_mode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,14 @@ loopx dashboard --kiro-cli-bin /path/to/kiro-cli # explicit executable

Two boundaries this does **not** cross:

- **Read-only by refusal.** LoopX Chat answers every ACP
`session/request_permission` with `cancelled` and exposes no client host
tools, so a Kiro tool call that needs approval is refused rather than
auto-approved. The launch argv carries no `--trust-all-tools`; adding it
would move that decision out of the owner's hands.
- **Owner-managed host permissions.** LoopX Chat answers every interactive ACP
`session/request_permission` with `cancelled`, exposes no client host tools,
and launches without `--trust-all-tools`. Kiro can still execute a tool
without asking when its user, workspace, or agent permission rules already
say `allow`; LoopX cannot turn those persistent host rules into a read-only
sandbox. The capability therefore advertises `workspace_write`, and the
owner must configure Kiro permissions for the desired boundary. A planning
prompt or a cancelled request is not an authority gate.
- **Not the governed loop.** A dashboard Chat session is one bounded
conversation. The `/goal` loop above is entered from a Kiro CLI session
through the installed skill facade; the two surfaces share the host, not the
Expand Down
129 changes: 129 additions & 0 deletions loopx/web/chat/assets/index-Bs_ZC86m.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion loopx/web/chat/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
content="LoopX 个人 Agent 工作区:在同一个频道里查看、纠偏并推进 Goal。"
/>
<title>LoopX 个人 Agent 工作区</title>
<script type="module" crossorigin src="/chat/assets/index-BFBuCPSV.js"></script>
<script type="module" crossorigin src="/chat/assets/index-Bs_ZC86m.js"></script>
<link rel="stylesheet" crossorigin href="/chat/assets/index-1ek48Zfo.css">
</head>
<body>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"scripts": {
"test:control-plane": "node --no-warnings --experimental-strip-types --test tests/control_plane_ts/*.test.ts",
"test:control-plane:coverage": "c8 --all --include=loopx/control_plane/**/*.ts --exclude=loopx/control_plane/**/*.generated.ts --reporter=lcov --reporter=text --reports-dir=coverage/control-plane node --no-warnings --experimental-strip-types --test tests/control_plane_ts/*.test.ts",
"test:dashboard:coverage": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --sourceMap --inlineSources --outDir apps/presentation/dashboard/node_modules/.cache/loopx-agent-family apps/presentation/dashboard/src/features/personal-workspace/agent-family.ts && c8 --exclude-node-modules=false --include=apps/presentation/dashboard/node_modules/.cache/loopx-agent-family/agent-family.js --reporter=lcov --reporter=text --reports-dir=coverage/dashboard node apps/presentation/dashboard/src/features/personal-workspace/agent-family.test.mjs",
"test:postgresql-authority-store": "node --no-warnings --experimental-strip-types --test tests/control_plane_ts/postgresql_authority_store.integration.test.ts",
"typecheck:control-plane": "tsc --project tsconfig.control-plane.json --noEmit"
},
Expand Down
5 changes: 3 additions & 2 deletions sonar-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ sonar.sourceEncoding=UTF-8
sonar.sources=loopx,apps,scripts
sonar.tests=tests
sonar.exclusions=**/node_modules/**,**/dist/**,**/build/**,**/src-tauri/**,loopx/web/chat/assets/**,**/*.min.js,deprecate/**
sonar.coverage.exclusions=apps/**/*.test.*,apps/**/smoke/**

# Python
sonar.python.version=3.11
sonar.python.coverage.reportPaths=coverage.xml

# TypeScript control plane
sonar.javascript.lcov.reportPaths=coverage/control-plane/lcov.info
# TypeScript/JavaScript product coverage
sonar.javascript.lcov.reportPaths=coverage/control-plane/lcov.info,coverage/dashboard/lcov.info

# Non-blocking by default: report findings, never gate the workflow.
sonar.qualitygate.wait=false
11 changes: 7 additions & 4 deletions tests/test_kiro_cli_host_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,8 +574,10 @@ def test_dashboard_lists_kiro_cli_as_a_builtin_chat_agent(tmp_path: Path) -> Non
"""`loopx dashboard` renders its Agent picker from the running process's
capability rows, so a host is only reachable there if it appears as a row
with an adapter the runtime can actually start. Kiro CLI ships an ACP agent,
so the row must be built-in, ACP-shaped, and read-only — and its id must be
reserved, or an owner-local endpoint could shadow the built-in adapter."""
so the row must be built-in and ACP-shaped — and its id must be reserved,
or an owner-local endpoint could shadow the built-in adapter. Kiro owns
persistent permission rules, so LoopX must not falsely advertise a
read-only sandbox that it cannot enforce."""
controller = ChatRuntimeController(
store=ChatSessionStore(tmp_path / "runtime"),
codex_bin="loopx-missing-codex-for-test",
Expand All @@ -589,13 +591,14 @@ def test_dashboard_lists_kiro_cli_as_a_builtin_chat_agent(tmp_path: Path) -> Non
assert row["source"] == "builtin"
assert row["adapter_kind"] == "acp"
assert row["display_name"] == "Kiro CLI"
assert row["trust_scope"] == "read_only"
assert row["trust_scope"] == "workspace_write"
assert row["available"] is True
finally:
controller.close()

# The launch argv must not auto-approve tools: LoopX Chat cancels every ACP
# permission request, and a trust flag here would bypass that decision.
# permission request. Pre-existing Kiro allow rules remain host-owned, which
# is why the capability row conservatively declares workspace_write.
command = kiro_cli_chat_command("kiro-cli")
assert command == ("kiro-cli", "acp")
assert not any("trust" in argument for argument in command)
Expand Down