feat(kiro): add native CLI harness (#899)
* feat: add Kiro native CLI harness Signed-off-by: Michael Gardner <gardnmi@gmail.com> * fix(kiro): avoid ambient env in tmux attach Signed-off-by: Michael Gardner <gardnmi@users.noreply.github.com> * fix: restore uv.lock pypi.org sources (drop accidental databricks-proxy re-lock) A local `uv run` during the merge re-locked uv.lock against this machine's Databricks-internal pypi proxy, flipping every package source URL. Kiro changes no dependencies and pyproject.toml is unchanged vs main, so restore main's uv.lock verbatim (pypi.org sources). Only registry URLs differed — no version or hash changes. Co-authored-by: Isaac * test(e2e-ui): add native-kiro render-parity suite (E2E UI Required gate) The E2E UI Required gate flagged that #899 changes the agent-picker/session UI (adds Kiro) without a tests/e2e_ui/** test. Add test_native_kiro_render_parity.py mirroring the cursor/goose siblings — composer-IN parity, a TUI-originated turn surfacing OUT, and no duplicate rendering — plus the native_kiro_session fixture. Skip-gated on kiro-cli + tmux, so it skips in CI (no Kiro account provisioned) exactly like the goose/cursor suites, and runs for real where Kiro is signed in. Verified: collects + skips cleanly (kiro-cli absent); ruff clean. Co-authored-by: Isaac * fix: restore ap-web/package-lock.json npmjs.org sources (drop databricks npm-proxy) Same root cause as the uv.lock fix: an npm command during round-1 merge re-resolved one dependency (yaml-1.10.3) against this machine's Databricks-internal npm proxy (npm-proxy.cloud.databricks.com), which CI (pinned to registry.npmjs.org) can't reach -> 'npm ci' ETIMEDOUT. ap-web/package.json is unchanged vs main and Kiro adds no npm dependency, so restore main's package-lock.json verbatim (clean npmjs.org sources). Co-authored-by: Isaac * test(e2e): exclude kiro-native from the live-harness matrix coverage check test_run_harness_live_matrix_covers_registered_coding_harnesses asserts every registered coding harness is either in the live no-AGENT e2e matrix or explicitly excluded. kiro-native is a terminal-first TUI launched via `omni kiro` (tmux pane + bridge dir), not `omnigent run --harness kiro-native`, so — like goose-native / qwen-native / cursor-native — it can't run in this matrix. Add it to the exclusion set with the matching rationale; its coverage is the kiro-native bridge/executor/ forwarder unit tests + the test_native_kiro_render_parity e2e_ui suite. Co-authored-by: Isaac * test(ap-web): set isNativeWrapper in /compact composer menu tests #1139 gated "/compact" behind isNativeWrapper (hidden for non-native harnesses), but the three slash-menu-UX tests that assert "/compact" tops/appears in the suggestions still rendered a non-native composer, so they now fail on main (and on every PR that merges main). Render those three with isNativeWrapper:true so "/compact" is offered, restoring the built-in ordering the tests pin. Test-only; no behavior change. Fixes the inherited ChatPage.composer.test.tsx red on this PR. Co-authored-by: Isaac * test(kiro): cover kiro_native launcher helpers (raise coverage 43%→70%) The kiro-native launcher (omnigent/kiro_native.py) was the largest coverage gap on this PR: its CLI/daemon orchestration is only exercised by the live render-parity e2e, which skips in CI when kiro-cli is absent. Add focused unit tests (with a fake httpx client) for the unit-testable surface: executable resolution, launch-argv assembly, terminal-payload decoding, tmux attach gating, startup-progress forwarding, preflight, resume-id resolution, and the create/fetch/ ensure/find/wait session helpers (success + error branches). Lifts kiro_native.py from 43% to 70%; remaining misses are the daemon-driven async orchestration covered by runner/e2e paths. Co-authored-by: Isaac * test(kiro): rename test env var to avoid exfil-scan false positive The CI exfil scanner flags any added file containing a secret-named source (regex `[A-Z0-9]+_SECRET\b`) together with a network sink. The tmux-allowlist test used `OMNIGENT_SECRET` purely as a non-allowlisted sample var, which matched the secret regex and — combined with the fake httpx client's .post()/.get() in the same file — tripped the "secret-named source + network sink" block. Rename it to a neutral `OMNIGENT_UNLISTED_VAR`; the test's intent (filtering non-allowlisted keys) is unchanged. Co-authored-by: Isaac --------- Signed-off-by: Michael Gardner <gardnmi@gmail.com> Signed-off-by: Michael Gardner <gardnmi@users.noreply.github.com> Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
This commit is contained in:
@@ -97,15 +97,18 @@ uv tool install -q --python 3.12 git+https://github.com/omnigent-ai/omnigent.git
|
||||
- **Node.js 22 LTS or newer** with **`npm`**, for the Claude, Codex, and Pi
|
||||
coding harnesses. `omnigent run` installs the harness CLI you pick.
|
||||
https://docs.npmjs.com/downloading-and-installing-node-js-and-npm
|
||||
- **`tmux`**, required by the native `omnigent claude` / `omnigent codex`
|
||||
- **Kiro CLI** (optional), for `omnigent kiro`: install with
|
||||
`curl -fsSL https://cli.kiro.dev/install | bash`, then sign in with Kiro.
|
||||
- **`tmux`**, required by the native `omnigent claude` / `omnigent codex` /
|
||||
`omnigent kiro`
|
||||
wrappers (`brew install tmux` / `apt install tmux`; the installer offers
|
||||
to install it for you).
|
||||
- **`bubblewrap`** (`bwrap`), **Linux only**. The native `omnigent claude` /
|
||||
`omnigent codex` and `pi` harnesses wrap each agent terminal in a `bwrap`
|
||||
OS-sandbox; on Linux that isolation is mandatory, so a missing `bwrap`
|
||||
binary makes those terminals fail to start (`apt install bubblewrap`; the
|
||||
installer offers to install it for you). macOS uses the built-in `seatbelt`
|
||||
sandbox and needs nothing extra.
|
||||
`omnigent codex` / `omnigent kiro` and `pi` harnesses wrap each agent
|
||||
terminal in a `bwrap` OS-sandbox; on Linux that isolation is mandatory, so a
|
||||
missing `bwrap` binary makes those terminals fail to start
|
||||
(`apt install bubblewrap`; the installer offers to install it for you). macOS
|
||||
uses the built-in `seatbelt` sandbox and needs nothing extra.
|
||||
- **Databricks** (optional). To use a Databricks workspace as your model
|
||||
provider, install Omnigent with the `databricks` extra:
|
||||
`uv tool install "omnigent[databricks]"` — or pass it to the bootstrap
|
||||
@@ -164,6 +167,7 @@ Or launch a specific agent runtime, or your own agent:
|
||||
```bash
|
||||
omnigent claude # Claude Code, in a session your team can join
|
||||
omnigent codex # Codex
|
||||
omnigent kiro # Kiro CLI
|
||||
omnigent run path/to/agent.yaml # your own agent (see "Write your own agent")
|
||||
```
|
||||
|
||||
@@ -379,7 +383,7 @@ name: my_agent
|
||||
prompt: You are a helpful data analyst.
|
||||
|
||||
executor:
|
||||
harness: claude-sdk # or: claude-native, codex, codex-native, cursor, cursor-native, openai-agents, pi, pi-native, antigravity, qwen, copilot
|
||||
harness: claude-sdk # or: claude-native, codex, codex-native, cursor, cursor-native, kiro-native, openai-agents, pi, pi-native, antigravity, qwen, copilot
|
||||
|
||||
tools:
|
||||
# A local Python function (schema auto-generated from the signature)
|
||||
|
||||
@@ -33,6 +33,7 @@ function iconForAgent(agent: AvailableAgent): ComponentType<SVGProps<SVGSVGEleme
|
||||
if (nativeAgent?.iconKind === "opencode") return OpenCodeIcon;
|
||||
if (nativeAgent?.iconKind === "pi") return PiIcon;
|
||||
if (nativeAgent?.iconKind === "cursor") return CursorIcon;
|
||||
if (nativeAgent?.iconKind === "kiro") return CursorIcon;
|
||||
if (nativeAgent?.iconKind === "goose") return GooseIcon;
|
||||
if (nativeAgent?.iconKind === "antigravity") return AntigravityIcon;
|
||||
// A null harness (spec couldn't load) flows through to the bot fallback.
|
||||
@@ -40,6 +41,7 @@ function iconForAgent(agent: AvailableAgent): ComponentType<SVGProps<SVGSVGEleme
|
||||
if (agent.harness?.includes("claude")) return ClaudeIcon;
|
||||
// Both the SDK "cursor" harness and "cursor-native" get the Cursor glyph.
|
||||
if (agent.harness?.includes("cursor")) return CursorIcon;
|
||||
if (agent.harness?.includes("kiro")) return CursorIcon;
|
||||
if (agent.harness?.includes("goose")) return GooseIcon;
|
||||
// qwen falls back to generic BotIcon for now; see docs/QWEN_FOLLOWUPS.md
|
||||
// Exact match — a substring check would false-match e.g. "openapi".
|
||||
|
||||
@@ -107,6 +107,12 @@ describe("useAvailableAgents", () => {
|
||||
description: null,
|
||||
harness: "pi-native",
|
||||
},
|
||||
{
|
||||
id: "ag_kiro_native",
|
||||
name: "kiro-native-ui",
|
||||
description: null,
|
||||
harness: "kiro-native",
|
||||
},
|
||||
{
|
||||
id: "ag_agy_native",
|
||||
name: "antigravity-native-ui",
|
||||
@@ -174,6 +180,14 @@ describe("useAvailableAgents", () => {
|
||||
harness: "pi-native",
|
||||
skills: [],
|
||||
},
|
||||
{
|
||||
id: "ag_kiro_native",
|
||||
name: "kiro-native-ui",
|
||||
display_name: "Kiro",
|
||||
description: null,
|
||||
harness: "kiro-native",
|
||||
skills: [],
|
||||
},
|
||||
{
|
||||
id: "ag_agy_native",
|
||||
name: "antigravity-native-ui",
|
||||
@@ -365,6 +379,53 @@ describe("useAvailableAgents", () => {
|
||||
expect(enrichCalls).toEqual(["/v1/sessions/conv_3/agent"]);
|
||||
});
|
||||
|
||||
it("dedupes native built-ins and hides session-discovered native shadows", async () => {
|
||||
routeFetch({
|
||||
[BUILTINS_URL]: mockResponse({
|
||||
object: "list",
|
||||
data: [
|
||||
// Stale/non-canonical native row from older local state; it
|
||||
// resolves by harness but must not compete with the seeded row.
|
||||
{ id: "ag_stale_kiro", name: "kiro-naitive", harness: "kiro-native" },
|
||||
{ id: "ag_kiro", name: "kiro-native-ui", harness: "kiro-native" },
|
||||
],
|
||||
has_more: false,
|
||||
}),
|
||||
[SCAN_URL]: mockResponse({
|
||||
object: "list",
|
||||
data: [
|
||||
// This distinct session-bound id used to enrich into a second
|
||||
// Kiro row because it did not shadow the built-in by name/id.
|
||||
{ id: "conv_kiro", agent_id: "ag_session_kiro", agent_name: "kiro-naitive" },
|
||||
// Legacy failed Kiro attempts used a plain "kiro" agent name and
|
||||
// no harness; that row must not surface as a custom Kiro picker row.
|
||||
{ id: "conv_legacy", agent_id: "ag_legacy_kiro", agent_name: "kiro" },
|
||||
],
|
||||
has_more: false,
|
||||
}),
|
||||
"/v1/sessions/conv_kiro/agent": mockResponse({
|
||||
id: "ag_session_kiro",
|
||||
object: "agent",
|
||||
name: "kiro-naitive",
|
||||
harness: "kiro-native",
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAvailableAgents(), { wrapper });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data).toEqual([
|
||||
{
|
||||
id: "ag_kiro",
|
||||
name: "kiro-native-ui",
|
||||
display_name: "Kiro",
|
||||
description: null,
|
||||
harness: "kiro-native",
|
||||
skills: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses same-named custom agents with distinct agent_ids to the newest session's row", async () => {
|
||||
routeFetch({
|
||||
[BUILTINS_URL]: mockResponse({ object: "list", data: [], has_more: false }),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { authenticatedFetch } from "@/lib/identity";
|
||||
import { agentRootName } from "@/lib/forkHarness";
|
||||
import { capitalizeAgentName } from "@/lib/agentLabels";
|
||||
import {
|
||||
nativeCodingAgentForAvailableAgent,
|
||||
nativeCodingAgentForAgentName,
|
||||
nativeCodingAgentForHarness,
|
||||
} from "@/lib/nativeCodingAgents";
|
||||
@@ -40,6 +41,29 @@ function displayNameForAgent(name: string, harness?: string | null): string {
|
||||
);
|
||||
}
|
||||
|
||||
function dedupeNativeAgents(agents: AvailableAgent[]): AvailableAgent[] {
|
||||
const result: AvailableAgent[] = [];
|
||||
const nativeIndex = new Map<string, number>();
|
||||
for (const agent of agents) {
|
||||
const nativeAgent = nativeCodingAgentForAvailableAgent(agent);
|
||||
if (nativeAgent?.key !== "kiro") {
|
||||
result.push(agent);
|
||||
continue;
|
||||
}
|
||||
const existingIndex = nativeIndex.get(nativeAgent.key);
|
||||
if (existingIndex === undefined) {
|
||||
nativeIndex.set(nativeAgent.key, result.length);
|
||||
result.push(agent);
|
||||
continue;
|
||||
}
|
||||
const existing = result[existingIndex];
|
||||
if (agent.name === nativeAgent.agentName && existing.name !== nativeAgent.agentName) {
|
||||
result[existingIndex] = agent;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Wire row of the built-in list, GET /v1/agents. */
|
||||
interface BuiltinAgentWire {
|
||||
id: string;
|
||||
@@ -64,14 +88,16 @@ async function fetchBuiltinAgents(): Promise<AvailableAgent[]> {
|
||||
const res = await authenticatedFetch("/v1/agents");
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
const body = (await res.json()) as { data: BuiltinAgentWire[] };
|
||||
return body.data.map((a) => ({
|
||||
return dedupeNativeAgents(
|
||||
body.data.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
display_name: displayNameForAgent(a.name, a.harness),
|
||||
description: a.description ?? null,
|
||||
harness: a.harness ?? null,
|
||||
skills: a.skills ?? [],
|
||||
}));
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,6 +218,10 @@ async function fetchAvailableAgents(): Promise<AvailableAgent[]> {
|
||||
]);
|
||||
const builtinIds = new Set(builtins.map((a) => a.id));
|
||||
const builtinNames = new Set(builtins.map((a) => a.name));
|
||||
const hasKiroBuiltin = builtins.some(
|
||||
(a) => nativeCodingAgentForAvailableAgent(a)?.key === "kiro",
|
||||
);
|
||||
const kiroLegacyNames = new Set(["kiro"]);
|
||||
// One row per custom base name, newest session first (scan order):
|
||||
// same-named agent_ids are per-session mints of the same agent, and
|
||||
// identical-name rows are indistinguishable in the picker anyway.
|
||||
@@ -203,9 +233,15 @@ async function fetchAvailableAgents(): Promise<AvailableAgent[]> {
|
||||
// the clone would slip past the shadow check and pollute the picker.
|
||||
const base = agentRootName(agent.agentName);
|
||||
if (builtinIds.has(agent.agentId) || builtinNames.has(base)) continue;
|
||||
if (hasKiroBuiltin && kiroLegacyNames.has(base.toLocaleLowerCase())) continue;
|
||||
if (!customByName.has(base)) customByName.set(base, agent);
|
||||
}
|
||||
const enriched = await Promise.all(Array.from(customByName.values()).map(enrichSessionAgent));
|
||||
const enriched = (
|
||||
await Promise.all(Array.from(customByName.values()).map(enrichSessionAgent))
|
||||
).filter((agent) => {
|
||||
const nativeKey = nativeCodingAgentForAvailableAgent(agent)?.key;
|
||||
return nativeKey !== "kiro" || !hasKiroBuiltin;
|
||||
});
|
||||
// Built-ins first; custom agents follow in scan order (newest session
|
||||
// first). NewChatDialog's display-order sort is stable, so unranked
|
||||
// custom names keep this relative order.
|
||||
|
||||
@@ -483,6 +483,12 @@ describe("inventoryTerminals", () => {
|
||||
session: "main",
|
||||
running: true,
|
||||
};
|
||||
const kiroPane: TerminalInfo = {
|
||||
id: "terminal_kiro_main",
|
||||
name: "kiro",
|
||||
session: "main",
|
||||
running: true,
|
||||
};
|
||||
const goosePane: TerminalInfo = {
|
||||
id: "terminal_goose_main",
|
||||
name: "goose",
|
||||
@@ -528,6 +534,13 @@ describe("inventoryTerminals", () => {
|
||||
expect(inventoryTerminals([cursorPane, bash], true)).toEqual([bash]);
|
||||
});
|
||||
|
||||
it("drops the kiro vendor pane for native Kiro sessions", () => {
|
||||
// Regression: terminal_kiro_main was missing from AGENT_TERMINAL_IDS,
|
||||
// so Kiro's Terminal pill opened a shell view with an X close button
|
||||
// instead of preserving the Chat/Terminal toggle.
|
||||
expect(inventoryTerminals([kiroPane, bash], true)).toEqual([bash]);
|
||||
});
|
||||
|
||||
it("drops the goose vendor pane for native Goose sessions", () => {
|
||||
// Regression: terminal_goose_main was missing from AGENT_TERMINAL_IDS, so
|
||||
// the goose TUI pane leaked into the Shells inventory and (via isShellView)
|
||||
@@ -605,7 +618,8 @@ describe("isAgentTerminalKey", () => {
|
||||
expect(isAgentTerminalKey("terminal:terminal_pi_main")).toBe(true);
|
||||
// cursor-native: same regression class as pi above.
|
||||
expect(isAgentTerminalKey("terminal:terminal_cursor_main")).toBe(true);
|
||||
// goose-/qwen-native: same regression class as pi/cursor above.
|
||||
// kiro-/goose-/qwen-native: same regression class as cursor/pi above.
|
||||
expect(isAgentTerminalKey("terminal:terminal_kiro_main")).toBe(true);
|
||||
expect(isAgentTerminalKey("terminal:terminal_goose_main")).toBe(true);
|
||||
expect(isAgentTerminalKey("terminal:terminal_qwen_main")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -50,9 +50,10 @@ export const PANEL_NO_TERMINAL_KEY = "";
|
||||
* connection pill's Terminal view, runner-created per session shape:
|
||||
* the embedded Omnigent REPL (``tui``/``main``) for SDK sessions,
|
||||
* and the vendor pane (``claude``/``main``, ``codex``/``main``,
|
||||
* ``pi``/``main``, ``cursor``/``main``, ``goose``/``main``, ``qwen``/``main``,
|
||||
* or ``antigravity``/``main``) for native-wrapper sessions. These are plumbing,
|
||||
* not part of the session's shell inventory, and at most one exists per session.
|
||||
* ``pi``/``main``, ``cursor``/``main``, ``kiro``/``main``, ``goose``/``main``,
|
||||
* ``qwen``/``main``, or ``antigravity``/``main``) for native-wrapper sessions.
|
||||
* These are plumbing, not part of the session's shell inventory, and at most
|
||||
* one exists per session.
|
||||
*
|
||||
* Missing an entry here makes that pane read as a *user shell*: the
|
||||
* Chat/Terminal pill self-hides in Terminal view (``isShellView``), so the
|
||||
@@ -66,6 +67,7 @@ export const AGENT_TERMINAL_IDS: ReadonlySet<string> = new Set([
|
||||
"terminal_opencode_main",
|
||||
"terminal_pi_main",
|
||||
"terminal_cursor_main",
|
||||
"terminal_kiro_main",
|
||||
"terminal_goose_main",
|
||||
"terminal_qwen_main",
|
||||
"terminal_antigravity_main",
|
||||
|
||||
@@ -17,6 +17,7 @@ export const BUILTIN_AGENTS = new Set([
|
||||
"opencode-native-ui", // OpenCode
|
||||
"pi-native-ui", // Pi
|
||||
"cursor-native-ui", // Cursor
|
||||
"kiro-native-ui", // Kiro
|
||||
"antigravity-native-ui", // Antigravity
|
||||
"goose-native-ui", // Goose
|
||||
"qwen-native-ui", // Qwen Code
|
||||
@@ -34,6 +35,7 @@ export const AGENT_DISPLAY_ORDER = [
|
||||
"OpenCode",
|
||||
"Cursor",
|
||||
"Pi",
|
||||
"Kiro",
|
||||
"Antigravity",
|
||||
"Qwen Code",
|
||||
"Polly",
|
||||
|
||||
@@ -1314,6 +1314,31 @@ describe("BlockStream — status events", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("output_item.done error event preserves persisted ids", () => {
|
||||
const blocks = reduce([
|
||||
{
|
||||
type: "error",
|
||||
source: "execution",
|
||||
toolName: null,
|
||||
error: {
|
||||
code: "kiro_native_prompt_not_recorded",
|
||||
message: "Kiro did not accept this web message.",
|
||||
},
|
||||
itemId: "err_kiro",
|
||||
responseId: "resp_kiro_failed_input",
|
||||
},
|
||||
]);
|
||||
|
||||
const err = blocks.find((b) => b.type === "error");
|
||||
expect(err).toBeDefined();
|
||||
if (err && err.type === "error") {
|
||||
expect(err.ctx.itemId).toBe("err_kiro");
|
||||
expect(err.ctx.responseId).toBe("resp_kiro_failed_input");
|
||||
expect(err.message).toBe("Kiro did not accept this web message.");
|
||||
expect(err.code).toBe("kiro_native_prompt_not_recorded");
|
||||
}
|
||||
});
|
||||
|
||||
it("retry event → RetryBlock with attempt/max/delay fields", () => {
|
||||
const blocks = reduce([
|
||||
{ type: "response_created", response: makeResponse() },
|
||||
|
||||
@@ -757,7 +757,7 @@ function* processEvent(state: ReducerState, event: StreamEvent): Generator<AnyBl
|
||||
// shows just `[llm]` with no hint as to what went wrong).
|
||||
yield {
|
||||
type: "error",
|
||||
ctx: ctx(state),
|
||||
ctx: ctx(state, event.itemId ?? null, event.responseId ?? null),
|
||||
message: event.error.message,
|
||||
source: event.source,
|
||||
code: event.error.code,
|
||||
|
||||
@@ -359,6 +359,10 @@ export interface ErrorEvent {
|
||||
source: string;
|
||||
toolName: string | null;
|
||||
error: ErrorInfo;
|
||||
/** Server-assigned item id when parsed from `response.output_item.done`. */
|
||||
itemId?: string;
|
||||
/** Server-assigned response id when parsed from `response.output_item.done`. */
|
||||
responseId?: string;
|
||||
}
|
||||
|
||||
// ── Compaction ───────────────────────────────────────────
|
||||
|
||||
@@ -35,6 +35,17 @@ describe("nativeCodingAgentForHarness", () => {
|
||||
expect(nativeCodingAgentForHarness("native-pi")).toBe(nativeCodingAgentForHarness("pi-native"));
|
||||
});
|
||||
|
||||
it("resolves Kiro and folds the reversed native-kiro alias", () => {
|
||||
const kiro = nativeCodingAgentForHarness("kiro-native");
|
||||
expect(kiro).toMatchObject({
|
||||
key: "kiro",
|
||||
displayName: "Kiro",
|
||||
harness: "kiro-native",
|
||||
wrapperLabel: "kiro-native-ui",
|
||||
});
|
||||
expect(nativeCodingAgentForHarness("native-kiro")).toBe(kiro);
|
||||
});
|
||||
|
||||
it("resolves the canonical antigravity-native harness", () => {
|
||||
expect(nativeCodingAgentForHarness("antigravity-native")?.key).toBe("antigravity");
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ export type NativeCodingAgentIconKind =
|
||||
| "opencode"
|
||||
| "pi"
|
||||
| "cursor"
|
||||
| "kiro"
|
||||
| "goose"
|
||||
| "antigravity"
|
||||
| "qwen"
|
||||
@@ -76,6 +77,15 @@ export const NATIVE_CODING_AGENTS = [
|
||||
iconKind: "pi",
|
||||
sortRank: 40,
|
||||
},
|
||||
{
|
||||
key: "kiro",
|
||||
agentName: "kiro-native-ui",
|
||||
harness: "kiro-native",
|
||||
wrapperLabel: "kiro-native-ui",
|
||||
displayName: "Kiro",
|
||||
iconKind: "kiro",
|
||||
sortRank: 50,
|
||||
},
|
||||
{
|
||||
// Antigravity's native CLI (Gemini-family). Mirrors the server's
|
||||
// canonical `antigravity-native` harness and the `antigravity-native-ui`
|
||||
@@ -97,7 +107,7 @@ export const NATIVE_CODING_AGENTS = [
|
||||
wrapperLabel: "goose-native-ui",
|
||||
displayName: "Goose",
|
||||
iconKind: "goose",
|
||||
sortRank: 50,
|
||||
sortRank: 60,
|
||||
},
|
||||
{
|
||||
// qwen has no brand glyph yet, so it falls back to the generic bot icon
|
||||
@@ -144,6 +154,7 @@ const BY_WRAPPER: Map<string, NativeCodingAgentSpec> = new Map(
|
||||
const HARNESS_ALIASES: Record<string, string> = {
|
||||
"native-pi": "pi-native",
|
||||
"native-cursor": "cursor-native",
|
||||
"native-kiro": "kiro-native",
|
||||
"native-antigravity": "antigravity-native",
|
||||
"native-goose": "goose-native",
|
||||
"native-qwen": "qwen-native",
|
||||
|
||||
@@ -452,6 +452,36 @@ describe("response.output_item.done (message)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("response.output_item.done (error)", () => {
|
||||
it("lifts persisted error items for live transcript rendering", () => {
|
||||
const out = parse("response.output_item.done", {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
id: "err_kiro",
|
||||
type: "error",
|
||||
status: "completed",
|
||||
response_id: "resp_kiro_failed_input",
|
||||
source: "execution",
|
||||
code: "kiro_native_prompt_not_recorded",
|
||||
message: "Kiro did not accept this web message.",
|
||||
},
|
||||
});
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toEqual({
|
||||
type: "error",
|
||||
source: "execution",
|
||||
toolName: null,
|
||||
error: {
|
||||
code: "kiro_native_prompt_not_recorded",
|
||||
message: "Kiro did not accept this web message.",
|
||||
},
|
||||
itemId: "err_kiro",
|
||||
responseId: "resp_kiro_failed_input",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("response.output_item.done (slash_command)", () => {
|
||||
// parseOutputItem returns null for unknown item.types; without
|
||||
// these cases the live UI silently drops every Skill invocation.
|
||||
|
||||
@@ -923,6 +923,20 @@ function parseOutputItem(data: Record<string, unknown>): StreamEvent | null {
|
||||
} satisfies MessageDone;
|
||||
}
|
||||
|
||||
if (itemType === "error") {
|
||||
return {
|
||||
type: "error",
|
||||
source: String(rec.source ?? ""),
|
||||
toolName: null,
|
||||
error: {
|
||||
code: String(rec.code ?? ""),
|
||||
message: String(rec.message ?? ""),
|
||||
},
|
||||
itemId,
|
||||
responseId,
|
||||
} satisfies ErrorEvent;
|
||||
}
|
||||
|
||||
if (itemType === "slash_command") {
|
||||
// Coerce a missing ``output`` (server-side exclude_none) to null
|
||||
// so downstream code branches on a single shape.
|
||||
|
||||
@@ -611,7 +611,7 @@ describe("NewChatLandingScreen", () => {
|
||||
expect(screen.getByText("No agents")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("orders Cursor above Pi in the built-in agent picker", () => {
|
||||
it("orders native built-ins together in the agent picker", () => {
|
||||
mockAgents([
|
||||
{
|
||||
id: "a_pi",
|
||||
@@ -621,6 +621,14 @@ describe("NewChatLandingScreen", () => {
|
||||
harness: "pi-native",
|
||||
skills: [],
|
||||
},
|
||||
{
|
||||
id: "a_kiro",
|
||||
name: "kiro-native-ui",
|
||||
display_name: "Kiro",
|
||||
description: null,
|
||||
harness: "kiro-native",
|
||||
skills: [],
|
||||
},
|
||||
{
|
||||
id: "a_cursor",
|
||||
name: "cursor-native-ui",
|
||||
@@ -645,12 +653,24 @@ describe("NewChatLandingScreen", () => {
|
||||
harness: "claude-native",
|
||||
skills: [],
|
||||
},
|
||||
{
|
||||
id: "a_polly",
|
||||
name: "polly",
|
||||
display_name: "Polly",
|
||||
description: null,
|
||||
harness: "claude-sdk",
|
||||
skills: [],
|
||||
},
|
||||
]);
|
||||
renderLanding();
|
||||
fireEvent.pointerDown(screen.getByTestId("new-chat-landing-agent-select"), { button: 0 });
|
||||
const cursor = screen.getByTestId("new-chat-landing-agent-a_cursor");
|
||||
const pi = screen.getByTestId("new-chat-landing-agent-a_pi");
|
||||
const kiro = screen.getByTestId("new-chat-landing-agent-a_kiro");
|
||||
const polly = screen.getByTestId("new-chat-landing-agent-a_polly");
|
||||
expect(cursor.compareDocumentPosition(pi) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(pi.compareDocumentPosition(kiro) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(kiro.compareDocumentPosition(polly) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("seeds the working directory from the host's most-recent path", async () => {
|
||||
|
||||
@@ -311,6 +311,7 @@ function brandChildIcon(child: ChildSessionInfo): AgentRowIcon | null {
|
||||
if (nativeAgent?.iconKind === "opencode") return OpenCodeIcon;
|
||||
if (nativeAgent?.iconKind === "pi") return PiIcon;
|
||||
if (nativeAgent?.iconKind === "cursor") return CursorIcon;
|
||||
if (nativeAgent?.iconKind === "kiro") return CursorIcon;
|
||||
if (nativeAgent?.iconKind === "antigravity") return AntigravityIcon;
|
||||
if (nativeAgent?.iconKind === "goose") return GooseIcon;
|
||||
// Exact match — substring checks would false-match names like "pipeline".
|
||||
@@ -476,7 +477,7 @@ function MainRow({ rootSessionId, isActive }: { rootSessionId: string; isActive:
|
||||
? OpenCodeIcon
|
||||
: nativeAgent?.iconKind === "pi"
|
||||
? PiIcon
|
||||
: nativeAgent?.iconKind === "cursor"
|
||||
: nativeAgent?.iconKind === "cursor" || nativeAgent?.iconKind === "kiro"
|
||||
? CursorIcon
|
||||
: nativeAgent?.iconKind === "antigravity"
|
||||
? AntigravityIcon
|
||||
|
||||
@@ -183,6 +183,13 @@ describe("getConversationAgentType", () => {
|
||||
expect(getConversationAgentType(conv)).toBe("Pi");
|
||||
});
|
||||
|
||||
it("returns 'Kiro' for kiro-native-ui sessions", () => {
|
||||
const conv = conversation("conv_kiro", null, new Date(2026, 4, 14, 9), {
|
||||
labels: { "omnigent.wrapper": "kiro-native-ui" },
|
||||
});
|
||||
expect(getConversationAgentType(conv)).toBe("Kiro");
|
||||
});
|
||||
|
||||
it("returns 'Antigravity' for antigravity-native-ui sessions", () => {
|
||||
const conv = conversation("conv_agy", null, new Date(2026, 4, 14, 9), {
|
||||
labels: { "omnigent.wrapper": "antigravity-native-ui" },
|
||||
@@ -276,6 +283,13 @@ describe("getConversationIconKind", () => {
|
||||
}),
|
||||
),
|
||||
).toBe("pi");
|
||||
expect(
|
||||
getConversationIconKind(
|
||||
conversation("conv_kiro", null, new Date(2026, 4, 14, 9), {
|
||||
labels: { "omnigent.wrapper": "kiro-native-ui" },
|
||||
}),
|
||||
),
|
||||
).toBe("kiro");
|
||||
expect(
|
||||
getConversationIconKind(
|
||||
conversation("conv_agy", null, new Date(2026, 4, 14, 9), {
|
||||
|
||||
@@ -27,6 +27,7 @@ export type ConversationIconKind =
|
||||
| "opencode"
|
||||
| "pi"
|
||||
| "cursor"
|
||||
| "kiro"
|
||||
| "goose"
|
||||
| "antigravity"
|
||||
| "qwen"
|
||||
|
||||
+1
-1
@@ -265,7 +265,7 @@ no user credentials ever enter the sandbox.
|
||||
(`ghcr.io/omnigent-ai/omnigent-host:latest`, published by CI from the `host`
|
||||
target of [`docker/Dockerfile`](docker/Dockerfile)), so the host starts in
|
||||
seconds instead of installing Omnigent at boot. The image ships the
|
||||
coding-harness CLIs (`claude`, `codex`, `pi`), so agents on any harness run
|
||||
coding-harness CLIs (`claude`, `codex`, `pi`, `kiro-cli`), so agents on any harness run
|
||||
in the sandbox with nothing extra to install. To run sandboxes from your own
|
||||
image instead (a fork, or extra tooling baked in), build the same `host`
|
||||
target and point the config at it:
|
||||
|
||||
@@ -46,7 +46,7 @@ export CWSANDBOX_BASE_URL=https://api.cwsandbox.com # optional (this is the de
|
||||
Sandboxes boot from `ghcr.io/omnigent-ai/omnigent-host:latest`, published by CI
|
||||
from the `host` target of [`deploy/docker/Dockerfile`](../docker/Dockerfile)
|
||||
with Omnigent and its dependencies preinstalled — including the coding-harness
|
||||
CLIs (`claude`, `codex`, `pi`), so agents on any harness run without an
|
||||
CLIs (`claude`, `codex`, `pi`, `kiro-cli`), so agents on any harness run without an
|
||||
in-sandbox install.
|
||||
|
||||
To use a different image (a fork, or extra tooling baked in), build the same
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
# and server-launched managed hosts. It bakes the full omnigent
|
||||
# install plus the tools a host needs at runtime — git (workspaces /
|
||||
# worktrees), tmux (terminal sessions spawned by native harnesses),
|
||||
# and the coding-harness CLIs (claude / codex / pi, via Node) — and
|
||||
# and the coding-harness CLIs (claude / codex / pi, via Node; kiro-cli via
|
||||
# Kiro's installer) — and
|
||||
# skips everything server-only: no SPA bundle, no psycopg, no
|
||||
# uvicorn entrypoint. Modal's `Image.from_registry` requirements shape
|
||||
# it: `python` + `pip` on $PATH, and CMD-only (an ENTRYPOINT that
|
||||
@@ -168,9 +169,10 @@ FROM node:${NODE_VERSION}-slim AS node-runtime
|
||||
# port" and breaks web-turn injection), bubblewrap to OS-sandbox the
|
||||
# native harness terminals (mandatory and fail-loud on Linux), curl + CA
|
||||
# certificates for outbound HTTPS and in-sandbox downloads, plus the
|
||||
# coding-harness CLIs (claude / codex / pi) so claude-sdk, claude-native,
|
||||
# codex, and pi agents can run in managed sandboxes without an in-sandbox
|
||||
# install. No SPA, no psycopg, no server entrypoint.
|
||||
# coding-harness CLIs (claude / codex / pi / kiro-cli) so claude-sdk,
|
||||
# claude-native, codex, pi, and kiro-native agents can run in managed
|
||||
# sandboxes without an in-sandbox install. No SPA, no psycopg, no server
|
||||
# entrypoint.
|
||||
#
|
||||
# Also carries two additions required only by the NVIDIA OpenShell provider
|
||||
# (deploy/openshell/README.md) and inert for the root-based providers
|
||||
@@ -199,7 +201,8 @@ ENV PYTHONUNBUFFERED=1 \
|
||||
IS_SANDBOX=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git tmux procps lsof bubblewrap curl ca-certificates \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git tmux procps lsof bubblewrap curl ca-certificates unzip \
|
||||
iproute2 nftables \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -243,6 +246,14 @@ RUN npm install -g --no-audit --no-fund \
|
||||
@earendil-works/pi-coding-agent \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Kiro CLI is not published as an npm package; use its official installer and
|
||||
# copy the resulting root-local binaries onto the global PATH for all sandbox
|
||||
# users.
|
||||
RUN curl -fsSL https://cli.kiro.dev/install | bash \
|
||||
&& install -m 0755 /root/.local/bin/kiro-cli /usr/local/bin/kiro-cli \
|
||||
&& if [ -f /root/.local/bin/kiro-cli-chat ]; then \
|
||||
install -m 0755 /root/.local/bin/kiro-cli-chat /usr/local/bin/kiro-cli-chat; \
|
||||
fi
|
||||
# Antigravity CLI (`agy`) — the antigravity-native harness shells out to `agy`
|
||||
# on the host, launching it in a tmux pane (see omnigent/antigravity_native*.py),
|
||||
# so a managed host image must carry it. It is NOT an npm package
|
||||
|
||||
@@ -83,7 +83,7 @@ ENV PYTHONUNBUFFERED=1 \
|
||||
USER 0
|
||||
|
||||
# curl-minimal and ca-certificates are preinstalled in UBI9.
|
||||
RUN dnf install -y --nodocs git tmux \
|
||||
RUN dnf install -y --nodocs git tmux unzip \
|
||||
&& dnf clean all
|
||||
|
||||
RUN git config --system credential.helper \
|
||||
@@ -101,6 +101,14 @@ RUN npm install -g --no-audit --no-fund \
|
||||
@earendil-works/pi-coding-agent \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Kiro CLI is not published as an npm package; use its official installer and
|
||||
# copy the resulting root-local binaries onto the global PATH.
|
||||
RUN curl -fsSL https://cli.kiro.dev/install | bash \
|
||||
&& install -m 0755 /root/.local/bin/kiro-cli /usr/local/bin/kiro-cli \
|
||||
&& if [ -f /root/.local/bin/kiro-cli-chat ]; then \
|
||||
install -m 0755 /root/.local/bin/kiro-cli-chat /usr/local/bin/kiro-cli-chat; \
|
||||
fi
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY --from=builder /build /build
|
||||
|
||||
|
||||
@@ -241,8 +241,8 @@ seconds instead of paying an in-sandbox dependency install. It bakes
|
||||
the full omnigent install (all three packages + deps, `python` and
|
||||
`pip` on PATH), `git` (workspaces / worktrees), `tmux` (terminal
|
||||
sessions spawned by native harnesses), and the coding-harness CLIs —
|
||||
`claude`, `codex`, and `pi`, with the Node runtime they need — so
|
||||
claude-sdk / claude-native / codex / pi agents run in sandboxes
|
||||
`claude`, `codex`, `pi`, and `kiro-cli`, with the runtime they need — so
|
||||
claude-sdk / claude-native / codex / pi / kiro-native agents run in sandboxes
|
||||
without an in-sandbox install. None of the server-only bits are
|
||||
included (no SPA bundle, no psycopg, no uvicorn entrypoint).
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ Sandboxes boot from `ghcr.io/omnigent-ai/omnigent-host:latest`, published
|
||||
by CI from the `host` target of
|
||||
[`deploy/docker/Dockerfile`](../docker/Dockerfile) with Omnigent and its
|
||||
dependencies preinstalled — including the coding-harness CLIs (`claude`,
|
||||
`codex`, `pi`), so agents on any harness run without an in-sandbox
|
||||
`codex`, `pi`, `kiro-cli`), so agents on any harness run without an in-sandbox
|
||||
install.
|
||||
|
||||
To use a different image (a fork, or extra tooling baked in), build the
|
||||
@@ -83,7 +83,7 @@ pulls the image, not Omnigent).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Native terminals need `bubblewrap`.** The `claude-native` /
|
||||
> `codex-native` / `pi` harnesses wrap each agent terminal in a bubblewrap
|
||||
> `codex-native` / `kiro-native` / `pi` harnesses wrap each agent terminal in a bubblewrap
|
||||
> (`bwrap`) OS-sandbox, and on Linux that isolation is mandatory and
|
||||
> fail-loud — a host image without the `bwrap` binary makes those terminals
|
||||
> fail to start (`linux_bwrap sandbox requires the 'bwrap' binary on PATH`).
|
||||
|
||||
@@ -164,7 +164,7 @@ Sandboxes boot from `ghcr.io/omnigent-ai/omnigent-host:latest`, an image
|
||||
published by CI from the `host` target of
|
||||
[`deploy/docker/Dockerfile`](../docker/Dockerfile) with Omnigent
|
||||
and its dependencies preinstalled — including the coding-harness CLIs
|
||||
(`claude`, `codex`, `pi`), so agents on any harness run without an
|
||||
(`claude`, `codex`, `pi`, `kiro-cli`), so agents on any harness run without an
|
||||
in-sandbox install.
|
||||
|
||||
To use a different image (a fork, or extra tooling baked in), build the
|
||||
|
||||
@@ -102,7 +102,7 @@ automatically — Omnigent needs no extra configuration.
|
||||
Sandboxes boot from `ghcr.io/omnigent-ai/omnigent-host:latest`, published by CI
|
||||
from the `host` target of [`deploy/docker/Dockerfile`](../docker/Dockerfile) with
|
||||
Omnigent and its dependencies preinstalled — including the coding-harness CLIs
|
||||
(`claude`, `codex`, `pi`), so agents on any harness run without an in-sandbox
|
||||
(`claude`, `codex`, `pi`, `kiro-cli`), so agents on any harness run without an in-sandbox
|
||||
install. OpenShell injects its own supervisor as the container entrypoint.
|
||||
|
||||
The `host` target also carries the two things OpenShell's image contract requires
|
||||
|
||||
@@ -49,7 +49,7 @@ resolved from the YAML file's directory.
|
||||
|
||||
```yaml
|
||||
executor:
|
||||
harness: claude-sdk # claude-sdk, openai-agents, codex, cursor, pi, antigravity, qwen, copilot, hermes, ...
|
||||
harness: claude-sdk # claude-sdk, openai-agents, codex, cursor, kiro-native, pi, antigravity, qwen, copilot, hermes, ...
|
||||
model: databricks-claude-opus-4-7
|
||||
auth:
|
||||
type: databricks
|
||||
@@ -66,6 +66,11 @@ gateway / `auth.type: databricks` does not apply. Authenticate it with
|
||||
`auth: {type: api_key, api_key: ${CURSOR_API_KEY}}`, and choose a Cursor model
|
||||
id (e.g. `auto`, `gpt-5`) rather than a `databricks-*` id.
|
||||
|
||||
The `kiro-native` harness is the native Kiro CLI terminal path used by
|
||||
`omnigent kiro`. It requires `kiro-cli` on `PATH` and Kiro's own login/auth; it
|
||||
does not use Databricks, OpenAI, or Anthropic provider credentials. Plain
|
||||
`harness: kiro` is not a generic Omnigent harness id.
|
||||
|
||||
### Antigravity (Gemini)
|
||||
|
||||
`harness: antigravity` runs the agent through Google's
|
||||
|
||||
@@ -58,6 +58,10 @@ OPENCODE_NATIVE_WRAPPER_VALUE = "opencode-native-ui"
|
||||
# ``conversations.labels[WRAPPER_LABEL_KEY]``.
|
||||
CURSOR_NATIVE_WRAPPER_VALUE = "cursor-native-ui"
|
||||
|
||||
# Value the ``omnigent kiro`` wrapper writes into
|
||||
# ``conversations.labels[WRAPPER_LABEL_KEY]``.
|
||||
KIRO_NATIVE_WRAPPER_VALUE = "kiro-native-ui"
|
||||
|
||||
# Value the ``omnigent goose`` wrapper writes into
|
||||
# ``conversations.labels[WRAPPER_LABEL_KEY]``.
|
||||
GOOSE_NATIVE_WRAPPER_VALUE = "goose-native-ui"
|
||||
|
||||
@@ -1049,6 +1049,14 @@ def _redirect_native_resume_if_needed(
|
||||
progress=progress,
|
||||
)
|
||||
return True
|
||||
if native_agent.key == "kiro":
|
||||
_run_kiro_native_resume_redirect(
|
||||
base_url=base_url,
|
||||
conversation_id=conversation_id,
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
progress=progress,
|
||||
)
|
||||
return True
|
||||
if native_agent.key == "cursor":
|
||||
_run_cursor_native_resume_redirect(
|
||||
base_url=base_url,
|
||||
@@ -1188,6 +1196,30 @@ def _run_pi_native_resume_redirect(
|
||||
)
|
||||
|
||||
|
||||
def _run_kiro_native_resume_redirect(
|
||||
*,
|
||||
base_url: str,
|
||||
conversation_id: str,
|
||||
auto_open_conversation: bool,
|
||||
progress: RunnerStartupProgress | None,
|
||||
) -> None:
|
||||
"""Hand a kiro-native conversation back to ``omnigent kiro``."""
|
||||
_finish_native_redirect_progress(
|
||||
progress=progress,
|
||||
conversation_id=conversation_id,
|
||||
wrapper_name="kiro-native",
|
||||
native_command="kiro",
|
||||
)
|
||||
from omnigent.kiro_native import run_kiro_native
|
||||
|
||||
run_kiro_native(
|
||||
server=base_url,
|
||||
session_id=conversation_id,
|
||||
kiro_args=(),
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
)
|
||||
|
||||
|
||||
def _run_cursor_native_resume_redirect(
|
||||
*,
|
||||
base_url: str,
|
||||
|
||||
+154
@@ -1174,6 +1174,7 @@ _CLICK_SUBCOMMANDS: frozenset[str] = frozenset(
|
||||
"goose",
|
||||
"hermes",
|
||||
"host",
|
||||
"kiro",
|
||||
"lakebox",
|
||||
"login",
|
||||
"opencode",
|
||||
@@ -4682,6 +4683,159 @@ def cursor(
|
||||
)
|
||||
|
||||
|
||||
@cli.command(
|
||||
context_settings={
|
||||
"ignore_unknown_options": True,
|
||||
"allow_extra_args": True,
|
||||
}
|
||||
)
|
||||
@click.option(
|
||||
"--server",
|
||||
default=None,
|
||||
help=(
|
||||
"Remote omnigent URL. Ensures the host daemon, asks the "
|
||||
"daemon-spawned runner to launch the Kiro TUI, and attaches this TTY. "
|
||||
'Pass --server "" to auto-spawn a persistent local server in the '
|
||||
"background and use that instead of a remote one."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"-r",
|
||||
"--resume",
|
||||
"resume",
|
||||
is_flag=False,
|
||||
flag_value=_RESUME_PICKER_SENTINEL,
|
||||
default=None,
|
||||
help=(
|
||||
"Resume a prior Omnigent conversation. With a conversation id "
|
||||
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
|
||||
"opens an interactive picker scoped to kiro-native sessions."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--session",
|
||||
"session_id",
|
||||
metavar="SESSION_ID",
|
||||
default=None,
|
||||
hidden=True,
|
||||
help="Deprecated alias for ``--resume <id>``; kept for one release.",
|
||||
)
|
||||
@click.option("--model", default=None, help="Kiro model to use for the native chat.")
|
||||
@click.option("--effort", default=None, help="Kiro effort level to use for the native chat.")
|
||||
@click.option("--agent", "kiro_agent", default=None, help="Kiro agent to use for the native chat.")
|
||||
@click.option(
|
||||
"--trust-tools",
|
||||
"trust_tools",
|
||||
multiple=True,
|
||||
metavar="TOOL",
|
||||
help="Trust a specific Kiro tool. May be passed multiple times.",
|
||||
)
|
||||
@click.option(
|
||||
"--trust-all-tools",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Explicitly trust all Kiro tools for this local launch.",
|
||||
)
|
||||
@click.option(
|
||||
"-p",
|
||||
"--prompt",
|
||||
default=None,
|
||||
help="Send this as the initial Kiro chat input when the TUI starts.",
|
||||
)
|
||||
@click.argument("kiro_args", nargs=-1, type=click.UNPROCESSED)
|
||||
def kiro(
|
||||
server: str | None,
|
||||
resume: str | None,
|
||||
session_id: str | None,
|
||||
model: str | None,
|
||||
effort: str | None,
|
||||
kiro_agent: str | None,
|
||||
trust_tools: tuple[str, ...],
|
||||
trust_all_tools: bool,
|
||||
prompt: str | None,
|
||||
kiro_args: tuple[str, ...],
|
||||
) -> None:
|
||||
"""Launch the Kiro TUI in an Omnigent terminal.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
omnigent kiro
|
||||
omnigent kiro --resume conv_abc123
|
||||
omnigent kiro --resume # interactive picker
|
||||
omnigent kiro --model auto -p "review this repo"
|
||||
"""
|
||||
choice = _split_resume_value(resume)
|
||||
if session_id is not None and (choice.picker or choice.conversation_id is not None):
|
||||
raise click.UsageError(
|
||||
"--session and --resume are mutually exclusive; "
|
||||
"prefer --resume (--session is deprecated).",
|
||||
)
|
||||
_reject_reserved_kiro_resume_args(kiro_args)
|
||||
|
||||
from omnigent.kiro_native import run_kiro_native
|
||||
|
||||
cfg = _load_effective_config()
|
||||
if server is None:
|
||||
server = cfg.get("server")
|
||||
if model is None:
|
||||
model = cfg.get("model")
|
||||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||||
launch_args = _build_kiro_launch_args(
|
||||
effort=effort,
|
||||
kiro_agent=kiro_agent,
|
||||
trust_tools=trust_tools,
|
||||
trust_all_tools=trust_all_tools,
|
||||
passthrough_args=kiro_args,
|
||||
)
|
||||
|
||||
server = _ensure_backend(server)
|
||||
resolved_session_id = (
|
||||
choice.conversation_id if choice.conversation_id is not None else session_id
|
||||
)
|
||||
|
||||
run_kiro_native(
|
||||
server=server,
|
||||
session_id=resolved_session_id,
|
||||
resume_picker=choice.picker,
|
||||
kiro_args=launch_args,
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
)
|
||||
|
||||
|
||||
def _reject_reserved_kiro_resume_args(kiro_args: tuple[str, ...]) -> None:
|
||||
"""Reject Kiro-owned resume flags in passthrough args."""
|
||||
reserved = {"--resume", "--resume-id", "--resume-picker"}
|
||||
if any(arg == flag or arg.startswith(f"{flag}=") for arg in kiro_args for flag in reserved):
|
||||
raise click.UsageError(
|
||||
"Kiro resume flags are reserved for Omnigent resume handling; use "
|
||||
"`omnigent kiro --resume [CONVERSATION]` instead."
|
||||
)
|
||||
|
||||
|
||||
def _build_kiro_launch_args(
|
||||
*,
|
||||
effort: str | None,
|
||||
kiro_agent: str | None,
|
||||
trust_tools: tuple[str, ...],
|
||||
trust_all_tools: bool,
|
||||
passthrough_args: tuple[str, ...],
|
||||
) -> tuple[str, ...]:
|
||||
"""Build mapped Kiro CLI args for the runner-owned terminal launch."""
|
||||
args: list[str] = []
|
||||
if effort:
|
||||
args.extend(["--effort", effort])
|
||||
if kiro_agent:
|
||||
args.extend(["--agent", kiro_agent])
|
||||
for tool in trust_tools:
|
||||
args.extend(["--trust-tools", tool])
|
||||
if trust_all_tools:
|
||||
args.append("--trust-all-tools")
|
||||
args.extend(passthrough_args)
|
||||
return tuple(args)
|
||||
|
||||
|
||||
@cli.command(
|
||||
context_settings={
|
||||
"ignore_unknown_options": True,
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
HARNESS_ALIASES: dict[str, str] = {
|
||||
"claude": "claude-sdk",
|
||||
"native-kiro": "kiro-native",
|
||||
"native-pi": "pi-native",
|
||||
# The SDK package / runtime dispatch spelling; specs use "openai-agents".
|
||||
"openai-agents-sdk": "openai-agents",
|
||||
@@ -53,6 +54,8 @@ NATIVE_HARNESSES: frozenset[str] = frozenset(
|
||||
"native-pi",
|
||||
"cursor-native",
|
||||
"native-cursor",
|
||||
"kiro-native",
|
||||
"native-kiro",
|
||||
# Native Antigravity (agy) TUI bridge used by ``omnigent antigravity``;
|
||||
# the in-process SDK counterpart is the canonical ``antigravity``
|
||||
# harness (see HARNESS_ALIASES / runtime/harnesses/__init__.py).
|
||||
|
||||
@@ -699,6 +699,11 @@ class TerminalEnvSpec:
|
||||
MCP servers that construct Databricks SDK clients and let
|
||||
the SDK's auth resolver pick up the parent's profile
|
||||
instead of the explicit token they were given).
|
||||
:param inherit_env: Whether the terminal process starts from the
|
||||
parent process environment before applying ``env`` / ``env_unset``.
|
||||
Defaults to ``True`` for backward compatibility. Set to ``False``
|
||||
for native CLI integrations that must receive an explicit allowlisted
|
||||
environment instead of ambient host secrets.
|
||||
:param os_env: OS environment backing this terminal, ``"inherit"``,
|
||||
or ``None`` to use the default caller process environment.
|
||||
:param allow_cwd_override: Whether launch callers may override cwd.
|
||||
@@ -725,6 +730,7 @@ class TerminalEnvSpec:
|
||||
args: list[str] = field(default_factory=list)
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
env_unset: list[str] = field(default_factory=list)
|
||||
inherit_env: bool = True
|
||||
os_env: OSEnvSpec | str | None = None
|
||||
allow_cwd_override: bool = False
|
||||
allow_sandbox_override: bool = False
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Executor that bridges Omnigent web-chat turns into the native Kiro TUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from omnigent.inner.executor import (
|
||||
Executor,
|
||||
ExecutorConfig,
|
||||
ExecutorError,
|
||||
ExecutorEvent,
|
||||
Message,
|
||||
ToolSpec,
|
||||
TurnComplete,
|
||||
)
|
||||
from omnigent.kiro_native_bridge import KIRO_NATIVE_BRIDGE_DIR_ENV_VAR, inject_user_message
|
||||
|
||||
|
||||
class KiroNativeExecutor(Executor):
|
||||
"""Harness-side executor for ``omnigent kiro`` web-UI turns."""
|
||||
|
||||
def __init__(self, bridge_dir: Path | None = None) -> None:
|
||||
self._bridge_dir = bridge_dir or _bridge_dir_from_env()
|
||||
self._inject_lock = asyncio.Lock()
|
||||
|
||||
def supports_streaming(self) -> bool:
|
||||
""":returns: ``False`` — output is shown by the embedded terminal."""
|
||||
return False
|
||||
|
||||
def supports_live_message_queue(self) -> bool:
|
||||
""":returns: ``True`` — messages can be injected mid-turn."""
|
||||
return True
|
||||
|
||||
async def enqueue_session_message(self, session_key: str, content: Any) -> bool:
|
||||
"""Inject a live steering message into the Kiro terminal."""
|
||||
del session_key
|
||||
text = _content_to_text(content, self._bridge_dir)
|
||||
if not text:
|
||||
return False
|
||||
try:
|
||||
async with self._inject_lock:
|
||||
await asyncio.to_thread(inject_user_message, self._bridge_dir, content=text)
|
||||
except RuntimeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def run_turn(
|
||||
self,
|
||||
messages: list[Message],
|
||||
tools: list[ToolSpec],
|
||||
system_prompt: str,
|
||||
config: ExecutorConfig | None = None,
|
||||
) -> AsyncIterator[ExecutorEvent]:
|
||||
"""Inject the latest web-UI user message into the Kiro TUI pane."""
|
||||
del tools, system_prompt, config
|
||||
text = _latest_user_text(messages, self._bridge_dir)
|
||||
if not text:
|
||||
yield ExecutorError(message="kiro native turn had no user text to send")
|
||||
return
|
||||
try:
|
||||
async with self._inject_lock:
|
||||
await asyncio.to_thread(inject_user_message, self._bridge_dir, content=text)
|
||||
except RuntimeError as exc:
|
||||
yield ExecutorError(message=str(exc))
|
||||
return
|
||||
yield TurnComplete(response=None)
|
||||
|
||||
|
||||
def _bridge_dir_from_env() -> Path:
|
||||
"""Resolve the kiro-native bridge dir from the harness spawn env."""
|
||||
raw = os.environ.get(KIRO_NATIVE_BRIDGE_DIR_ENV_VAR, "").strip()
|
||||
if not raw:
|
||||
raise RuntimeError(
|
||||
f"{KIRO_NATIVE_BRIDGE_DIR_ENV_VAR} is required for the kiro-native harness"
|
||||
)
|
||||
return Path(raw)
|
||||
|
||||
|
||||
def _latest_user_text(messages: list[Message], bridge_dir: Path) -> str:
|
||||
"""Return the latest user message's text."""
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "user":
|
||||
return _content_to_text(message.get("content"), bridge_dir)
|
||||
return ""
|
||||
|
||||
|
||||
def _content_to_text(content: Any, bridge_dir: Path) -> str:
|
||||
"""Normalize executor content into text the Kiro TUI receives."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
from omnigent.inner.native_attachments import materialize_attachment
|
||||
|
||||
attachment_lines: list[str] = []
|
||||
text_parts: list[str] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
block_type = block.get("type", "")
|
||||
if block_type in ("input_text", "text"):
|
||||
text = block.get("text")
|
||||
if isinstance(text, str):
|
||||
text_parts.append(text)
|
||||
elif block_type in ("input_image", "input_file"):
|
||||
path = materialize_attachment(block, bridge_dir)
|
||||
if path is not None:
|
||||
attachment_lines.append(f"[Attached: {path}]")
|
||||
return "\n\n".join(attachment_lines + text_parts)
|
||||
return ""
|
||||
@@ -0,0 +1,20 @@
|
||||
"""``harness: kiro-native`` wrap (the native Kiro TUI)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.inner.executor import Executor
|
||||
from omnigent.inner.kiro_native_executor import KiroNativeExecutor
|
||||
from omnigent.runtime.harnesses._executor_adapter import ExecutorAdapter
|
||||
|
||||
|
||||
def _build_kiro_native_executor() -> Executor:
|
||||
"""Construct a :class:`KiroNativeExecutor`."""
|
||||
return KiroNativeExecutor()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Build the kiro-native harness's FastAPI app (required entry point)."""
|
||||
adapter = ExecutorAdapter(executor_factory=_build_kiro_native_executor)
|
||||
return adapter.build()
|
||||
@@ -725,6 +725,8 @@ class TerminalInstance:
|
||||
if the same key also appears in ``env``, the strip wins.
|
||||
Intentional: ``env_unset`` is a leak-prevention boundary,
|
||||
not a soft default.
|
||||
:param inherit_env: Whether to start from ``os.environ`` before applying
|
||||
``env`` / ``env_unset``.
|
||||
:param sandbox_policy: Optional sandbox wrapper policy.
|
||||
:param conversation_link: Optional web UI link for the owning
|
||||
conversation, e.g. ``"/c/conv_abc123"``.
|
||||
@@ -746,6 +748,7 @@ class TerminalInstance:
|
||||
args: list[str] = field(default_factory=list)
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
env_unset: list[str] = field(default_factory=list)
|
||||
inherit_env: bool = True
|
||||
sandbox_policy: SandboxPolicy | None = None
|
||||
conversation_link: str | None = None
|
||||
# Egress allow-list to enforce for this terminal. Populated
|
||||
@@ -889,7 +892,10 @@ class TerminalInstance:
|
||||
# commands outside the sandbox. The host-side control plane
|
||||
# addresses the socket via ``self.socket_path`` directly and never
|
||||
# needs the env var; any inherited value is stripped below too.
|
||||
env = dict(os.environ)
|
||||
if self.inherit_env:
|
||||
env = os.environ.copy()
|
||||
else:
|
||||
env = {}
|
||||
env.pop("OMNIGENT_TMUX_SOCK", None)
|
||||
# Apply per-terminal env overrides (takes precedence over inherited env).
|
||||
env.update(self.env)
|
||||
@@ -1788,6 +1794,7 @@ def create_terminal_instance(
|
||||
args=list(spec.args),
|
||||
env=dict(spec.env),
|
||||
env_unset=list(spec.env_unset),
|
||||
inherit_env=spec.inherit_env,
|
||||
sandbox_policy=sandbox,
|
||||
conversation_link=conversation_link,
|
||||
egress_rules=egress_rules,
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
"""Native Kiro TUI wrapper for the Omnigent CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from omnigent._native_resume_hint import echo_native_cold_resume_hint, echo_native_resume_hint
|
||||
from omnigent._runner_startup import RunnerStartupProgress, runner_startup_progress
|
||||
from omnigent._wrapper_labels import KIRO_NATIVE_WRAPPER_VALUE as _WRAPPER_LABEL_VALUE
|
||||
from omnigent._wrapper_labels import WRAPPER_LABEL_KEY as _WRAPPER_LABEL_KEY
|
||||
from omnigent.conversation_browser import conversation_url, open_conversation_link_if_enabled
|
||||
from omnigent.entities.session_resources import terminal_resource_id
|
||||
from omnigent.host.daemon_launch import (
|
||||
error_text,
|
||||
launch_or_reuse_daemon_runner,
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
from omnigent.native_terminal import (
|
||||
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
|
||||
)
|
||||
from omnigent.native_terminal import (
|
||||
DAEMON_RUNNER_ONLINE_TIMEOUT_S as _DAEMON_RUNNER_ONLINE_TIMEOUT_S,
|
||||
)
|
||||
from omnigent.native_terminal import (
|
||||
DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S,
|
||||
)
|
||||
from omnigent.native_terminal import bind_session_runner as _bind_session_runner
|
||||
from omnigent.native_terminal import url_component
|
||||
|
||||
_DEFAULT_KIRO_COMMAND = "kiro-cli"
|
||||
_KIRO_PATH_ENV = "OMNIGENT_KIRO_PATH"
|
||||
_AGENT_NAME = "kiro-native-ui"
|
||||
_TERMINAL_NAME = "kiro"
|
||||
_TERMINAL_SESSION_KEY = "main"
|
||||
_TMUX_ATTACH_ENV_ALLOWLIST = (
|
||||
"COLORTERM",
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"LOGNAME",
|
||||
"PATH",
|
||||
"SHELL",
|
||||
"TERM",
|
||||
"TERM_PROGRAM",
|
||||
"TMPDIR",
|
||||
"USER",
|
||||
)
|
||||
_SESSION_LABELS = {
|
||||
"omnigent.ui": "terminal",
|
||||
_WRAPPER_LABEL_KEY: _WRAPPER_LABEL_VALUE,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NativeKiroLaunch:
|
||||
"""Resolved native Kiro process launch."""
|
||||
|
||||
executable: str
|
||||
argv: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LaunchedKiroTerminal:
|
||||
"""Terminal resource returned by the Omnigent runner launch path."""
|
||||
|
||||
terminal_id: str
|
||||
tmux_socket: Path | None
|
||||
tmux_target: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedKiroTerminal:
|
||||
"""Prepared native Kiro terminal attachment details."""
|
||||
|
||||
session_id: str
|
||||
terminal_id: str
|
||||
tmux_socket: Path | None
|
||||
tmux_target: str | None
|
||||
reattached: bool
|
||||
cold_resumed: bool = False
|
||||
|
||||
|
||||
def _configured_kiro_command(env: Mapping[str, str]) -> str:
|
||||
"""Return the configured kiro-cli executable name/path from *env*."""
|
||||
value = env.get(_KIRO_PATH_ENV, "").strip()
|
||||
return value or _DEFAULT_KIRO_COMMAND
|
||||
|
||||
|
||||
def resolve_kiro_executable(
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
which: Callable[[str], str | None] | None = None,
|
||||
) -> str:
|
||||
"""Resolve the native Kiro (``kiro-cli``) executable."""
|
||||
env = os.environ if env is None else env
|
||||
which = shutil.which if which is None else which
|
||||
command = _configured_kiro_command(env)
|
||||
resolved = which(command)
|
||||
if resolved is None:
|
||||
raise click.ClickException(
|
||||
"Native Kiro requires the 'kiro-cli' CLI on PATH. Install and login to "
|
||||
f"Kiro, or set {_KIRO_PATH_ENV}=/path/to/kiro-cli."
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def build_kiro_launch(
|
||||
kiro_args: Sequence[str],
|
||||
*,
|
||||
model: str | None = None,
|
||||
prompt: str | None = None,
|
||||
resume_id: str | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
which: Callable[[str], str | None] | None = None,
|
||||
) -> NativeKiroLaunch:
|
||||
"""Build the argv for a native Kiro TUI process."""
|
||||
executable = resolve_kiro_executable(env=env, which=which)
|
||||
argv = [executable, "chat", "--tui"]
|
||||
if resume_id:
|
||||
argv.extend(["--resume-id", resume_id])
|
||||
if model:
|
||||
argv.extend(["--model", model])
|
||||
argv.extend(kiro_args)
|
||||
if prompt:
|
||||
argv.append(prompt)
|
||||
return NativeKiroLaunch(executable=executable, argv=argv)
|
||||
|
||||
|
||||
def run_kiro_native(
|
||||
*,
|
||||
server: str | None,
|
||||
session_id: str | None,
|
||||
kiro_args: tuple[str, ...],
|
||||
resume_picker: bool = False,
|
||||
model: str | None = None,
|
||||
prompt: str | None = None,
|
||||
auto_open_conversation: bool = False,
|
||||
) -> None:
|
||||
"""Launch the Kiro TUI in an Omnigent terminal."""
|
||||
_preflight_local_tools()
|
||||
if server is None:
|
||||
raise click.ClickException(
|
||||
"Kiro requires a resolved Omnigent server URL. The CLI should call "
|
||||
"_ensure_backend before run_kiro_native."
|
||||
)
|
||||
with TemporaryDirectory(prefix="omnigent-kiro-native-") as tmpdir:
|
||||
spec_path = _materialize_kiro_agent_spec(Path(tmpdir), model=model)
|
||||
_run_with_remote_server(
|
||||
server.rstrip("/"),
|
||||
spec_path,
|
||||
session_id=session_id,
|
||||
resume_picker=resume_picker,
|
||||
kiro_args=kiro_args,
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
)
|
||||
|
||||
|
||||
def _materialize_kiro_agent_spec(tmpdir: Path, *, model: str | None = None) -> Path:
|
||||
"""Write the terminal-first agent spec used by ``omnigent kiro``."""
|
||||
yaml_path = tmpdir / "kiro-native-ui.yaml"
|
||||
executor: dict[str, str] = {"harness": "kiro-native"}
|
||||
if model:
|
||||
executor["model"] = model
|
||||
raw: dict[str, Any] = {
|
||||
"name": _AGENT_NAME,
|
||||
"prompt": (
|
||||
"Kiro is running in the session terminal. The user drives the kiro-cli TUI directly."
|
||||
),
|
||||
"executor": executor,
|
||||
"spawn": True,
|
||||
"os_env": {
|
||||
"type": "caller_process",
|
||||
"cwd": ".",
|
||||
"sandbox": {"type": "none"},
|
||||
},
|
||||
"terminals": {
|
||||
"shell": {
|
||||
"command": "bash",
|
||||
"allow_cwd_override": True,
|
||||
"os_env": {
|
||||
"type": "caller_process",
|
||||
"cwd": ".",
|
||||
"sandbox": {"type": "none"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8")
|
||||
return yaml_path
|
||||
|
||||
|
||||
def _run_with_remote_server(
|
||||
base_url: str,
|
||||
spec_path: Path,
|
||||
*,
|
||||
session_id: str | None,
|
||||
resume_picker: bool,
|
||||
kiro_args: tuple[str, ...],
|
||||
model: str | None = None,
|
||||
prompt: str | None = None,
|
||||
auto_open_conversation: bool = False,
|
||||
) -> None:
|
||||
"""Launch Kiro on an Omnigent server via a daemon-spawned runner."""
|
||||
from omnigent.chat import _bundle_agent, _remote_headers
|
||||
from omnigent.cli import _ensure_host_daemon
|
||||
from omnigent.host.identity import load_or_create_host_identity
|
||||
|
||||
headers = _remote_headers(server_url=base_url)
|
||||
try:
|
||||
resolved_session_id = _resolve_session_id_for_resume(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
session_id=session_id,
|
||||
resume_picker=resume_picker,
|
||||
)
|
||||
if resolved_session_id is None and resume_picker and session_id is None:
|
||||
return
|
||||
|
||||
async def _drive() -> None:
|
||||
with runner_startup_progress(initial_message="Preparing Kiro...") as progress:
|
||||
_update_startup_progress(progress, "Connecting to local daemon...")
|
||||
_ensure_host_daemon(base_url)
|
||||
host_id = load_or_create_host_identity().host_id
|
||||
bundle = None if resolved_session_id is not None else _bundle_agent(spec_path)
|
||||
prepared = await _prepare_kiro_terminal_via_daemon(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
session_id=resolved_session_id,
|
||||
session_bundle=bundle,
|
||||
kiro_args=kiro_args,
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
host_id=host_id,
|
||||
workspace=str(Path.cwd().resolve()),
|
||||
startup_progress=progress,
|
||||
)
|
||||
click.echo(f"Web UI: {conversation_url(base_url, prepared.session_id)}", err=True)
|
||||
open_conversation_link_if_enabled(
|
||||
base_url=base_url,
|
||||
conversation_id=prepared.session_id,
|
||||
enabled=auto_open_conversation,
|
||||
warn=lambda message: click.echo(message, err=True),
|
||||
)
|
||||
if prepared.cold_resumed:
|
||||
echo_native_cold_resume_hint(agent_label="Kiro")
|
||||
await _attach_terminal_resource(prepared)
|
||||
if resolved_session_id is None:
|
||||
echo_native_resume_hint(
|
||||
native_command="kiro",
|
||||
session_id=prepared.session_id,
|
||||
server=base_url,
|
||||
)
|
||||
|
||||
asyncio.run(_drive())
|
||||
except httpx.ConnectError as exc:
|
||||
raise click.ClickException(
|
||||
f"Could not reach the omnigent server at {base_url}. "
|
||||
"Confirm the server is running and reachable from here "
|
||||
f"(e.g. `curl {base_url}/health`), and that --server is correct."
|
||||
) from exc
|
||||
|
||||
|
||||
async def _prepare_kiro_terminal_via_daemon(
|
||||
*,
|
||||
base_url: str,
|
||||
headers: dict[str, str],
|
||||
session_id: str | None,
|
||||
session_bundle: bytes | None,
|
||||
kiro_args: tuple[str, ...],
|
||||
model: str | None,
|
||||
prompt: str | None,
|
||||
host_id: str,
|
||||
workspace: str,
|
||||
startup_progress: RunnerStartupProgress | None = None,
|
||||
) -> PreparedKiroTerminal:
|
||||
"""Create or resume a kiro-native session through a daemon runner."""
|
||||
persist_args = list(kiro_args)
|
||||
if model:
|
||||
persist_args[:0] = ["--model", model]
|
||||
if prompt:
|
||||
persist_args.append(prompt)
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=timeout) as client:
|
||||
reattached = False
|
||||
cold_resumed = False
|
||||
if session_id is None:
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Kiro session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Kiro session...")
|
||||
session_id = await _create_kiro_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Kiro session...")
|
||||
payload = await _fetch_kiro_session(client, session_id)
|
||||
labels = payload.get("labels") if isinstance(payload, dict) else None
|
||||
if (
|
||||
not isinstance(labels, dict)
|
||||
or labels.get(_WRAPPER_LABEL_KEY) != _WRAPPER_LABEL_VALUE
|
||||
):
|
||||
raise click.ClickException(
|
||||
f"Conversation {session_id!r} is not a kiro-native session."
|
||||
)
|
||||
existing_terminal = await _find_running_kiro_terminal(client, session_id)
|
||||
if existing_terminal is not None:
|
||||
if persist_args:
|
||||
click.echo(
|
||||
"Ignoring Kiro launch args for an already-running terminal; "
|
||||
"restart the session terminal to apply them.",
|
||||
err=True,
|
||||
)
|
||||
_update_startup_progress(startup_progress, "Kiro terminal ready.")
|
||||
return PreparedKiroTerminal(
|
||||
session_id=session_id,
|
||||
terminal_id=existing_terminal.terminal_id,
|
||||
tmux_socket=existing_terminal.tmux_socket,
|
||||
tmux_target=existing_terminal.tmux_target,
|
||||
reattached=True,
|
||||
)
|
||||
cold_resumed = True
|
||||
if persist_args:
|
||||
_update_startup_progress(startup_progress, "Updating Kiro session...")
|
||||
resp = await client.patch(
|
||||
f"/v1/sessions/{url_component(session_id)}",
|
||||
json={"terminal_launch_args": persist_args},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise click.ClickException(
|
||||
f"Kiro session launch config update failed "
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
host_id=host_id,
|
||||
session_id=session_id,
|
||||
workspace=workspace,
|
||||
)
|
||||
_update_startup_progress(startup_progress, "Waiting for runner...")
|
||||
await wait_for_runner_online(client, runner_id, timeout_s=_DAEMON_RUNNER_ONLINE_TIMEOUT_S)
|
||||
await _bind_session_runner(client, session_id, runner_id)
|
||||
_update_startup_progress(startup_progress, "Starting Kiro terminal...")
|
||||
await _ensure_kiro_terminal_on_runner(client, session_id)
|
||||
terminal = await _wait_for_kiro_terminal_ready(
|
||||
client,
|
||||
session_id,
|
||||
timeout_s=_DAEMON_TERMINAL_READY_TIMEOUT_S,
|
||||
)
|
||||
_update_startup_progress(startup_progress, "Kiro terminal ready.")
|
||||
return PreparedKiroTerminal(
|
||||
session_id=session_id,
|
||||
terminal_id=terminal.terminal_id,
|
||||
tmux_socket=terminal.tmux_socket,
|
||||
tmux_target=terminal.tmux_target,
|
||||
reattached=reattached,
|
||||
cold_resumed=cold_resumed,
|
||||
)
|
||||
|
||||
|
||||
async def _create_kiro_session(
|
||||
client: httpx.AsyncClient,
|
||||
bundle: bytes,
|
||||
*,
|
||||
terminal_launch_args: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Create a bundled terminal-first kiro-native session."""
|
||||
metadata: dict[str, Any] = {"labels": dict(_SESSION_LABELS)}
|
||||
if terminal_launch_args:
|
||||
metadata["terminal_launch_args"] = terminal_launch_args
|
||||
resp = await client.post(
|
||||
"/v1/sessions",
|
||||
data={"metadata": json.dumps(metadata)},
|
||||
files={"bundle": ("kiro-native-ui.tar.gz", bundle, "application/gzip")},
|
||||
timeout=120.0,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise click.ClickException(
|
||||
f"Kiro session creation failed ({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
body = resp.json()
|
||||
new_session_id = body.get("session_id")
|
||||
if not isinstance(new_session_id, str) or not new_session_id:
|
||||
raise click.ClickException("Kiro session creation response did not include session_id.")
|
||||
return new_session_id
|
||||
|
||||
|
||||
async def _fetch_kiro_session(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]:
|
||||
"""Fetch an existing Omnigent session."""
|
||||
resp = await client.get(f"/v1/sessions/{url_component(session_id)}")
|
||||
if resp.status_code == 404:
|
||||
raise click.ClickException(f"Conversation {session_id!r} not found on the server.")
|
||||
if resp.status_code >= 400:
|
||||
raise click.ClickException(
|
||||
f"Failed to fetch conversation {session_id!r} ({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
payload = resp.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise click.ClickException("Conversation fetch returned non-object JSON.")
|
||||
return payload
|
||||
|
||||
|
||||
async def _ensure_kiro_terminal_on_runner(client: httpx.AsyncClient, session_id: str) -> None:
|
||||
"""Ask the bound runner to ensure the Kiro terminal exists."""
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{url_component(session_id)}/resources/terminals",
|
||||
json={
|
||||
"terminal": _TERMINAL_NAME,
|
||||
"session_key": _TERMINAL_SESSION_KEY,
|
||||
"ensure_native_terminal": True,
|
||||
},
|
||||
timeout=60.0,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise click.ClickException(
|
||||
f"Kiro terminal ensure failed ({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
|
||||
async def _wait_for_kiro_terminal_ready(
|
||||
client: httpx.AsyncClient,
|
||||
session_id: str,
|
||||
*,
|
||||
timeout_s: float,
|
||||
) -> LaunchedKiroTerminal:
|
||||
"""Wait until the runner exposes the Kiro terminal resource."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout_s
|
||||
while loop.time() < deadline:
|
||||
terminal = await _find_running_kiro_terminal(client, session_id)
|
||||
if terminal is not None:
|
||||
return terminal
|
||||
await asyncio.sleep(0.2)
|
||||
raise click.ClickException(
|
||||
f"The runner did not create the Kiro terminal for {session_id!r} within {timeout_s:.0f}s."
|
||||
)
|
||||
|
||||
|
||||
async def _find_running_kiro_terminal(
|
||||
client: httpx.AsyncClient,
|
||||
session_id: str,
|
||||
) -> LaunchedKiroTerminal | None:
|
||||
"""Return the existing running Kiro terminal id if present."""
|
||||
terminal_id = kiro_terminal_resource_id()
|
||||
resp = await client.get(
|
||||
f"/v1/sessions/{url_component(session_id)}"
|
||||
f"/resources/terminals/{url_component(terminal_id)}"
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
if resp.status_code >= 400:
|
||||
text = error_text(resp)
|
||||
if resp.status_code in {409, 503} and (
|
||||
"not bound to a runner" in text or "offline" in text
|
||||
):
|
||||
return None
|
||||
raise click.ClickException(f"Failed to fetch Kiro terminal ({resp.status_code}): {text}")
|
||||
payload = resp.json()
|
||||
metadata = payload.get("metadata") if isinstance(payload, dict) else None
|
||||
if isinstance(metadata, dict) and metadata.get("running") is False:
|
||||
return None
|
||||
return _launched_kiro_terminal_from_payload(payload)
|
||||
|
||||
|
||||
def _launched_kiro_terminal_from_payload(payload: object) -> LaunchedKiroTerminal:
|
||||
"""Decode terminal launch metadata returned by the runner."""
|
||||
if not isinstance(payload, dict):
|
||||
raise click.ClickException("Kiro terminal launch returned non-object JSON.")
|
||||
terminal_id = payload.get("id")
|
||||
if not isinstance(terminal_id, str) or not terminal_id:
|
||||
raise click.ClickException("Kiro terminal launch response did not include terminal id.")
|
||||
metadata = payload.get("metadata")
|
||||
tmux_socket: Path | None = None
|
||||
tmux_target: str | None = None
|
||||
if isinstance(metadata, dict):
|
||||
raw_socket = metadata.get("tmux_socket")
|
||||
raw_target = metadata.get("tmux_target")
|
||||
if isinstance(raw_socket, str) and raw_socket:
|
||||
tmux_socket = Path(raw_socket)
|
||||
if isinstance(raw_target, str) and raw_target:
|
||||
tmux_target = raw_target
|
||||
return LaunchedKiroTerminal(
|
||||
terminal_id=terminal_id,
|
||||
tmux_socket=tmux_socket,
|
||||
tmux_target=tmux_target,
|
||||
)
|
||||
|
||||
|
||||
async def _attach_terminal_resource(prepared: PreparedKiroTerminal) -> None:
|
||||
"""Attach the current terminal to the prepared Kiro terminal resource."""
|
||||
direct_tmux_error = _direct_tmux_unavailable_reason(prepared)
|
||||
if direct_tmux_error is not None:
|
||||
raise click.ClickException(
|
||||
f"Runner-owned Kiro terminal requires direct tmux attach, but {direct_tmux_error}"
|
||||
)
|
||||
if prepared.tmux_socket is None or prepared.tmux_target is None:
|
||||
raise click.ClickException("Kiro tmux attach metadata was incomplete.")
|
||||
await _attach_direct_tmux(prepared.tmux_socket, prepared.tmux_target)
|
||||
|
||||
|
||||
async def _attach_direct_tmux(socket_path: Path, tmux_target: str) -> None:
|
||||
"""Attach the current terminal directly to the runner-owned tmux pane."""
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"-S",
|
||||
str(socket_path),
|
||||
"-f",
|
||||
os.devnull,
|
||||
"attach",
|
||||
"-t",
|
||||
tmux_target,
|
||||
env=_tmux_attach_env(),
|
||||
)
|
||||
await process.wait()
|
||||
|
||||
|
||||
def _tmux_attach_env() -> dict[str, str]:
|
||||
"""Return the small local environment needed by ``tmux attach``."""
|
||||
return {key: os.environ[key] for key in _TMUX_ATTACH_ENV_ALLOWLIST if os.environ.get(key)}
|
||||
|
||||
|
||||
def _direct_tmux_unavailable_reason(prepared: PreparedKiroTerminal) -> str | None:
|
||||
"""Explain why direct tmux attach is unavailable."""
|
||||
if prepared.tmux_socket is None:
|
||||
return "the terminal resource did not include a tmux socket path."
|
||||
if prepared.tmux_target is None:
|
||||
return "the terminal resource did not include a tmux target."
|
||||
if not prepared.tmux_socket.exists():
|
||||
return f"tmux socket {prepared.tmux_socket} is not reachable from this CLI process."
|
||||
if shutil.which("tmux") is None:
|
||||
return "tmux is not available on PATH."
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_session_id_for_resume(
|
||||
*,
|
||||
base_url: str,
|
||||
headers: dict[str, str],
|
||||
session_id: str | None,
|
||||
resume_picker: bool,
|
||||
) -> str | None:
|
||||
"""Translate resume inputs into a concrete kiro-native session id."""
|
||||
if session_id is not None:
|
||||
return session_id
|
||||
if not resume_picker:
|
||||
return None
|
||||
from omnigent_client import OmnigentClient
|
||||
|
||||
from omnigent.repl._resume_picker import pick_conversation_by_wrapper_label_from_sdk
|
||||
|
||||
async def _drive() -> str | None:
|
||||
async with OmnigentClient(
|
||||
base_url=base_url,
|
||||
headers=headers if headers else None,
|
||||
) as client:
|
||||
return await pick_conversation_by_wrapper_label_from_sdk(
|
||||
client,
|
||||
wrapper_value=_WRAPPER_LABEL_VALUE,
|
||||
agent_name=_AGENT_NAME,
|
||||
)
|
||||
|
||||
return asyncio.run(_drive())
|
||||
|
||||
|
||||
def _update_startup_progress(
|
||||
startup_progress: RunnerStartupProgress | None,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Show one concise Kiro startup milestone when a renderer is active."""
|
||||
if startup_progress is not None:
|
||||
startup_progress.update(message)
|
||||
|
||||
|
||||
def _preflight_local_tools() -> None:
|
||||
"""Verify local executables required by the native Kiro wrapper."""
|
||||
if shutil.which("tmux") is None:
|
||||
raise click.ClickException(
|
||||
"tmux was not found on local PATH. The native Kiro wrapper "
|
||||
"attaches to the runner-owned Kiro tmux terminal."
|
||||
)
|
||||
|
||||
|
||||
def kiro_terminal_resource_id() -> str:
|
||||
"""Return the deterministic terminal resource id for Kiro."""
|
||||
return terminal_resource_id(_TERMINAL_NAME, _TERMINAL_SESSION_KEY)
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Bridge utilities for native Kiro TUI sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
KIRO_NATIVE_BRIDGE_DIR_ENV_VAR = "HARNESS_KIRO_NATIVE_BRIDGE_DIR"
|
||||
|
||||
_BRIDGE_ROOT = Path(os.environ.get("TMPDIR", "/tmp")) / f"omnigent-{os.getuid()}" / "kiro-native"
|
||||
_TMUX_FILE = "tmux.json"
|
||||
_FORWARDER_READY_FILE = "kiro_session_forwarder_ready.json"
|
||||
_TMUX_READY_TIMEOUT_S = 30.0
|
||||
_TMUX_SEND_TIMEOUT_S = 10.0
|
||||
_POLL_INTERVAL_S = 0.2
|
||||
_TYPE_SETTLE_S = 0.3
|
||||
_TYPE_COMMIT_TIMEOUT_S = 5.0
|
||||
_SUBMIT_VERIFY_TIMEOUT_S = 5.0
|
||||
_SUBMIT_RETRY_INTERVAL_S = 0.5
|
||||
_KIRO_SEPARATOR = "────"
|
||||
_KIRO_INPUT_READY_MARKERS = (
|
||||
"ask a question or describe a task",
|
||||
"Type to steer",
|
||||
)
|
||||
_SEND_KEYS_LITERAL_CHARS_PER_CALL = 1024
|
||||
|
||||
# Ambient provider/cloud/CI credentials that must not be inherited by Kiro.
|
||||
KIRO_NATIVE_ENV_UNSET = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_SESSION_TOKEN",
|
||||
"AZURE_CLIENT_SECRET",
|
||||
"CI",
|
||||
"DATABRICKS_CLIENT_SECRET",
|
||||
"DATABRICKS_CONFIG_PROFILE",
|
||||
"DATABRICKS_HOST",
|
||||
"DATABRICKS_TOKEN",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"GOOGLE_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
]
|
||||
|
||||
_CHILD_ENV_ALLOWLIST = [
|
||||
"COLORTERM",
|
||||
"HOME",
|
||||
"KIRO_CONFIG_HOME",
|
||||
"KIRO_HOME",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"LOGNAME",
|
||||
"NO_COLOR",
|
||||
"PATH",
|
||||
"SHELL",
|
||||
"TERM",
|
||||
"TMPDIR",
|
||||
"USER",
|
||||
]
|
||||
|
||||
|
||||
def bridge_dir_for_session_id(session_id: str) -> Path:
|
||||
"""Return the per-session Kiro bridge directory."""
|
||||
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:32]
|
||||
return _BRIDGE_ROOT / digest
|
||||
|
||||
|
||||
def prepare_bridge_dir(session_id: str) -> Path:
|
||||
"""Create and return the per-session Kiro bridge directory."""
|
||||
bridge_dir = bridge_dir_for_session_id(session_id)
|
||||
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(bridge_dir, 0o700)
|
||||
return bridge_dir
|
||||
|
||||
|
||||
def build_kiro_native_spawn_env(session_id: str) -> dict[str, str]:
|
||||
"""Build the ``HARNESS_KIRO_NATIVE_*`` env for the harness executor."""
|
||||
bridge_dir = prepare_bridge_dir(session_id)
|
||||
return {KIRO_NATIVE_BRIDGE_DIR_ENV_VAR: str(bridge_dir)}
|
||||
|
||||
|
||||
def build_kiro_native_terminal_env(
|
||||
session_id: str,
|
||||
*,
|
||||
source_env: dict[str, str] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Build the allowlisted child environment for ``kiro-cli``."""
|
||||
env = os.environ if source_env is None else source_env
|
||||
child = {key: env[key] for key in _CHILD_ENV_ALLOWLIST if env.get(key)}
|
||||
child[KIRO_NATIVE_BRIDGE_DIR_ENV_VAR] = str(prepare_bridge_dir(session_id))
|
||||
return child
|
||||
|
||||
|
||||
def write_tmux_target(
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
socket_path: Path,
|
||||
tmux_target: str,
|
||||
pid: int | None = None,
|
||||
requires_forwarder_ready: bool = False,
|
||||
) -> None:
|
||||
"""Advertise the tmux socket + target for the running Kiro terminal."""
|
||||
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
payload: dict[str, Any] = {
|
||||
"socket_path": str(socket_path),
|
||||
"tmux_target": tmux_target,
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
if requires_forwarder_ready:
|
||||
payload["requires_forwarder_ready"] = True
|
||||
if pid is not None:
|
||||
payload["pid"] = pid
|
||||
tmp = bridge_dir / (_TMUX_FILE + ".tmp")
|
||||
tmp.write_text(json.dumps(payload), encoding="utf-8")
|
||||
os.replace(tmp, bridge_dir / _TMUX_FILE)
|
||||
|
||||
|
||||
def read_tmux_info(bridge_dir: Path) -> dict[str, str] | None:
|
||||
"""Return ``{socket_path, tmux_target}`` from ``tmux.json``, or ``None``."""
|
||||
try:
|
||||
raw = (bridge_dir / _TMUX_FILE).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
socket_path = data.get("socket_path")
|
||||
tmux_target = data.get("tmux_target")
|
||||
if (
|
||||
isinstance(socket_path, str)
|
||||
and socket_path
|
||||
and isinstance(tmux_target, str)
|
||||
and tmux_target
|
||||
):
|
||||
return {"socket_path": socket_path, "tmux_target": tmux_target}
|
||||
return None
|
||||
|
||||
|
||||
def write_forwarder_ready(bridge_dir: Path) -> None:
|
||||
"""Mark the Kiro JSONL forwarder as attached and caught up."""
|
||||
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
payload = {"updated_at": time.time()}
|
||||
tmp = bridge_dir / (_FORWARDER_READY_FILE + ".tmp")
|
||||
tmp.write_text(json.dumps(payload), encoding="utf-8")
|
||||
os.replace(tmp, bridge_dir / _FORWARDER_READY_FILE)
|
||||
|
||||
|
||||
def _read_bridge_json(bridge_dir: Path, filename: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
raw = (bridge_dir / filename).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _wait_for_forwarder_ready_if_required(
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
tmux_info: dict[str, Any],
|
||||
timeout_s: float,
|
||||
) -> None:
|
||||
if tmux_info.get("requires_forwarder_ready") is not True:
|
||||
return
|
||||
tmux_updated_at = tmux_info.get("updated_at")
|
||||
if not isinstance(tmux_updated_at, int | float):
|
||||
tmux_updated_at = 0.0
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
ready = _read_bridge_json(bridge_dir, _FORWARDER_READY_FILE)
|
||||
ready_updated_at = ready.get("updated_at") if ready is not None else None
|
||||
if isinstance(ready_updated_at, int | float) and ready_updated_at >= tmux_updated_at:
|
||||
return
|
||||
time.sleep(_POLL_INTERVAL_S)
|
||||
raise RuntimeError("kiro-native session forwarder was not ready before injection")
|
||||
|
||||
|
||||
def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
info = read_tmux_info(bridge_dir)
|
||||
if info is not None:
|
||||
return info
|
||||
time.sleep(_POLL_INTERVAL_S)
|
||||
raise RuntimeError(f"kiro-native tmux target was not advertised within {timeout_s:.0f}s")
|
||||
|
||||
|
||||
def _run_tmux(socket_path: str, *args: str) -> None:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["tmux", "-S", socket_path, *args],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_TMUX_SEND_TIMEOUT_S,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(f"tmux command timed out after {_TMUX_SEND_TIMEOUT_S}s") from exc
|
||||
if proc.returncode != 0:
|
||||
detail = proc.stderr.strip() or proc.stdout.strip() or "<no output>"
|
||||
raise RuntimeError(f"tmux command failed (rc={proc.returncode}): {detail}")
|
||||
|
||||
|
||||
def _session_alive(socket_path: str, tmux_target: str) -> bool:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["tmux", "-S", socket_path, "has-session", "-t", tmux_target],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_TMUX_SEND_TIMEOUT_S,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
return proc.returncode == 0
|
||||
|
||||
|
||||
def _capture_pane(socket_path: str, tmux_target: str) -> str:
|
||||
"""Capture visible pane contents; return empty string on failure."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["tmux", "-S", socket_path, "capture-pane", "-p", "-t", tmux_target],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_TMUX_SEND_TIMEOUT_S,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return ""
|
||||
return proc.stdout if proc.returncode == 0 else ""
|
||||
|
||||
|
||||
def _submit_needle(content: str) -> str:
|
||||
"""Return a small marker used to identify the pasted draft."""
|
||||
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
for line in normalized.split("\n"):
|
||||
for idx, ch in enumerate(line):
|
||||
if ord(ch) < 0x20:
|
||||
line = line[:idx]
|
||||
break
|
||||
line = line.strip()
|
||||
if line:
|
||||
return line[:24]
|
||||
return ""
|
||||
|
||||
|
||||
def _kiro_input_region(pane: str) -> str:
|
||||
"""Return Kiro's bottom input region, excluding transcript history."""
|
||||
lines = pane.splitlines()
|
||||
for index in range(len(lines) - 1, -1, -1):
|
||||
if _KIRO_SEPARATOR in lines[index]:
|
||||
return "\n".join(lines[index + 1 :])
|
||||
return "\n".join(lines[-8:])
|
||||
|
||||
|
||||
def _draft_in_input_region(pane: str, needle: str, baseline_region: str) -> bool:
|
||||
"""Return whether the draft is still visible in Kiro's input region."""
|
||||
region = _kiro_input_region(pane)
|
||||
if not needle or region == baseline_region:
|
||||
return False
|
||||
normalized_needle = needle.strip()
|
||||
if not normalized_needle:
|
||||
return False
|
||||
return any(
|
||||
line == normalized_needle or line.startswith(normalized_needle)
|
||||
for line in _kiro_draft_candidate_lines(region)
|
||||
)
|
||||
|
||||
|
||||
def _kiro_draft_candidate_lines(region: str) -> list[str]:
|
||||
"""Return input-region lines that can represent editable draft text."""
|
||||
candidates: list[str] = []
|
||||
for raw_line in region.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("kiro_default"):
|
||||
continue
|
||||
if line.startswith("/copy"):
|
||||
continue
|
||||
if line.startswith("▸ Credits:"):
|
||||
continue
|
||||
if any(marker in line for marker in _KIRO_INPUT_READY_MARKERS):
|
||||
continue
|
||||
candidates.append(line)
|
||||
return candidates
|
||||
|
||||
|
||||
def _kiro_input_ready(pane: str) -> bool:
|
||||
"""Return whether Kiro's bottom input prompt is ready to receive text."""
|
||||
region = _kiro_input_region(pane)
|
||||
return any(marker in region for marker in _KIRO_INPUT_READY_MARKERS)
|
||||
|
||||
|
||||
def _wait_for_kiro_input_ready(
|
||||
socket_path: str,
|
||||
tmux_target: str,
|
||||
*,
|
||||
timeout_s: float,
|
||||
) -> None:
|
||||
"""Wait until Kiro has rendered an input prompt before typing."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
if _kiro_input_ready(_capture_pane(socket_path, tmux_target)):
|
||||
return
|
||||
time.sleep(_POLL_INTERVAL_S)
|
||||
raise RuntimeError("kiro-native TUI input prompt was not ready before injection")
|
||||
|
||||
|
||||
def _type_literal_text(socket_path: str, tmux_target: str, text: str) -> None:
|
||||
"""Type text into Kiro using literal tmux keystrokes."""
|
||||
for start in range(0, len(text), _SEND_KEYS_LITERAL_CHARS_PER_CALL):
|
||||
chunk = text[start : start + _SEND_KEYS_LITERAL_CHARS_PER_CALL]
|
||||
# ``--`` ends option parsing so a chunk starting with ``-`` (or a chunk
|
||||
# boundary that lands on one) is sent as literal text, not parsed as a
|
||||
# tmux flag — which would otherwise fail the send-keys call silently.
|
||||
_run_tmux(
|
||||
socket_path,
|
||||
"send-keys",
|
||||
"-l",
|
||||
"-t",
|
||||
tmux_target,
|
||||
"--",
|
||||
chunk,
|
||||
)
|
||||
|
||||
|
||||
def inject_user_message(
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
content: str,
|
||||
timeout_s: float = _TMUX_READY_TIMEOUT_S,
|
||||
) -> None:
|
||||
"""Deliver a web-UI user message into the Kiro TUI via tmux typing."""
|
||||
if not content:
|
||||
raise RuntimeError("kiro-native injection requires non-empty content")
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||||
raw_info = _read_bridge_json(bridge_dir, _TMUX_FILE) or {}
|
||||
_wait_for_forwarder_ready_if_required(
|
||||
bridge_dir,
|
||||
tmux_info=raw_info,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
socket_path = info["socket_path"]
|
||||
tmux_target = info["tmux_target"]
|
||||
if not _session_alive(socket_path, tmux_target):
|
||||
raise RuntimeError(
|
||||
"kiro terminal is no longer running (the TUI exited); restart the session"
|
||||
)
|
||||
_wait_for_kiro_input_ready(socket_path, tmux_target, timeout_s=timeout_s)
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "C-a")
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "C-k")
|
||||
baseline_region = _kiro_input_region(_capture_pane(socket_path, tmux_target))
|
||||
_type_literal_text(socket_path, tmux_target, content)
|
||||
needle = _submit_needle(content)
|
||||
draft_seen = False
|
||||
if needle:
|
||||
deadline = time.monotonic() + _TYPE_COMMIT_TIMEOUT_S
|
||||
while time.monotonic() < deadline:
|
||||
if _draft_in_input_region(
|
||||
_capture_pane(socket_path, tmux_target), needle, baseline_region
|
||||
):
|
||||
draft_seen = True
|
||||
break
|
||||
time.sleep(_POLL_INTERVAL_S)
|
||||
time.sleep(_TYPE_SETTLE_S)
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
|
||||
if not draft_seen:
|
||||
return
|
||||
deadline = time.monotonic() + _SUBMIT_VERIFY_TIMEOUT_S
|
||||
last_enter = time.monotonic()
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(_POLL_INTERVAL_S)
|
||||
if not _draft_in_input_region(
|
||||
_capture_pane(socket_path, tmux_target), needle, baseline_region
|
||||
):
|
||||
return
|
||||
if time.monotonic() - last_enter >= _SUBMIT_RETRY_INTERVAL_S:
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
|
||||
last_enter = time.monotonic()
|
||||
raise RuntimeError("Kiro did not accept the submitted message; the draft is still visible")
|
||||
@@ -0,0 +1,476 @@
|
||||
"""Structured session forwarder for the kiro-native harness.
|
||||
|
||||
Kiro CLI persists chat turns under ``~/.kiro/sessions/cli`` as session metadata
|
||||
plus JSONL message records. The native Kiro terminal path injects web prompts
|
||||
into the TUI; this forwarder mirrors Kiro's persisted assistant messages back
|
||||
into the Omnigent conversation with ``external_conversation_item`` events.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from omnigent.kiro_native_bridge import write_forwarder_ready
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_POLL_INTERVAL_S = 0.7
|
||||
_POST_TIMEOUT_S = 30.0
|
||||
_DISCOVERY_SKEW_MS = 10_000
|
||||
_STATE_FILE = "kiro_session_forwarder.json"
|
||||
|
||||
_SUPERVISOR_INITIAL_BACKOFF_S = 1.0
|
||||
_SUPERVISOR_MAX_BACKOFF_S = 30.0
|
||||
_SUPERVISOR_HEALTHY_UPTIME_S = 60.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ForwardState:
|
||||
"""Durable cursor for one Kiro JSONL session file."""
|
||||
|
||||
session_id: str | None = None
|
||||
byte_offset: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _KiroConversationMessage:
|
||||
"""One conversation message parsed from Kiro's JSONL store."""
|
||||
|
||||
message_id: str
|
||||
role: str
|
||||
text: str
|
||||
|
||||
|
||||
def _kiro_cli_sessions_dir(home: Path | None = None) -> Path:
|
||||
"""Return Kiro CLI's session directory for this user."""
|
||||
return (home or Path.home()) / ".kiro" / "sessions" / "cli"
|
||||
|
||||
|
||||
def _read_state(bridge_dir: Path) -> _ForwardState:
|
||||
"""Load the persisted forward cursor, or a cold default."""
|
||||
try:
|
||||
data = json.loads((bridge_dir / _STATE_FILE).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return _ForwardState()
|
||||
session_id = data.get("session_id")
|
||||
byte_offset = data.get("byte_offset")
|
||||
return _ForwardState(
|
||||
session_id=session_id if isinstance(session_id, str) and session_id else None,
|
||||
byte_offset=byte_offset if isinstance(byte_offset, int) and byte_offset >= 0 else 0,
|
||||
)
|
||||
|
||||
|
||||
def _write_state(bridge_dir: Path, state: _ForwardState) -> None:
|
||||
"""Persist the forward cursor atomically."""
|
||||
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(bridge_dir, 0o700)
|
||||
tmp = bridge_dir / (_STATE_FILE + ".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps({"session_id": state.session_id, "byte_offset": state.byte_offset}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, bridge_dir / _STATE_FILE)
|
||||
|
||||
|
||||
def _parse_iso_epoch_ms(value: object) -> int:
|
||||
"""Parse Kiro's ISO timestamp string into epoch milliseconds."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return 0
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return 0
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return int(parsed.timestamp() * 1000)
|
||||
|
||||
|
||||
def _same_workspace(left: object, right: str) -> bool:
|
||||
"""Return whether Kiro metadata cwd matches the runner workspace."""
|
||||
if not isinstance(left, str) or not left:
|
||||
return False
|
||||
try:
|
||||
return Path(left).expanduser().resolve() == Path(right).expanduser().resolve()
|
||||
except OSError:
|
||||
return left == right
|
||||
|
||||
|
||||
def _discover_kiro_session_jsonl(
|
||||
*,
|
||||
workspace: str,
|
||||
launch_epoch_ms: int,
|
||||
sessions_dir: Path | None = None,
|
||||
) -> tuple[str, Path] | None:
|
||||
"""Find this Omnigent session's Kiro JSONL file."""
|
||||
root = sessions_dir or _kiro_cli_sessions_dir()
|
||||
if not root.is_dir():
|
||||
return None
|
||||
floor_ms = max(0, launch_epoch_ms - _DISCOVERY_SKEW_MS)
|
||||
best: tuple[int, str, Path] | None = None
|
||||
for metadata_path in root.glob("*.json"):
|
||||
session_id = metadata_path.stem
|
||||
jsonl_path = root / f"{session_id}.jsonl"
|
||||
if not jsonl_path.is_file():
|
||||
continue
|
||||
try:
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if not isinstance(metadata, dict) or not _same_workspace(metadata.get("cwd"), workspace):
|
||||
continue
|
||||
created_ms = _parse_iso_epoch_ms(metadata.get("created_at"))
|
||||
updated_ms = _parse_iso_epoch_ms(metadata.get("updated_at"))
|
||||
if created_ms and created_ms < floor_ms:
|
||||
continue
|
||||
sort_ms = updated_ms or created_ms
|
||||
if best is None or sort_ms > best[0]:
|
||||
best = (sort_ms, session_id, jsonl_path)
|
||||
if best is None:
|
||||
return None
|
||||
return best[1], best[2]
|
||||
|
||||
|
||||
def _kiro_session_jsonl_for_id(
|
||||
session_id: str,
|
||||
*,
|
||||
workspace: str,
|
||||
sessions_dir: Path | None = None,
|
||||
) -> Path | None:
|
||||
"""Return the JSONL path for a known Kiro session id, if it is usable."""
|
||||
root = sessions_dir or _kiro_cli_sessions_dir()
|
||||
metadata_path = root / f"{session_id}.json"
|
||||
jsonl_path = root / f"{session_id}.jsonl"
|
||||
if not jsonl_path.is_file():
|
||||
return None
|
||||
try:
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(metadata, dict) or not _same_workspace(metadata.get("cwd"), workspace):
|
||||
return None
|
||||
return jsonl_path
|
||||
|
||||
|
||||
def _read_new_kiro_messages(
|
||||
jsonl_path: Path,
|
||||
byte_offset: int,
|
||||
) -> tuple[list[_KiroConversationMessage], int]:
|
||||
"""Read conversation messages after *byte_offset* from Kiro's JSONL file."""
|
||||
messages: list[_KiroConversationMessage] = []
|
||||
try:
|
||||
with jsonl_path.open("rb") as handle:
|
||||
handle.seek(byte_offset)
|
||||
# Advance only past newline-terminated lines: Kiro appends to this
|
||||
# JSONL live, so the final line may be a record mid-write (no
|
||||
# trailing ``\n``). Persisting ``handle.tell()`` past such a partial
|
||||
# line would skip the record once Kiro finishes writing it. Hold the
|
||||
# offset at the last complete line and re-read the tail next poll.
|
||||
offset = byte_offset
|
||||
for raw_line in handle:
|
||||
if not raw_line.endswith(b"\n"):
|
||||
break
|
||||
offset += len(raw_line)
|
||||
try:
|
||||
line = raw_line.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
message = _parse_kiro_jsonl_line(line)
|
||||
if message is not None:
|
||||
messages.append(message)
|
||||
return messages, offset
|
||||
except OSError:
|
||||
return [], byte_offset
|
||||
|
||||
|
||||
def _parse_kiro_jsonl_line(line: str) -> _KiroConversationMessage | None:
|
||||
"""Parse one Kiro JSONL line into a mirrorable conversation message."""
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(record, dict):
|
||||
return None
|
||||
kind = record.get("kind")
|
||||
if kind == "Prompt":
|
||||
role = "user"
|
||||
elif kind == "AssistantMessage":
|
||||
role = "assistant"
|
||||
else:
|
||||
return None
|
||||
data = record.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
message_id = data.get("message_id")
|
||||
if not isinstance(message_id, str) or not message_id:
|
||||
return None
|
||||
text = _kiro_content_text(data.get("content")).strip()
|
||||
if not text:
|
||||
return None
|
||||
return _KiroConversationMessage(message_id=message_id, role=role, text=text)
|
||||
|
||||
|
||||
def _kiro_content_text(content: object) -> str:
|
||||
"""Join text blocks from Kiro's persisted message content."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if not isinstance(content, list):
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get("kind") == "text" and isinstance(block.get("data"), str):
|
||||
parts.append(block["data"])
|
||||
elif block.get("type") in {"text", "output_text"} and isinstance(block.get("text"), str):
|
||||
parts.append(block["text"])
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
async def _post_conversation_message(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
agent_name: str,
|
||||
message: _KiroConversationMessage,
|
||||
) -> None:
|
||||
"""POST one Kiro message as an external conversation item."""
|
||||
if message.role == "assistant":
|
||||
item_data = {
|
||||
"role": "assistant",
|
||||
"agent": agent_name,
|
||||
"content": [{"type": "output_text", "text": message.text}],
|
||||
}
|
||||
else:
|
||||
item_data = {
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": message.text}],
|
||||
}
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{session_id}/events",
|
||||
json={
|
||||
"type": "external_conversation_item",
|
||||
"data": {
|
||||
"item_type": "message",
|
||||
"item_data": item_data,
|
||||
"response_id": f"kiro:{message.message_id}",
|
||||
},
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _post_session_status(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
status: str,
|
||||
response_id: str | None = None,
|
||||
) -> None:
|
||||
"""POST one Kiro turn-status edge as an external session status."""
|
||||
data: dict[str, str] = {"status": status}
|
||||
if response_id is not None:
|
||||
data["response_id"] = response_id
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{session_id}/events",
|
||||
json={"type": "external_session_status", "data": data},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _patch_external_session_id(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
external_session_id: str,
|
||||
) -> None:
|
||||
"""Persist Kiro's native CLI session id onto the Omnigent session."""
|
||||
resp = await client.patch(
|
||||
f"/v1/sessions/{session_id}",
|
||||
json={"external_session_id": external_session_id},
|
||||
)
|
||||
# The server rejects overwrites with a different id. Forwarding must keep
|
||||
# running in that case; losing chat mirroring would be worse than failing to
|
||||
# improve cold resume for an already-conflicted session.
|
||||
if resp.status_code >= 400:
|
||||
_logger.warning(
|
||||
"AP rejected Kiro external_session_id PATCH (%s); session=%s kiro_session=%s",
|
||||
resp.status_code,
|
||||
session_id,
|
||||
external_session_id,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def forward_kiro_session_to_omnigent(
|
||||
*,
|
||||
base_url: str,
|
||||
headers: dict[str, str],
|
||||
session_id: str,
|
||||
bridge_dir: Path,
|
||||
agent_name: str,
|
||||
workspace: str,
|
||||
launch_epoch_ms: int,
|
||||
expected_session_id: str | None = None,
|
||||
poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> None:
|
||||
"""Tail Kiro's session JSONL and mirror assistant messages into AP."""
|
||||
state = _read_state(bridge_dir)
|
||||
jsonl_path: Path | None = None
|
||||
timeout = httpx.Timeout(_POST_TIMEOUT_S)
|
||||
mirrored_external_session_id: str | None = None
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url, headers=headers, auth=auth, timeout=timeout
|
||||
) as client:
|
||||
while True:
|
||||
try:
|
||||
if state.session_id is None or jsonl_path is None or not jsonl_path.exists():
|
||||
discovered: tuple[str, Path] | None = None
|
||||
if expected_session_id:
|
||||
expected_path = await asyncio.to_thread(
|
||||
_kiro_session_jsonl_for_id,
|
||||
expected_session_id,
|
||||
workspace=workspace,
|
||||
)
|
||||
if expected_path is not None:
|
||||
discovered = (expected_session_id, expected_path)
|
||||
elif discovered is None:
|
||||
discovered = await asyncio.to_thread(
|
||||
_discover_kiro_session_jsonl,
|
||||
workspace=workspace,
|
||||
launch_epoch_ms=launch_epoch_ms,
|
||||
)
|
||||
if discovered is not None:
|
||||
discovered_session_id, discovered_path = discovered
|
||||
if state.session_id != discovered_session_id:
|
||||
state = _ForwardState(session_id=discovered_session_id, byte_offset=0)
|
||||
_write_state(bridge_dir, state)
|
||||
jsonl_path = discovered_path
|
||||
if jsonl_path is not None and state.session_id is not None:
|
||||
if mirrored_external_session_id != state.session_id:
|
||||
await _patch_external_session_id(
|
||||
client,
|
||||
session_id=session_id,
|
||||
external_session_id=state.session_id,
|
||||
)
|
||||
mirrored_external_session_id = state.session_id
|
||||
messages, byte_offset = await asyncio.to_thread(
|
||||
_read_new_kiro_messages,
|
||||
jsonl_path,
|
||||
state.byte_offset,
|
||||
)
|
||||
for message in messages:
|
||||
await _post_conversation_message(
|
||||
client,
|
||||
session_id=session_id,
|
||||
agent_name=agent_name,
|
||||
message=message,
|
||||
)
|
||||
if message.role == "user":
|
||||
await _post_session_status(
|
||||
client,
|
||||
session_id=session_id,
|
||||
status="running",
|
||||
)
|
||||
elif message.role == "assistant":
|
||||
await _post_session_status(
|
||||
client,
|
||||
session_id=session_id,
|
||||
status="idle",
|
||||
response_id=f"kiro:{message.message_id}",
|
||||
)
|
||||
if byte_offset != state.byte_offset:
|
||||
state.byte_offset = byte_offset
|
||||
_write_state(bridge_dir, state)
|
||||
write_forwarder_ready(bridge_dir)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"kiro session forwarder poll failed; session=%s bridge_dir=%s",
|
||||
session_id,
|
||||
bridge_dir,
|
||||
)
|
||||
await asyncio.sleep(poll_interval_s)
|
||||
|
||||
|
||||
def _supervisor_monotonic() -> float:
|
||||
"""Indirection so tests can stub the supervisor clock."""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
async def _supervisor_sleep(seconds: float) -> None:
|
||||
"""Indirection so tests can stub supervisor sleep."""
|
||||
await asyncio.sleep(seconds)
|
||||
|
||||
|
||||
async def supervise_kiro_session_forwarder(
|
||||
*,
|
||||
base_url: str,
|
||||
headers: dict[str, str],
|
||||
session_id: str,
|
||||
bridge_dir: Path,
|
||||
agent_name: str,
|
||||
workspace: str,
|
||||
launch_epoch_ms: int,
|
||||
expected_session_id: str | None = None,
|
||||
poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> None:
|
||||
"""Run the Kiro session forwarder under a restart supervisor."""
|
||||
backoff_s = _SUPERVISOR_INITIAL_BACKOFF_S
|
||||
while True:
|
||||
run_started_at = _supervisor_monotonic()
|
||||
crash_exc: Exception | None = None
|
||||
try:
|
||||
await forward_kiro_session_to_omnigent(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
session_id=session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
agent_name=agent_name,
|
||||
workspace=workspace,
|
||||
launch_epoch_ms=launch_epoch_ms,
|
||||
expected_session_id=expected_session_id,
|
||||
poll_interval_s=poll_interval_s,
|
||||
auth=auth,
|
||||
)
|
||||
_logger.warning(
|
||||
"kiro session forwarder returned unexpectedly; restarting; "
|
||||
"session=%s bridge_dir=%s",
|
||||
session_id,
|
||||
bridge_dir,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - supervisor restarts on any crash
|
||||
crash_exc = exc
|
||||
if _supervisor_monotonic() - run_started_at >= _SUPERVISOR_HEALTHY_UPTIME_S:
|
||||
backoff_s = _SUPERVISOR_INITIAL_BACKOFF_S
|
||||
if crash_exc is not None:
|
||||
_logger.error(
|
||||
"kiro session forwarder crashed; restarting in %.1fs; session=%s bridge_dir=%s",
|
||||
backoff_s,
|
||||
session_id,
|
||||
bridge_dir,
|
||||
exc_info=crash_exc,
|
||||
)
|
||||
await _supervisor_sleep(backoff_s)
|
||||
backoff_s = min(backoff_s * 2, _SUPERVISOR_MAX_BACKOFF_S)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"forward_kiro_session_to_omnigent",
|
||||
"supervise_kiro_session_forwarder",
|
||||
]
|
||||
@@ -230,7 +230,7 @@ def harness_supports_model_override(harness: str | None) -> bool:
|
||||
"""
|
||||
Return whether *harness* has per-session model-override plumbing.
|
||||
|
||||
Native CLIs (claude-native / codex-native) receive the override as
|
||||
Native CLIs receive the override as
|
||||
``--model`` at terminal launch; the SDK harnesses receive it via
|
||||
``HARNESS_<H>_MODEL`` in the spawn env. Anything else (e.g.
|
||||
unknown harnesses) silently ignores the
|
||||
|
||||
@@ -11,6 +11,7 @@ from omnigent._wrapper_labels import (
|
||||
CURSOR_NATIVE_WRAPPER_VALUE,
|
||||
GOOSE_NATIVE_WRAPPER_VALUE,
|
||||
HERMES_NATIVE_WRAPPER_VALUE,
|
||||
KIRO_NATIVE_WRAPPER_VALUE,
|
||||
OPENCODE_NATIVE_WRAPPER_VALUE,
|
||||
PI_NATIVE_WRAPPER_VALUE,
|
||||
QWEN_NATIVE_WRAPPER_VALUE,
|
||||
@@ -90,6 +91,15 @@ CURSOR_NATIVE_CODING_AGENT = NativeCodingAgent(
|
||||
terminal_name="cursor",
|
||||
)
|
||||
|
||||
KIRO_NATIVE_CODING_AGENT = NativeCodingAgent(
|
||||
key="kiro",
|
||||
display_name="Kiro",
|
||||
agent_name="kiro-native-ui",
|
||||
harness="kiro-native",
|
||||
wrapper_label=KIRO_NATIVE_WRAPPER_VALUE,
|
||||
terminal_name="kiro",
|
||||
)
|
||||
|
||||
GOOSE_NATIVE_CODING_AGENT = NativeCodingAgent(
|
||||
key="goose",
|
||||
display_name="Goose",
|
||||
@@ -131,6 +141,7 @@ NATIVE_CODING_AGENTS: tuple[NativeCodingAgent, ...] = (
|
||||
PI_NATIVE_CODING_AGENT,
|
||||
OPENCODE_NATIVE_CODING_AGENT,
|
||||
CURSOR_NATIVE_CODING_AGENT,
|
||||
KIRO_NATIVE_CODING_AGENT,
|
||||
GOOSE_NATIVE_CODING_AGENT,
|
||||
ANTIGRAVITY_NATIVE_CODING_AGENT,
|
||||
QWEN_NATIVE_CODING_AGENT,
|
||||
|
||||
@@ -57,6 +57,10 @@ QWEN_KEY = "qwen"
|
||||
# installer rather than npm — so it carries an ``install_hint``, not a ``package``.
|
||||
CURSOR_KEY = "cursor"
|
||||
|
||||
# Kiro authenticates against its own backend and ships as a standalone native
|
||||
# installer, not an npm package managed by ``omnigent setup``.
|
||||
KIRO_KEY = "kiro"
|
||||
|
||||
# OpenCode native harness CLI (``opencode serve`` / ``opencode attach``),
|
||||
# installed via the ``opencode-ai`` npm package. No login/logout/status argv
|
||||
# is wired yet — readiness is binary-only until an auth check exists.
|
||||
@@ -179,6 +183,12 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
|
||||
install_hint="curl https://cursor.com/install -fsS | bash",
|
||||
login_status_key="isAuthenticated",
|
||||
),
|
||||
KIRO_KEY: HarnessInstallSpec(
|
||||
"Kiro",
|
||||
"kiro-cli",
|
||||
package=None,
|
||||
install_hint="curl -fsSL https://cli.kiro.dev/install | bash",
|
||||
),
|
||||
# The native Antigravity (agy) TUI bridge wraps the ``agy`` CLI. ``agy`` has
|
||||
# no ``login`` / ``logout`` subcommand — the user authenticates via browser
|
||||
# OAuth by launching ``agy`` with no arguments on first run — so login_args /
|
||||
@@ -221,10 +231,11 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
|
||||
# here — the ones that cannot launch without a binary on ``PATH``:
|
||||
# ``claude-native`` wraps the ``claude`` CLI, ``codex-native`` the ``codex``
|
||||
# CLI, ``pi`` / ``pi-native`` the ``pi`` CLI, ``opencode-native`` the
|
||||
# ``opencode`` CLI, ``qwen`` / ``qwen-code`` the ``qwen`` CLI, and
|
||||
# ``cursor-native`` / ``native-cursor`` the ``cursor-agent`` CLI (the native
|
||||
# Cursor TUI, installed via Cursor's curl installer rather than npm — see its
|
||||
# ``install_hint``).
|
||||
# ``opencode`` CLI, ``qwen`` / ``qwen-code`` the ``qwen`` CLI,
|
||||
# ``cursor-native`` / ``native-cursor`` the ``cursor-agent`` CLI, and
|
||||
# ``kiro-native`` / ``native-kiro`` the ``kiro-cli`` CLI. Cursor and Kiro
|
||||
# install out-of-band rather than through npm — see their ``install_hint``
|
||||
# values.
|
||||
# SDK-based harnesses run in-process and are deliberately absent, so they
|
||||
# resolve to "no CLI required": ``claude-sdk``, ``codex``, ``openai-agents-sdk``,
|
||||
# the in-process ``antigravity`` Gemini SDK harness, and the SDK ``cursor``
|
||||
@@ -237,6 +248,8 @@ _HARNESS_NAME_TO_KEY: dict[str, str] = {
|
||||
"pi-native": PI_KEY,
|
||||
"cursor-native": CURSOR_KEY,
|
||||
"native-cursor": CURSOR_KEY,
|
||||
"kiro-native": KIRO_KEY,
|
||||
"native-kiro": KIRO_KEY,
|
||||
# The native agy TUI bridge wraps the ``agy`` CLI; both spellings map to
|
||||
# the Gemini family's install spec. (The in-process ``antigravity`` SDK
|
||||
# harness is deliberately absent — like the other SDK harnesses it needs no
|
||||
|
||||
@@ -34,6 +34,7 @@ from omnigent.onboarding.harness_install import (
|
||||
CURSOR_KEY,
|
||||
GOOSE_KEY,
|
||||
HERMES_KEY,
|
||||
KIRO_KEY,
|
||||
OPENCODE_KEY,
|
||||
PI_KEY,
|
||||
QWEN_KEY,
|
||||
@@ -90,6 +91,10 @@ _OPENCODE_HARNESSES: frozenset[str] = frozenset({"opencode-native"})
|
||||
# unknown harness, letting a binary-less launch die inside the executor.
|
||||
_CURSOR_NATIVE_HARNESSES: frozenset[str] = frozenset({"cursor-native", "native-cursor"})
|
||||
|
||||
# Native Kiro harnesses boot the standalone ``kiro-cli`` TUI. Kiro has its own
|
||||
# auth backend and no Omnigent provider family, so readiness is binary presence.
|
||||
_KIRO_NATIVE_HARNESSES: frozenset[str] = frozenset({"kiro-native", "native-kiro"})
|
||||
|
||||
# Native Goose harnesses. Boot the ``goose session`` TUI (``omni goose``) and
|
||||
# can't launch without the ``goose`` binary on ``PATH`` — gate on it, like the
|
||||
# other native CLI harnesses. Goose owns its own auth (``goose configure``), so
|
||||
@@ -148,7 +153,7 @@ def _install_key(canonical: str) -> str:
|
||||
def harness_is_configured(harness: str) -> bool:
|
||||
"""Return whether *harness* can be launched on this machine.
|
||||
|
||||
Only CLI-wrapping harnesses are assessed (native Claude/Codex and
|
||||
Only CLI-wrapping harnesses are assessed (native Claude/Codex/Kiro and
|
||||
``pi`` / ``pi-native``): they cannot run without their binary on
|
||||
``PATH``, and that is the one thing the daemon can check reliably and
|
||||
locally. SDK harnesses and unknown harnesses always return ``True`` —
|
||||
@@ -157,8 +162,8 @@ def harness_is_configured(harness: str) -> bool:
|
||||
break working launches.
|
||||
|
||||
:param harness: A harness id, e.g. ``"claude-native"``, ``"codex"``,
|
||||
``"openai-agents"``, ``"agents_sdk"``, ``"pi"``, ``"pi-native"``,
|
||||
``"qwen"``, or ``"qwen-code"``.
|
||||
``"openai-agents"``, ``"agents_sdk"``, ``"kiro-native"``, ``"pi"``,
|
||||
``"pi-native"``, ``"qwen"``, or ``"qwen-code"``.
|
||||
:returns: ``True`` when launchable (CLI installed, or a harness the
|
||||
daemon doesn't gate); ``False`` only when a CLI-wrapping
|
||||
harness's binary is missing from ``PATH``.
|
||||
@@ -172,6 +177,8 @@ def harness_is_configured(harness: str) -> bool:
|
||||
# state surfaces at run time; the daemon gates only on binary presence,
|
||||
# mirroring the other native harnesses.)
|
||||
return harness_cli_installed(CURSOR_KEY)
|
||||
if canonical in _KIRO_NATIVE_HARNESSES:
|
||||
return harness_cli_installed(KIRO_KEY)
|
||||
if canonical in _GOOSE_NATIVE_HARNESSES or canonical == GOOSE_KEY:
|
||||
# Goose — both the native TUI (``goose-native`` / ``native-goose``, via
|
||||
# ``omni goose``) and the headless ACP harness (``goose``, drives
|
||||
@@ -261,6 +268,7 @@ def configured_harness_map() -> dict[str, bool]:
|
||||
spellings.update(_PI_HARNESSES)
|
||||
spellings.update(_OPENCODE_HARNESSES)
|
||||
spellings.update(_CURSOR_NATIVE_HARNESSES)
|
||||
spellings.update(_KIRO_NATIVE_HARNESSES)
|
||||
spellings.update(_GOOSE_NATIVE_HARNESSES)
|
||||
spellings.update(_HERMES_NATIVE_HARNESSES)
|
||||
spellings.update(_QWEN_HARNESSES)
|
||||
|
||||
@@ -252,6 +252,15 @@ def _dispatch_wrapper(
|
||||
cursor_args=(),
|
||||
)
|
||||
return True
|
||||
if native_agent.key == "kiro":
|
||||
from omnigent.kiro_native import run_kiro_native
|
||||
|
||||
run_kiro_native(
|
||||
server=server,
|
||||
session_id=session_id,
|
||||
kiro_args=(),
|
||||
)
|
||||
return True
|
||||
if native_agent.key == "goose":
|
||||
from omnigent.goose_native import run_goose_native
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ from omnigent.runner.resource_registry import (
|
||||
CURSOR_NATIVE_TERMINAL_ROLE,
|
||||
GOOSE_NATIVE_TERMINAL_ROLE,
|
||||
HERMES_NATIVE_TERMINAL_ROLE,
|
||||
KIRO_NATIVE_TERMINAL_ROLE,
|
||||
OMNIGENT_REPL_TERMINAL_ROLE,
|
||||
OPENCODE_NATIVE_TERMINAL_ROLE,
|
||||
PI_NATIVE_TERMINAL_ROLE,
|
||||
@@ -504,6 +505,15 @@ class _PiNativeLaunchConfig:
|
||||
external_session_id: str | None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class _KiroNativeLaunchConfig:
|
||||
"""Persisted launch config needed for runner-owned Kiro terminal setup."""
|
||||
|
||||
workspace: Path
|
||||
terminal_launch_args: list[str] | None
|
||||
external_session_id: str | None
|
||||
|
||||
|
||||
def _required_runner_env(name: str) -> str:
|
||||
"""
|
||||
Return a required runner environment variable.
|
||||
@@ -564,6 +574,68 @@ def _pi_session_workspace(session_workspace: str | None) -> Path:
|
||||
return Path(raw.strip()).expanduser().resolve()
|
||||
|
||||
|
||||
def _kiro_session_workspace(session_workspace: str | None) -> Path:
|
||||
"""Resolve the cwd for a runner-owned Kiro terminal."""
|
||||
raw = session_workspace or _required_runner_env("OMNIGENT_RUNNER_WORKSPACE")
|
||||
return Path(raw.strip()).expanduser().resolve()
|
||||
|
||||
|
||||
async def _kiro_native_launch_config(
|
||||
*,
|
||||
session_id: str,
|
||||
server_client: httpx.AsyncClient | None,
|
||||
) -> _KiroNativeLaunchConfig:
|
||||
"""Fetch and validate persisted Kiro launch config for a session."""
|
||||
if server_client is None:
|
||||
raise RuntimeError("server_client is required for runner-owned Kiro terminals.")
|
||||
try:
|
||||
resp = await server_client.get(
|
||||
f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}",
|
||||
timeout=10.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise RuntimeError(f"Could not fetch Kiro launch config for {session_id!r}.") from exc
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"Could not fetch Kiro launch config for {session_id!r}: "
|
||||
f"GET /v1/sessions returned {resp.status_code}."
|
||||
)
|
||||
try:
|
||||
snapshot = resp.json()
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not fetch Kiro launch config for {session_id!r}: invalid JSON."
|
||||
) from exc
|
||||
if not isinstance(snapshot, dict):
|
||||
raise RuntimeError(
|
||||
f"Could not fetch Kiro launch config for {session_id!r}: "
|
||||
"snapshot was not a JSON object."
|
||||
)
|
||||
terminal_launch_args = snapshot.get("terminal_launch_args")
|
||||
if terminal_launch_args is not None and not (
|
||||
isinstance(terminal_launch_args, list)
|
||||
and all(isinstance(arg, str) for arg in terminal_launch_args)
|
||||
):
|
||||
raise RuntimeError(f"Invalid terminal_launch_args for Kiro session {session_id!r}.")
|
||||
session_workspace = snapshot.get("workspace")
|
||||
if session_workspace is not None and (
|
||||
not isinstance(session_workspace, str) or not session_workspace
|
||||
):
|
||||
raise RuntimeError(f"Invalid workspace for Kiro session {session_id!r}.")
|
||||
external_session_id = snapshot.get("external_session_id")
|
||||
if external_session_id is not None and (
|
||||
not isinstance(external_session_id, str) or not external_session_id.strip()
|
||||
):
|
||||
raise RuntimeError(f"Invalid external_session_id for Kiro session {session_id!r}.")
|
||||
return _KiroNativeLaunchConfig(
|
||||
workspace=_kiro_session_workspace(session_workspace),
|
||||
terminal_launch_args=terminal_launch_args,
|
||||
external_session_id=external_session_id.strip()
|
||||
if isinstance(external_session_id, str)
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
async def _pi_native_launch_config(
|
||||
*,
|
||||
session_id: str,
|
||||
@@ -1896,6 +1968,102 @@ async def _auto_create_hermes_terminal(
|
||||
return terminal_view
|
||||
|
||||
|
||||
async def _auto_create_kiro_terminal(
|
||||
session_id: str,
|
||||
resource_registry: SessionResourceRegistry,
|
||||
publish_event: Callable[[str, dict[str, Any]], None],
|
||||
*,
|
||||
server_client: httpx.AsyncClient | None,
|
||||
) -> SessionResourceView:
|
||||
"""Auto-create the Kiro TUI terminal for a kiro-native session."""
|
||||
from omnigent.inner.datamodel import OSEnvSpec, TerminalEnvSpec
|
||||
from omnigent.kiro_native import build_kiro_launch
|
||||
from omnigent.kiro_native_bridge import (
|
||||
KIRO_NATIVE_ENV_UNSET,
|
||||
build_kiro_native_terminal_env,
|
||||
prepare_bridge_dir,
|
||||
)
|
||||
|
||||
launch_config = await _kiro_native_launch_config(
|
||||
session_id=session_id,
|
||||
server_client=server_client,
|
||||
)
|
||||
workspace_path = launch_config.workspace
|
||||
if not workspace_path.exists():
|
||||
raise RuntimeError(f"Kiro workspace does not exist for session {session_id!r}.")
|
||||
workspace = str(workspace_path)
|
||||
bridge_dir = prepare_bridge_dir(session_id)
|
||||
kiro_launch = build_kiro_launch(
|
||||
launch_config.terminal_launch_args or [],
|
||||
resume_id=launch_config.external_session_id,
|
||||
)
|
||||
launch_epoch_ms = int(time.time() * 1000)
|
||||
terminal_view = await resource_registry.launch_required_terminal(
|
||||
session_id=session_id,
|
||||
terminal_name="kiro",
|
||||
session_key="main",
|
||||
resource_role=KIRO_NATIVE_TERMINAL_ROLE,
|
||||
spec=TerminalEnvSpec(
|
||||
os_env=OSEnvSpec(type="caller_process", cwd=workspace),
|
||||
command=kiro_launch.executable,
|
||||
args=kiro_launch.argv[1:],
|
||||
env=build_kiro_native_terminal_env(session_id),
|
||||
env_unset=list(KIRO_NATIVE_ENV_UNSET),
|
||||
inherit_env=False,
|
||||
scrollback=100_000,
|
||||
tmux_allow_passthrough=True,
|
||||
tmux_start_on_attach=False,
|
||||
),
|
||||
)
|
||||
terminal_registry = resource_registry.terminal_registry
|
||||
if terminal_registry is not None:
|
||||
instance = terminal_registry.get(session_id, "kiro", "main")
|
||||
if instance is not None and instance.running:
|
||||
from omnigent.kiro_native_bridge import write_tmux_target
|
||||
|
||||
write_tmux_target(
|
||||
bridge_dir,
|
||||
socket_path=instance.socket_path,
|
||||
tmux_target=instance.tmux_target,
|
||||
requires_forwarder_ready=launch_config.external_session_id is not None,
|
||||
)
|
||||
publish_event(
|
||||
session_id,
|
||||
{
|
||||
"type": "session.resource.created",
|
||||
"resource": session_resource_view_to_dict(terminal_view),
|
||||
},
|
||||
)
|
||||
from omnigent.runner._entry import _make_auth_token_factory, _RunnerDatabricksAuth
|
||||
|
||||
server_url = _required_runner_env("RUNNER_SERVER_URL")
|
||||
_runner_auth = _RunnerDatabricksAuth(_make_auth_token_factory())
|
||||
|
||||
from omnigent.kiro_native_session_forwarder import supervise_kiro_session_forwarder
|
||||
|
||||
_forwarder_task = asyncio.create_task(
|
||||
supervise_kiro_session_forwarder(
|
||||
base_url=server_url,
|
||||
headers={},
|
||||
session_id=session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
agent_name="kiro-native-ui",
|
||||
workspace=workspace,
|
||||
launch_epoch_ms=launch_epoch_ms,
|
||||
expected_session_id=launch_config.external_session_id,
|
||||
auth=_runner_auth,
|
||||
),
|
||||
name=f"kiro-session-forwarder-{session_id}",
|
||||
)
|
||||
_register_auto_forwarder_task(session_id, _forwarder_task)
|
||||
_logger.info(
|
||||
"Auto-created kiro terminal + session forwarder for session %s; forwarder_task=%s",
|
||||
session_id,
|
||||
_forwarder_task.get_name(),
|
||||
)
|
||||
return terminal_view
|
||||
|
||||
|
||||
async def _persist_qwen_external_session_id(
|
||||
server_client: httpx.AsyncClient | None,
|
||||
session_id: str,
|
||||
@@ -6376,6 +6544,7 @@ def create_runner_app(
|
||||
_pi_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
|
||||
_opencode_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
|
||||
_cursor_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
|
||||
_kiro_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
|
||||
_goose_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
|
||||
_qwen_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
|
||||
_hermes_terminal_ensure_locks: dict[str, asyncio.Lock] = {}
|
||||
@@ -7294,6 +7463,10 @@ def create_runner_app(
|
||||
from omnigent.cursor_native_bridge import build_cursor_native_spawn_env
|
||||
|
||||
spawn_env = build_cursor_native_spawn_env(session_id)
|
||||
if harness_name == "kiro-native" and spawn_env is None:
|
||||
from omnigent.kiro_native_bridge import build_kiro_native_spawn_env
|
||||
|
||||
spawn_env = build_kiro_native_spawn_env(session_id)
|
||||
if harness_name == "antigravity-native" and spawn_env is None:
|
||||
from omnigent.antigravity_native_bridge import (
|
||||
ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY,
|
||||
@@ -7665,6 +7838,36 @@ def create_runner_app(
|
||||
finally:
|
||||
_publish_terminal_pending(_publish_event, session_id, False)
|
||||
|
||||
if harness_name == "kiro-native":
|
||||
_kiro_ensure_lock = _kiro_terminal_ensure_locks.setdefault(session_id, asyncio.Lock())
|
||||
async with _kiro_ensure_lock:
|
||||
_tr = resource_registry.terminal_registry
|
||||
_has_kiro_terminal = (
|
||||
_tr is not None and _tr.get(session_id, "kiro", "main") is not None
|
||||
)
|
||||
if not _has_kiro_terminal:
|
||||
_publish_terminal_pending(_publish_event, session_id, True)
|
||||
try:
|
||||
await _auto_create_kiro_terminal(
|
||||
session_id,
|
||||
resource_registry,
|
||||
_publish_event,
|
||||
server_client=server_client,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.exception(
|
||||
"Failed to auto-create kiro terminal for %s",
|
||||
session_id,
|
||||
)
|
||||
_publish_native_terminal_start_error(
|
||||
_publish_event,
|
||||
session_id,
|
||||
"Kiro",
|
||||
exc,
|
||||
)
|
||||
finally:
|
||||
_publish_terminal_pending(_publish_event, session_id, False)
|
||||
|
||||
if harness_name == "antigravity-native":
|
||||
# Same concurrency guard as the claude/codex branches: two POST
|
||||
# /v1/sessions (connect callback + relaunch handshake) — or a
|
||||
@@ -8151,6 +8354,7 @@ def create_runner_app(
|
||||
_claude_terminal_ensure_locks.pop(session_id, None)
|
||||
_pi_terminal_ensure_locks.pop(session_id, None)
|
||||
_cursor_terminal_ensure_locks.pop(session_id, None)
|
||||
_kiro_terminal_ensure_locks.pop(session_id, None)
|
||||
_antigravity_terminal_ensure_locks.pop(session_id, None)
|
||||
_goose_terminal_ensure_locks.pop(session_id, None)
|
||||
_qwen_terminal_ensure_locks.pop(session_id, None)
|
||||
@@ -8768,6 +8972,7 @@ def create_runner_app(
|
||||
"claude-native",
|
||||
"pi-native",
|
||||
"cursor-native",
|
||||
"kiro-native",
|
||||
"goose-native",
|
||||
"qwen-native",
|
||||
"hermes-native",
|
||||
@@ -11642,6 +11847,10 @@ def create_runner_app(
|
||||
from omnigent.cursor_native_bridge import build_cursor_native_spawn_env
|
||||
|
||||
spawn_env = build_cursor_native_spawn_env(conv_id)
|
||||
if harness_name == "kiro-native" and spawn_env is None:
|
||||
from omnigent.kiro_native_bridge import build_kiro_native_spawn_env
|
||||
|
||||
spawn_env = build_kiro_native_spawn_env(conv_id)
|
||||
if harness_name == "antigravity-native" and spawn_env is None:
|
||||
from omnigent.antigravity_native_bridge import (
|
||||
ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY,
|
||||
@@ -12601,6 +12810,7 @@ def create_runner_app(
|
||||
# native terminal forwarders, so AP-forwarded output is the only
|
||||
# authoritative transcript source.
|
||||
if status in ("running", "waiting", "idle", "failed"):
|
||||
resource_registry.note_external_session_status(conversation_id, status)
|
||||
_fan_out_child_delta_to_parent(
|
||||
conversation_id,
|
||||
{"type": "session.status", "status": status},
|
||||
@@ -13369,6 +13579,40 @@ def create_runner_app(
|
||||
content=session_resource_view_to_dict(terminal_view),
|
||||
)
|
||||
|
||||
if (
|
||||
body.get("ensure_native_terminal")
|
||||
and terminal_name == "kiro"
|
||||
and session_key == "main"
|
||||
):
|
||||
kiro_terminal_id = terminal_resource_id("kiro", "main")
|
||||
ensure_lock = _kiro_terminal_ensure_locks.setdefault(session_id, asyncio.Lock())
|
||||
async with ensure_lock:
|
||||
existing = await resource_registry.get_terminal_resource(
|
||||
session_id, kiro_terminal_id
|
||||
)
|
||||
if existing is not None:
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content=session_resource_view_to_dict(existing),
|
||||
)
|
||||
try:
|
||||
terminal_view = await _auto_create_kiro_terminal(
|
||||
session_id,
|
||||
resource_registry,
|
||||
_publish_event,
|
||||
server_client=server_client,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.exception(
|
||||
"Kiro terminal ensure failed for session=%s",
|
||||
session_id,
|
||||
)
|
||||
return _native_terminal_start_error_response(exc, "Kiro")
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content=session_resource_view_to_dict(terminal_view),
|
||||
)
|
||||
|
||||
if (
|
||||
body.get("ensure_native_terminal")
|
||||
and terminal_name == "hermes"
|
||||
@@ -14979,6 +15223,7 @@ def create_runner_app(
|
||||
_claude_terminal_ensure_locks.pop(session_id, None)
|
||||
_pi_terminal_ensure_locks.pop(session_id, None)
|
||||
_cursor_terminal_ensure_locks.pop(session_id, None)
|
||||
_kiro_terminal_ensure_locks.pop(session_id, None)
|
||||
_antigravity_terminal_ensure_locks.pop(session_id, None)
|
||||
_goose_terminal_ensure_locks.pop(session_id, None)
|
||||
_qwen_terminal_ensure_locks.pop(session_id, None)
|
||||
@@ -15036,6 +15281,7 @@ def create_runner_app(
|
||||
_claude_terminal_ensure_locks.pop(session_id, None)
|
||||
_pi_terminal_ensure_locks.pop(session_id, None)
|
||||
_cursor_terminal_ensure_locks.pop(session_id, None)
|
||||
_kiro_terminal_ensure_locks.pop(session_id, None)
|
||||
_antigravity_terminal_ensure_locks.pop(session_id, None)
|
||||
_goose_terminal_ensure_locks.pop(session_id, None)
|
||||
_qwen_terminal_ensure_locks.pop(session_id, None)
|
||||
|
||||
@@ -52,6 +52,7 @@ CLAUDE_NATIVE_TERMINAL_ROLE = "claude-native"
|
||||
PI_NATIVE_TERMINAL_ROLE = "pi-native"
|
||||
OPENCODE_NATIVE_TERMINAL_ROLE = "opencode-native"
|
||||
CURSOR_NATIVE_TERMINAL_ROLE = "cursor-native"
|
||||
KIRO_NATIVE_TERMINAL_ROLE = "kiro-native"
|
||||
GOOSE_NATIVE_TERMINAL_ROLE = "goose-native"
|
||||
# Role marker for the runner-owned native Antigravity (agy) TUI terminal.
|
||||
# A generic terminal launched with ``terminal=antigravity`` shares the same
|
||||
@@ -380,6 +381,23 @@ class SessionResourceRegistry:
|
||||
"""
|
||||
self._set_session_status_memo(session_id, "running")
|
||||
|
||||
def note_external_session_status(self, session_id: str, status: str) -> None:
|
||||
"""Record a terminal-observed external status for exit classification.
|
||||
|
||||
Structured native forwarders can know turn completion more reliably than
|
||||
a PTY diff heuristic. Keep the required-terminal exit memo aligned so a
|
||||
terminal that closes after a forwarded ``idle`` edge is treated as a
|
||||
clean shutdown, while ``running`` / ``waiting`` still classify a later
|
||||
exit as mid-turn.
|
||||
|
||||
:param session_id: Session/conversation identifier, e.g. ``"conv_abc"``.
|
||||
:param status: External native status, e.g. ``"running"`` or ``"idle"``.
|
||||
"""
|
||||
if status == "idle":
|
||||
self._set_session_status_memo(session_id, "idle")
|
||||
elif status in {"running", "waiting"}:
|
||||
self._set_session_status_memo(session_id, "running")
|
||||
|
||||
@property
|
||||
def terminal_registry(self) -> TerminalRegistry | None:
|
||||
"""The wrapped terminal registry."""
|
||||
@@ -970,6 +988,7 @@ class SessionResourceRegistry:
|
||||
# after the paste), so — like pi/claude — the PTY watcher is its only
|
||||
# status source. Without this the web "Working…" badge never clears.
|
||||
CURSOR_NATIVE_TERMINAL_ROLE,
|
||||
KIRO_NATIVE_TERMINAL_ROLE,
|
||||
# goose-native injects then returns (its forwarder only mirrors the
|
||||
# transcript, not status), so the PTY watcher is its status source too.
|
||||
GOOSE_NATIVE_TERMINAL_ROLE,
|
||||
|
||||
@@ -71,6 +71,8 @@ _HARNESS_MODULES: dict[str, str] = {
|
||||
# back — a native-CLI harness like claude/codex/pi-native, so it IS in
|
||||
# ``NATIVE_HARNESSES``. See omnigent/inner/cursor_native_harness.py.
|
||||
"cursor-native": "omnigent.inner.cursor_native_harness",
|
||||
# Native Kiro TUI bridge used by ``omnigent kiro``.
|
||||
"kiro-native": "omnigent.inner.kiro_native_harness",
|
||||
# goose-native harness wrap. Drives the resident ``goose session`` TUI by
|
||||
# injecting each web-UI turn into its tmux pane and mirroring the transcript
|
||||
# back from Goose's SQLite session store — a native-CLI harness like
|
||||
|
||||
@@ -121,6 +121,14 @@ class DrainedInput:
|
||||
created_by: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchedDrain:
|
||||
"""Result from draining pending inputs up to a text-matched entry."""
|
||||
|
||||
matched: DrainedInput | None
|
||||
skipped: list[DrainedInput]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Entry:
|
||||
"""
|
||||
@@ -276,6 +284,51 @@ def resolve_oldest(conversation_id: str) -> DrainedInput | None:
|
||||
)
|
||||
|
||||
|
||||
def resolve_matching_text(conversation_id: str, text: str) -> MatchedDrain:
|
||||
"""
|
||||
Drain through the first pending entry whose text matches ``text``.
|
||||
|
||||
Kiro persists accepted web prompts as structured ``Prompt`` records. If an
|
||||
earlier injected web message errors before Kiro records a prompt, FIFO
|
||||
draining would consume that failed entry when the next successful prompt is
|
||||
mirrored, leaving the successful prompt stuck pending. This resolver lets
|
||||
Kiro match the accepted prompt text and returns any older skipped entries so
|
||||
the caller can surface them as failed web injections.
|
||||
|
||||
:param conversation_id: Conversation/session id, e.g. ``"conv_abc123"``.
|
||||
:param text: Accepted prompt text mirrored from Kiro's structured JSONL.
|
||||
:returns: Matched entry plus older skipped entries, or no match with an
|
||||
empty skipped list when the text was typed directly in the TUI.
|
||||
"""
|
||||
needle = _normalize_text(text)
|
||||
if not needle:
|
||||
return MatchedDrain(matched=None, skipped=[])
|
||||
with _lock:
|
||||
_evict_stale_locked(conversation_id, _now())
|
||||
entries = _pending.get(conversation_id)
|
||||
if entries is None:
|
||||
return MatchedDrain(matched=None, skipped=[])
|
||||
ordered = list(entries.items())
|
||||
match_index: int | None = None
|
||||
for index, (_pending_id, entry) in enumerate(ordered):
|
||||
entry_text = _normalize_text(_content_text(entry.content))
|
||||
if entry_text and (needle == entry_text or needle.endswith(entry_text)):
|
||||
match_index = index
|
||||
break
|
||||
if match_index is None:
|
||||
return MatchedDrain(matched=None, skipped=[])
|
||||
skipped_entries = ordered[:match_index]
|
||||
_matched_id, matched_entry = ordered[match_index]
|
||||
for pending_id, _entry in ordered[: match_index + 1]:
|
||||
entries.pop(pending_id, None)
|
||||
if not entries:
|
||||
_pending.pop(conversation_id, None)
|
||||
return MatchedDrain(
|
||||
matched=_drained_input(matched_entry),
|
||||
skipped=[_drained_input(entry) for _pending_id, entry in skipped_entries],
|
||||
)
|
||||
|
||||
|
||||
def snapshot_for(conversation_id: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Return un-consumed messages for one session, for snapshot replay.
|
||||
@@ -316,6 +369,34 @@ def snapshot_for(conversation_id: str) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def _drained_input(entry: _Entry) -> DrainedInput:
|
||||
"""Copy a pending entry into the public drained shape."""
|
||||
return DrainedInput(
|
||||
pending_id=entry.pending_id,
|
||||
content=copy.deepcopy(entry.content),
|
||||
created_by=entry.created_by,
|
||||
)
|
||||
|
||||
|
||||
def _content_text(content: list[dict[str, Any]]) -> str:
|
||||
"""Extract text blocks from a pending-input content list."""
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
block_type = block.get("type")
|
||||
if block_type in {"input_text", "text", "output_text"}:
|
||||
text = block.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _normalize_text(text: str) -> str:
|
||||
"""Normalize text enough to compare pending input with Kiro Prompt text."""
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def reset_for_tests() -> None:
|
||||
"""
|
||||
Clear the entire index. For test isolation only.
|
||||
|
||||
@@ -24,6 +24,7 @@ from omnigent.native_coding_agents import (
|
||||
CLAUDE_NATIVE_CODING_AGENT,
|
||||
CODEX_NATIVE_CODING_AGENT,
|
||||
CURSOR_NATIVE_CODING_AGENT,
|
||||
KIRO_NATIVE_CODING_AGENT,
|
||||
OPENCODE_NATIVE_CODING_AGENT,
|
||||
PI_NATIVE_CODING_AGENT,
|
||||
QWEN_NATIVE_CODING_AGENT,
|
||||
@@ -82,6 +83,7 @@ _CODEX_NATIVE_AGENT_NAME = CODEX_NATIVE_CODING_AGENT.agent_name
|
||||
_PI_NATIVE_AGENT_NAME = PI_NATIVE_CODING_AGENT.agent_name
|
||||
_OPENCODE_NATIVE_AGENT_NAME = OPENCODE_NATIVE_CODING_AGENT.agent_name
|
||||
_CURSOR_NATIVE_AGENT_NAME = CURSOR_NATIVE_CODING_AGENT.agent_name
|
||||
_KIRO_NATIVE_AGENT_NAME = KIRO_NATIVE_CODING_AGENT.agent_name
|
||||
_ANTIGRAVITY_NATIVE_AGENT_NAME = ANTIGRAVITY_NATIVE_CODING_AGENT.agent_name
|
||||
_QWEN_NATIVE_AGENT_NAME = QWEN_NATIVE_CODING_AGENT.agent_name
|
||||
_DEBBY_AGENT_NAME = "debby"
|
||||
@@ -360,6 +362,7 @@ def _ensure_default_agents(
|
||||
_ensure_default_pi_agent(agent_store, artifact_store, agent_cache)
|
||||
_ensure_default_opencode_agent(agent_store, artifact_store, agent_cache)
|
||||
_ensure_default_cursor_agent(agent_store, artifact_store, agent_cache)
|
||||
_ensure_default_kiro_agent(agent_store, artifact_store, agent_cache)
|
||||
_ensure_default_antigravity_agent(agent_store, artifact_store, agent_cache)
|
||||
_ensure_default_qwen_agent(agent_store, artifact_store, agent_cache)
|
||||
_ensure_default_debby_agent(agent_store, artifact_store, agent_cache)
|
||||
@@ -652,6 +655,34 @@ def _ensure_default_cursor_agent(
|
||||
)
|
||||
|
||||
|
||||
def _build_kiro_native_bundle() -> bytes:
|
||||
"""Build a gzipped tarball of the kiro-native-ui agent spec."""
|
||||
import tempfile
|
||||
|
||||
from omnigent.kiro_native import _materialize_kiro_agent_spec
|
||||
from omnigent.spec import materialize_bundle
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
spec_path = _materialize_kiro_agent_spec(Path(tmpdir), model=None)
|
||||
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
|
||||
return _tar_gz_dir(bundle_dir)
|
||||
|
||||
|
||||
def _ensure_default_kiro_agent(
|
||||
agent_store: AgentStore,
|
||||
artifact_store: ArtifactStore,
|
||||
agent_cache: Any,
|
||||
) -> None:
|
||||
"""Register or refresh the kiro-native-ui agent."""
|
||||
_ensure_builtin_agent(
|
||||
agent_store,
|
||||
artifact_store,
|
||||
agent_cache,
|
||||
name=_KIRO_NATIVE_AGENT_NAME,
|
||||
bundle_bytes=_build_kiro_native_bundle(),
|
||||
)
|
||||
|
||||
|
||||
def _ensure_default_antigravity_agent(
|
||||
agent_store: AgentStore,
|
||||
artifact_store: ArtifactStore,
|
||||
|
||||
@@ -4633,12 +4633,19 @@ async def _persist_external_conversation_item(
|
||||
# The transcript is text-only, so without this the image is dropped
|
||||
# from durable history and disappears on every reload / navigation.
|
||||
cleared_pending_id: str | None = None
|
||||
skipped_kiro_pending: list[pending_inputs.DrainedInput] = []
|
||||
if (
|
||||
item.type == "message"
|
||||
and isinstance(item.data, MessageData)
|
||||
and item.data.role == "user"
|
||||
and not item.data.is_meta
|
||||
):
|
||||
if _is_kiro_native_session(conv):
|
||||
text = _message_text(item.data.content) or ""
|
||||
matched = pending_inputs.resolve_matching_text(session_id, text)
|
||||
drained = matched.matched
|
||||
skipped_kiro_pending = matched.skipped
|
||||
else:
|
||||
drained = pending_inputs.resolve_oldest(session_id)
|
||||
if drained is not None:
|
||||
cleared_pending_id = drained.pending_id
|
||||
@@ -4655,6 +4662,12 @@ async def _persist_external_conversation_item(
|
||||
# No pending entry — direct terminal input. Fall back to the
|
||||
# identity authenticated on the forwarder's own request.
|
||||
item = item.model_copy(update={"created_by": created_by})
|
||||
for skipped in skipped_kiro_pending:
|
||||
await _persist_skipped_kiro_pending_input(
|
||||
session_id,
|
||||
skipped,
|
||||
conversation_store,
|
||||
)
|
||||
persisted_items = await asyncio.to_thread(conversation_store.append, session_id, [item])
|
||||
await _seed_missing_title_from_user_message(conv, item, conversation_store)
|
||||
persisted = persisted_items[0]
|
||||
@@ -4665,6 +4678,48 @@ async def _persist_external_conversation_item(
|
||||
return persisted.id
|
||||
|
||||
|
||||
def _is_kiro_native_session(conv: Conversation) -> bool:
|
||||
"""Return whether a conversation is backed by the native Kiro terminal."""
|
||||
return conv.labels.get("omnigent.wrapper") == "kiro-native-ui"
|
||||
|
||||
|
||||
async def _persist_skipped_kiro_pending_input(
|
||||
session_id: str,
|
||||
skipped: pending_inputs.DrainedInput,
|
||||
conversation_store: ConversationStore,
|
||||
) -> None:
|
||||
"""Persist a Kiro web input that never appeared in Kiro's JSONL transcript."""
|
||||
turn_id = generate_task_id()
|
||||
user_item = NewConversationItem(
|
||||
type="message",
|
||||
response_id=turn_id,
|
||||
data=MessageData(role="user", content=skipped.content),
|
||||
created_by=skipped.created_by,
|
||||
)
|
||||
error = ErrorData(
|
||||
source="execution",
|
||||
code="kiro_native_prompt_not_recorded",
|
||||
message=(
|
||||
"Kiro did not accept this web message into its structured session transcript. "
|
||||
"The native terminal may have shown the underlying error."
|
||||
),
|
||||
)
|
||||
persisted_items = await asyncio.to_thread(
|
||||
conversation_store.append,
|
||||
session_id,
|
||||
[
|
||||
user_item,
|
||||
NewConversationItem(type="error", response_id=turn_id, data=error),
|
||||
],
|
||||
)
|
||||
_publish_input_consumed(
|
||||
session_id,
|
||||
persisted_items[0],
|
||||
cleared_pending_id=skipped.pending_id,
|
||||
)
|
||||
_publish_external_conversation_item(session_id, persisted_items[1])
|
||||
|
||||
|
||||
def _merge_pending_file_blocks(
|
||||
item: NewConversationItem,
|
||||
pending_content: list[dict[str, Any]],
|
||||
@@ -6526,8 +6581,7 @@ def _is_native_terminal_session(conv: Conversation) -> bool:
|
||||
Return whether a session is owned by a terminal-native wrapper.
|
||||
|
||||
:param conv: Conversation row for the target session.
|
||||
:returns: ``True`` for wrappers whose transcript forwarder is the
|
||||
single writer for conversation history.
|
||||
:returns: ``True`` for wrappers backed by a native terminal harness.
|
||||
"""
|
||||
wrapper = conv.labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY)
|
||||
return native_coding_agent_for_wrapper_label(wrapper) is not None
|
||||
@@ -8132,13 +8186,13 @@ async def _dispatch_session_event_to_runner(
|
||||
Callers stay harness-agnostic — the claude-native message bypass
|
||||
is encapsulated here. Two dispatch outcomes:
|
||||
|
||||
* **claude-native + ``type == "message"``**: web-chat user
|
||||
* **transcript-forwarded native + ``type == "message"``**: web-chat user
|
||||
messages on these sessions must NOT be persisted by the AP
|
||||
server. The Omnigent would otherwise persist an AP-side copy AND
|
||||
let the transcript forwarder mirror the same message back
|
||||
(with its own store-assigned item id), so every web-typed
|
||||
prompt would land as two items in the chat panel. We forward
|
||||
to the bound runner so the claude-native harness types the
|
||||
to the bound runner so the native harness types the
|
||||
message into tmux; the transcript forwarder becomes the
|
||||
single writer for the conversation history. Returns a result
|
||||
with ``item_id=None`` (no AP-side persisted item) and a
|
||||
|
||||
@@ -88,6 +88,7 @@ OMNIGENT_HARNESSES = frozenset(
|
||||
"copilot",
|
||||
"cursor",
|
||||
"cursor-native",
|
||||
"kiro-native",
|
||||
"goose",
|
||||
"goose-native",
|
||||
"hermes",
|
||||
@@ -105,6 +106,7 @@ OMNIGENT_HARNESSES = frozenset(
|
||||
OMNIGENT_HARNESS_ALIASES = frozenset(
|
||||
{
|
||||
"claude",
|
||||
"native-kiro",
|
||||
"native-pi",
|
||||
"native-antigravity",
|
||||
"native-goose",
|
||||
|
||||
@@ -73,6 +73,35 @@ def test_is_url_absolute() -> None:
|
||||
assert _is_url("/home/user/my-agent") is False
|
||||
|
||||
|
||||
def test_redirect_native_resume_routes_kiro_wrapper(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A kiro-native wrapper session redirects to ``run_kiro_native``."""
|
||||
monkeypatch.setattr(
|
||||
chat_module,
|
||||
"_wrapper_label_for_conversation",
|
||||
lambda *, base_url, conversation_id: "kiro-native-ui",
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _capture(**kwargs: object) -> None:
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setattr("omnigent.kiro_native.run_kiro_native", _capture)
|
||||
|
||||
redirected = chat_module._redirect_native_resume_if_needed(
|
||||
base_url="https://example.com",
|
||||
conversation_id="conv_kiro",
|
||||
auto_open_conversation=True,
|
||||
)
|
||||
|
||||
assert redirected is True
|
||||
assert captured == {
|
||||
"server": "https://example.com",
|
||||
"session_id": "conv_kiro",
|
||||
"kiro_args": (),
|
||||
"auto_open_conversation": True,
|
||||
}
|
||||
|
||||
|
||||
# ── _extract_agent_name ──────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from click import ClickException
|
||||
from click.testing import CliRunner, Result
|
||||
|
||||
from omnigent.cli import (
|
||||
_CLICK_SUBCOMMANDS,
|
||||
_GLOBAL_CONFIG_KEYS,
|
||||
_adopt_ambient_credentials,
|
||||
_announce_auto_configured_credentials,
|
||||
@@ -199,6 +200,17 @@ def _fake_run_codex_native_capture(
|
||||
return _stub
|
||||
|
||||
|
||||
def _fake_run_kiro_native_capture(
|
||||
captured: dict[str, object],
|
||||
) -> Callable[..., None]:
|
||||
"""Build a ``run_kiro_native`` stub that records its kwargs."""
|
||||
|
||||
def _stub(**kwargs: object) -> None:
|
||||
captured.update(kwargs)
|
||||
|
||||
return _stub
|
||||
|
||||
|
||||
def test_claude_command_resume_binds_session_and_passes_unknown_args(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -523,6 +535,109 @@ def test_codex_command_session_and_resume_mutually_exclusive(
|
||||
assert "mutually exclusive" in result.output
|
||||
|
||||
|
||||
def test_kiro_command_is_registered_in_click_help() -> None:
|
||||
"""``omnigent kiro`` is a true top-level Click command."""
|
||||
result = CliRunner().invoke(cli, ["--help"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "kiro" in _CLICK_SUBCOMMANDS
|
||||
assert "kiro" in result.output
|
||||
|
||||
|
||||
def test_kiro_command_parses_native_options_and_prompt(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``omnigent kiro`` routes mapped options to the native Kiro runner."""
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr("omnigent.cli._load_effective_config", lambda: {"server": "https://cfg"})
|
||||
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda server: server)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.kiro_native.run_kiro_native",
|
||||
_fake_run_kiro_native_capture(captured),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli,
|
||||
[
|
||||
"kiro",
|
||||
"--model",
|
||||
"auto",
|
||||
"--effort",
|
||||
"high",
|
||||
"--agent",
|
||||
"dev",
|
||||
"--trust-tools",
|
||||
"Read",
|
||||
"--trust-all-tools",
|
||||
"-p",
|
||||
"hi",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["server"] == "https://cfg"
|
||||
assert captured["session_id"] is None
|
||||
assert captured["resume_picker"] is False
|
||||
assert captured["model"] == "auto"
|
||||
assert captured["prompt"] == "hi"
|
||||
assert captured["kiro_args"] == (
|
||||
"--effort",
|
||||
"high",
|
||||
"--agent",
|
||||
"dev",
|
||||
"--trust-tools",
|
||||
"Read",
|
||||
"--trust-all-tools",
|
||||
)
|
||||
|
||||
|
||||
def test_kiro_command_bare_resume_requests_picker(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``omnigent kiro --resume`` requests the Kiro-native picker."""
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr("omnigent.cli._load_effective_config", dict)
|
||||
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda *_: "http://localhost:0")
|
||||
monkeypatch.setattr(
|
||||
"omnigent.kiro_native.run_kiro_native",
|
||||
_fake_run_kiro_native_capture(captured),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["kiro", "--resume"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["session_id"] is None
|
||||
assert captured["resume_picker"] is True
|
||||
|
||||
|
||||
def test_kiro_command_session_and_resume_mutually_exclusive(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Invalid Kiro resume inputs fail before backend side effects."""
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._ensure_backend",
|
||||
lambda *_: pytest.fail("invalid args must not start the backend"),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["kiro", "--session", "conv_a", "--resume", "conv_b"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "mutually exclusive" in result.output
|
||||
|
||||
|
||||
def test_kiro_command_rejects_kiro_resume_passthrough_flags(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Kiro-owned resume flags are reserved for internal cold-resume mapping."""
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._ensure_backend",
|
||||
lambda *_: pytest.fail("invalid args must not start the backend"),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["kiro", "--", "--resume-id", "kiro-session"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Kiro resume flags are reserved" in result.output
|
||||
|
||||
|
||||
# ── bundled-agent shorthands (omnigent polly / omnigent debby) ──────────
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Regression tests for managed host image CLI availability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dockerfile",
|
||||
[
|
||||
_ROOT / "deploy/docker/Dockerfile",
|
||||
_ROOT / "deploy/docker/Dockerfile.ubi",
|
||||
],
|
||||
)
|
||||
def test_host_images_install_kiro_cli_from_official_installer(dockerfile: Path) -> None:
|
||||
"""Managed host images must preinstall the real Kiro CLI binary.
|
||||
|
||||
The public npm package named ``kiro-cli`` is unrelated and does not expose a
|
||||
``kiro-cli`` binary, so this pins the official installer path and global PATH
|
||||
copy that makes native Kiro work in managed sandboxes.
|
||||
"""
|
||||
text = dockerfile.read_text()
|
||||
|
||||
assert "https://cli.kiro.dev/install" in text
|
||||
assert "install -m 0755 /root/.local/bin/kiro-cli /usr/local/bin/kiro-cli" in text
|
||||
assert " kiro-cli \\" not in text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dockerfile",
|
||||
[
|
||||
_ROOT / "deploy/docker/Dockerfile",
|
||||
_ROOT / "deploy/docker/Dockerfile.ubi",
|
||||
],
|
||||
)
|
||||
def test_host_images_include_kiro_installer_dependency(dockerfile: Path) -> None:
|
||||
"""Kiro's installer needs ``unzip`` on Linux."""
|
||||
text = dockerfile.read_text()
|
||||
assert "unzip" in text
|
||||
@@ -193,6 +193,12 @@ def test_run_harness_live_matrix_covers_registered_coding_harnesses() -> None:
|
||||
not ``omnigent run --harness qwen-native``. Its coverage is the dedicated
|
||||
qwen-native bridge/executor/forwarder unit tests.
|
||||
|
||||
``kiro-native`` is excluded for the same reason as ``goose-native`` /
|
||||
``qwen-native`` / ``cursor-native``: it is a terminal-first TUI launched via
|
||||
``omni kiro`` (tmux pane + bridge dir), not ``omnigent run --harness
|
||||
kiro-native``. Its coverage is the dedicated kiro-native bridge/executor/
|
||||
forwarder unit tests plus the ``test_native_kiro_render_parity`` e2e_ui suite.
|
||||
|
||||
``hermes`` is excluded because it requires the ``hermes`` CLI binary
|
||||
(installed separately via Nous Research's install script) and authenticates
|
||||
through its own provider config, not the shared gateway/profile probe
|
||||
@@ -218,6 +224,7 @@ def test_run_harness_live_matrix_covers_registered_coding_harnesses() -> None:
|
||||
"qwen-native",
|
||||
"goose",
|
||||
"goose-native",
|
||||
"kiro-native",
|
||||
"hermes",
|
||||
"hermes-native",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""End-to-end smoke test: ``omnigent kiro`` drives the native Kiro TUI.
|
||||
|
||||
This opt-in test covers the user-facing Kiro native path: the CLI starts a
|
||||
runner-owned ``kiro-cli chat --tui`` terminal, the server accepts a web-style
|
||||
``POST /v1/sessions/{id}/events`` message, the Kiro bridge injects it into the
|
||||
TUI, and the Kiro session forwarder mirrors the assistant response back into
|
||||
the Omnigent conversation.
|
||||
|
||||
Run locally with a logged-in Kiro CLI::
|
||||
|
||||
OMNIGENT_E2E_KIRO_NATIVE=1 \
|
||||
.venv/bin/python -m pytest tests/e2e/test_kiro_native_cli_e2e.py -v
|
||||
|
||||
The test is skipped by default because ``kiro-cli`` authentication is anchored
|
||||
to the developer's ambient Kiro login; a binary present on CI may still be
|
||||
unauthenticated and would hang the TUI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.e2e._native_resume_helpers import (
|
||||
cli_env,
|
||||
inject_user_message,
|
||||
omnigent_console_script,
|
||||
poll_for_assistant_marker,
|
||||
spawn_cli_background,
|
||||
wait_for_conversation_id,
|
||||
wait_for_terminal_ready,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("OMNIGENT_E2E_KIRO_NATIVE") != "1"
|
||||
or shutil.which("kiro-cli") is None
|
||||
or shutil.which("tmux") is None,
|
||||
reason=(
|
||||
"kiro-native CLI e2e needs an interactive Kiro login and a `tmux` "
|
||||
"binary; set OMNIGENT_E2E_KIRO_NATIVE=1 and have `kiro-cli` logged in"
|
||||
),
|
||||
)
|
||||
|
||||
_CONV_ID_TIMEOUT = 120.0
|
||||
_TERMINAL_READY_TIMEOUT = 90.0
|
||||
_REPLY_TIMEOUT = 180.0
|
||||
|
||||
|
||||
def test_kiro_native_cli_smoke(
|
||||
resume_test_server: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A Kiro-native turn driven through the server returns an assistant item."""
|
||||
pwd_dir = tmp_path / "pwd"
|
||||
pwd_dir.mkdir()
|
||||
marker = f"KIRO_{uuid.uuid4().hex[:8].upper()}"
|
||||
|
||||
omni = str(omnigent_console_script())
|
||||
handle = spawn_cli_background(
|
||||
[omni, "kiro", "--server", resume_test_server],
|
||||
env=cli_env(),
|
||||
cwd=str(pwd_dir),
|
||||
)
|
||||
try:
|
||||
conversation_id = wait_for_conversation_id(handle, timeout=_CONV_ID_TIMEOUT)
|
||||
with httpx.Client(base_url=resume_test_server, timeout=30) as client:
|
||||
wait_for_terminal_ready(
|
||||
client,
|
||||
conversation_id=conversation_id,
|
||||
harness="kiro",
|
||||
timeout=_TERMINAL_READY_TIMEOUT,
|
||||
)
|
||||
inject_user_message(
|
||||
client,
|
||||
conversation_id=conversation_id,
|
||||
text=f"Reply with ONLY this exact word and nothing else: {marker}",
|
||||
)
|
||||
try:
|
||||
poll_for_assistant_marker(
|
||||
client,
|
||||
conversation_id=conversation_id,
|
||||
marker=marker,
|
||||
timeout=_REPLY_TIMEOUT,
|
||||
)
|
||||
except AssertionError as exc:
|
||||
raise AssertionError(
|
||||
f"`omnigent kiro` did not return marker {marker!r}. The "
|
||||
"kiro-native path regressed somewhere between tmux input, "
|
||||
"the Kiro TUI turn, and session-forwarder mirroring.\n\n"
|
||||
f"CLI output tail:\n{handle.output()[-2000:]}"
|
||||
) from exc
|
||||
finally:
|
||||
handle.terminate()
|
||||
@@ -2471,6 +2471,95 @@ def native_goose_session(
|
||||
respawned.wait(timeout=5)
|
||||
|
||||
|
||||
def _create_native_kiro_session(base_url: str, runner_id: str) -> str:
|
||||
"""Register the ``kiro-native`` wrapper agent and bind its session.
|
||||
|
||||
Mirrors :func:`_create_native_goose_session`: reuses the terminal-first spec
|
||||
``omnigent kiro`` ships
|
||||
(:func:`omnigent.kiro_native._materialize_kiro_agent_spec`) and stamps the
|
||||
same wrapper / terminal-first labels. Binding triggers the runner's
|
||||
kiro-native auto-bootstrap
|
||||
(:func:`omnigent.runner.app._auto_create_kiro_terminal`), which launches the
|
||||
``kiro-cli`` TUI in the session terminal and starts the forwarder that mirrors
|
||||
the TUI transcript back as conversation items.
|
||||
|
||||
:param base_url: Spawned server base URL.
|
||||
:param runner_id: The token-bound runner id to bind.
|
||||
:returns: The new session/conversation id.
|
||||
"""
|
||||
import json as _json
|
||||
import tempfile
|
||||
|
||||
from omnigent._wrapper_labels import (
|
||||
KIRO_NATIVE_WRAPPER_VALUE,
|
||||
UI_MODE_LABEL_KEY,
|
||||
UI_MODE_TERMINAL_VALUE,
|
||||
WRAPPER_LABEL_KEY,
|
||||
)
|
||||
from omnigent.kiro_native import _materialize_kiro_agent_spec
|
||||
|
||||
with tempfile.TemporaryDirectory() as _tmp:
|
||||
spec_path = _materialize_kiro_agent_spec(Path(_tmp), model=None)
|
||||
yaml_text = spec_path.read_text()
|
||||
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
||||
data = yaml_text.encode()
|
||||
info = tarfile.TarInfo("kiro-native-ui.yaml")
|
||||
info.size = len(data)
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
|
||||
labels = {
|
||||
UI_MODE_LABEL_KEY: UI_MODE_TERMINAL_VALUE,
|
||||
WRAPPER_LABEL_KEY: KIRO_NATIVE_WRAPPER_VALUE,
|
||||
}
|
||||
metadata = {
|
||||
"labels": labels,
|
||||
"workspace": str(_REPO_ROOT),
|
||||
}
|
||||
create = httpx.post(
|
||||
f"{base_url}/v1/sessions",
|
||||
data={"metadata": _json.dumps(metadata)},
|
||||
files={"bundle": ("kiro-native-ui.tar.gz", buf.getvalue(), "application/gzip")},
|
||||
timeout=30.0,
|
||||
)
|
||||
create.raise_for_status()
|
||||
session_id = str(create.json()["session_id"])
|
||||
_bind_session_runner(base_url, session_id, runner_id)
|
||||
return session_id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def native_kiro_session(
|
||||
live_server: str,
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> Iterator[tuple[str, str]]:
|
||||
"""A runner-bound session on the real ``kiro-native`` ("Kiro") wrapper.
|
||||
|
||||
The runner auto-launches the ``kiro-cli`` TUI in the session terminal on bind,
|
||||
so the SPA's Terminal view attaches to a live Kiro TUI and its Chat view
|
||||
renders the same canonical transcript. Drives the kiro render-parity suite.
|
||||
|
||||
:param live_server: Spawned server fixture; its runner is reused.
|
||||
:param tmp_path_factory: Pytest temp path factory (for a respawn log).
|
||||
:returns: ``(base_url, session_id)``.
|
||||
"""
|
||||
respawned = _ensure_runner_online(live_server, tmp_path_factory)
|
||||
runner_id = str(_server_state["runner_id"])
|
||||
session_id = _create_native_kiro_session(live_server, runner_id)
|
||||
try:
|
||||
yield (live_server, session_id)
|
||||
finally:
|
||||
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
|
||||
if respawned is not None:
|
||||
respawned.terminate()
|
||||
try:
|
||||
respawned.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
respawned.kill()
|
||||
respawned.wait(timeout=5)
|
||||
|
||||
|
||||
def _create_native_hermes_session(base_url: str, runner_id: str) -> str:
|
||||
"""Register the ``hermes-native`` wrapper agent and bind its session.
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
r"""UI journey: a native Kiro session renders parity with its TUI.
|
||||
|
||||
The native ``kiro-native`` ("Kiro") wrapper is terminal-first: the ``kiro-cli``
|
||||
TUI runs in the session terminal, the SPA's **Terminal** view attaches to that
|
||||
live TUI over a WebSocket, and the SPA's **Chat** view renders the SAME canonical
|
||||
transcript the TUI prints. A native forwarder
|
||||
(:mod:`omnigent.kiro_native_session_forwarder`) tails Kiro's structured session
|
||||
JSONL and mirrors the transcript back OUT as conversation items; web-composer
|
||||
messages are injected INTO the TUI's tmux pane by
|
||||
:class:`omnigent.inner.kiro_native_executor.KiroNativeExecutor`. This suite is the
|
||||
kiro sibling of ``test_native_goose_render_parity`` / ``test_native_cursor_render_parity``
|
||||
and asserts the same three properties:
|
||||
|
||||
1. **Render parity with the TUI.** Composer turns are sent through the web SPA;
|
||||
each per-turn user marker and assistant token must also appear in the
|
||||
canonical transcript, in order, exactly once.
|
||||
2. **A TUI-originated message surfaces in the web UI.** A turn typed directly
|
||||
into the Kiro TUI must be mirrored back out as a user item + assistant reply.
|
||||
3. **No duplicate rendering.** Every marker/token lands in exactly one bubble.
|
||||
|
||||
Gating
|
||||
------
|
||||
Like cursor-/goose-native, Kiro authenticates against its own backend (``kiro``
|
||||
sign-in), which CI does not provision. The suite **skips** when
|
||||
``kiro-cli``/``tmux`` are absent, and runs for real where Kiro is signed in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
from .test_message_render_parity import (
|
||||
_ASSISTANT,
|
||||
_USER,
|
||||
_WORKING,
|
||||
_assert_no_duplicate_render,
|
||||
_assert_transcript_parity,
|
||||
_ensure_chat_view,
|
||||
_send,
|
||||
_turn_prompt,
|
||||
)
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_TERMINAL_VIEW = '[data-testid="terminal-view"]'
|
||||
_XTERM_INPUT = ".xterm-helper-textarea"
|
||||
|
||||
_NATIVE_TURN_TIMEOUT_MS = 180_000
|
||||
_TERMINAL_READY_TIMEOUT_MS = 120_000
|
||||
_COMPOSER_TURNS = 2
|
||||
|
||||
|
||||
def _kiro_unavailable_reason() -> str | None:
|
||||
"""Return a skip reason when the kiro-native prerequisites are absent.
|
||||
|
||||
kiro-native needs the ``kiro-cli`` binary + ``tmux`` on PATH and a signed-in
|
||||
Kiro account (``kiro`` authenticates against its own backend; there is no
|
||||
Omnigent-managed credential). CI provisions no Kiro account, so any missing
|
||||
prerequisite → a clean skip (not a failure), matching the cursor/goose suites.
|
||||
|
||||
:returns: A human-readable skip reason, or ``None`` when prerequisites exist.
|
||||
"""
|
||||
if shutil.which("kiro-cli") is None:
|
||||
return "kiro-native render-parity needs the `kiro-cli` binary on PATH."
|
||||
if shutil.which("tmux") is None:
|
||||
return "kiro-native render-parity needs `tmux` on PATH (runner-owned TUI pane)."
|
||||
return None
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
_kiro_unavailable_reason() is not None,
|
||||
reason=_kiro_unavailable_reason() or "",
|
||||
)
|
||||
|
||||
|
||||
def _open_terminal_view(page: Page) -> None:
|
||||
"""Switch a terminal-first session to its Terminal (TUI) view."""
|
||||
view_mode = page.get_by_role("group", name="View mode")
|
||||
expect(view_mode).to_be_visible(timeout=_TERMINAL_READY_TIMEOUT_MS)
|
||||
terminal_button = view_mode.get_by_role("button", name="Terminal")
|
||||
expect(terminal_button).to_be_visible(timeout=30_000)
|
||||
terminal_button.click()
|
||||
|
||||
|
||||
def _wait_terminal_connected(page: Page) -> None:
|
||||
"""Wait until the embedded xterm has attached to the live Kiro TUI."""
|
||||
terminal = page.locator(_TERMINAL_VIEW).last
|
||||
expect(terminal).to_have_attribute(
|
||||
"data-state", "connected", timeout=_TERMINAL_READY_TIMEOUT_MS
|
||||
)
|
||||
|
||||
|
||||
def _type_into_tui(page: Page, text: str) -> None:
|
||||
"""Type *text* into the embedded Kiro TUI and submit with Enter."""
|
||||
xterm_input = page.locator(_TERMINAL_VIEW).last.locator(_XTERM_INPUT)
|
||||
expect(xterm_input).to_be_attached(timeout=30_000)
|
||||
xterm_input.focus()
|
||||
page.keyboard.type(text, delay=15)
|
||||
page.wait_for_timeout(1500)
|
||||
page.keyboard.press("Enter")
|
||||
|
||||
|
||||
def _wait_marker_in_transcript(
|
||||
base_url: str, session_id: str, marker: str, *, timeout_ms: int
|
||||
) -> None:
|
||||
"""Poll the canonical transcript until *marker* appears (TUI turn forwarded)."""
|
||||
deadline = time.monotonic() + timeout_ms / 1000.0
|
||||
while time.monotonic() < deadline:
|
||||
resp = httpx.get(
|
||||
f"{base_url}/v1/sessions/{session_id}/items",
|
||||
params={"limit": 100, "order": "asc"},
|
||||
timeout=10.0,
|
||||
)
|
||||
if resp.status_code == 200 and any(
|
||||
marker in str(item.get("content")) for item in resp.json().get("data", [])
|
||||
):
|
||||
return
|
||||
time.sleep(2.0)
|
||||
raise AssertionError(
|
||||
f"marker {marker!r} never reached the transcript within {timeout_ms}ms — "
|
||||
f"the TUI-typed turn was not submitted/forwarded for {session_id}."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(900)
|
||||
def test_native_kiro_message_render_parity(
|
||||
page: Page,
|
||||
native_kiro_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""Native Kiro renders parity with its TUI, both ways, with no dupes.
|
||||
|
||||
Mirrors ``test_native_goose_message_render_parity``: composer parity (IN), a
|
||||
TUI-originated turn surfacing in the web UI (OUT), and no duplicate rendering.
|
||||
"""
|
||||
base_url, session_id = native_kiro_session
|
||||
_log.info("native-kiro session ready: base_url=%s session_id=%s", base_url, session_id)
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
|
||||
_open_terminal_view(page)
|
||||
_wait_terminal_connected(page)
|
||||
_log.info("Kiro TUI attached (terminal-view connected)")
|
||||
|
||||
user_markers: list[str] = []
|
||||
assistant_tokens: list[str] = []
|
||||
|
||||
def _new_turn(index: int) -> tuple[str, str]:
|
||||
nonce = uuid.uuid4().hex[:8]
|
||||
user_marker = f"usr-{index}-{nonce}"
|
||||
assistant_token = f"ast-{index}-{nonce}"
|
||||
user_markers.append(user_marker)
|
||||
assistant_tokens.append(assistant_token)
|
||||
return user_marker, assistant_token
|
||||
|
||||
# --- Property 1 & 3: composer turns (IN) render parity, no dupes. ---
|
||||
_ensure_chat_view(page)
|
||||
for index in range(1, _COMPOSER_TURNS + 1):
|
||||
user_marker, assistant_token = _new_turn(index)
|
||||
_send(page, _turn_prompt(index, user_marker, assistant_token))
|
||||
expect(page.locator(_ASSISTANT, has_text=assistant_token).first).to_be_visible(
|
||||
timeout=_NATIVE_TURN_TIMEOUT_MS
|
||||
)
|
||||
expect(page.locator(_WORKING)).to_have_count(0, timeout=_NATIVE_TURN_TIMEOUT_MS)
|
||||
expect(page.locator(_USER)).to_have_count(index, timeout=30_000)
|
||||
|
||||
# --- Property 2 & 3: a TUI-originated turn (OUT) surfaces in the web UI. ---
|
||||
tui_index = _COMPOSER_TURNS + 1
|
||||
tui_marker, tui_token = _new_turn(tui_index)
|
||||
_open_terminal_view(page)
|
||||
_wait_terminal_connected(page)
|
||||
_type_into_tui(page, _turn_prompt(tui_index, tui_marker, tui_token))
|
||||
_wait_marker_in_transcript(base_url, session_id, tui_token, timeout_ms=_NATIVE_TURN_TIMEOUT_MS)
|
||||
|
||||
_ensure_chat_view(page)
|
||||
expect(page.locator(_ASSISTANT, has_text=tui_token).first).to_be_visible(
|
||||
timeout=_NATIVE_TURN_TIMEOUT_MS
|
||||
)
|
||||
expect(page.locator(_USER, has_text=tui_marker).first).to_be_visible(timeout=30_000)
|
||||
expect(page.locator(_WORKING)).to_have_count(0, timeout=_NATIVE_TURN_TIMEOUT_MS)
|
||||
expect(page.locator(_USER)).to_have_count(len(user_markers), timeout=30_000)
|
||||
|
||||
# --- Assert all three properties over every turn. ---
|
||||
_assert_no_duplicate_render(page, user_markers, assistant_tokens)
|
||||
_assert_transcript_parity(base_url, session_id, user_markers, assistant_tokens)
|
||||
_log.info("all turns verified: render parity + no-duplicate-render + transcript parity")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Tests for the Kiro native executor scaffold."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent.inner.executor import ExecutorError, TurnComplete
|
||||
from omnigent.inner.kiro_native_executor import KiroNativeExecutor
|
||||
|
||||
|
||||
def test_kiro_native_executor_scaffold_capabilities() -> None:
|
||||
"""The executor is terminal-first and supports live queue injection."""
|
||||
executor = KiroNativeExecutor(bridge_dir=Path("/tmp/kiro-bridge"))
|
||||
|
||||
assert executor.supports_streaming() is False
|
||||
assert executor.supports_live_message_queue() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kiro_native_executor_injects_latest_user_message(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A web turn injects exactly the latest user text into the Kiro terminal."""
|
||||
injected: list[tuple[Path, str]] = []
|
||||
|
||||
def _fake_inject(bridge_dir: Path, *, content: str) -> None:
|
||||
injected.append((bridge_dir, content))
|
||||
|
||||
monkeypatch.setattr("omnigent.inner.kiro_native_executor.inject_user_message", _fake_inject)
|
||||
executor = KiroNativeExecutor(bridge_dir=tmp_path)
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in executor.run_turn(
|
||||
[
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
{"role": "user", "content": "second"},
|
||||
],
|
||||
[],
|
||||
"",
|
||||
)
|
||||
]
|
||||
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], TurnComplete)
|
||||
assert events[0].response is None
|
||||
assert injected == [(tmp_path, "second")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kiro_native_executor_surfaces_injection_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Bridge injection errors return an ExecutorError instead of hanging."""
|
||||
|
||||
def _fail_inject(bridge_dir: Path, *, content: str) -> None:
|
||||
del bridge_dir, content
|
||||
raise RuntimeError("kiro terminal is no longer running")
|
||||
|
||||
monkeypatch.setattr("omnigent.inner.kiro_native_executor.inject_user_message", _fail_inject)
|
||||
executor = KiroNativeExecutor(bridge_dir=tmp_path)
|
||||
|
||||
events = [
|
||||
event async for event in executor.run_turn([{"role": "user", "content": "hi"}], [], "")
|
||||
]
|
||||
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], ExecutorError)
|
||||
assert "kiro terminal is no longer running" in events[0].message
|
||||
|
||||
|
||||
def test_kiro_native_executor_requires_bridge_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The harness process must receive the Kiro bridge dir env."""
|
||||
from omnigent.kiro_native_bridge import KIRO_NATIVE_BRIDGE_DIR_ENV_VAR
|
||||
|
||||
monkeypatch.delenv(KIRO_NATIVE_BRIDGE_DIR_ENV_VAR, raising=False)
|
||||
|
||||
with pytest.raises(RuntimeError, match=KIRO_NATIVE_BRIDGE_DIR_ENV_VAR):
|
||||
KiroNativeExecutor()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Tests for the kiro-native harness app scaffold."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.runtime.harnesses import _HARNESS_MODULES
|
||||
|
||||
|
||||
def test_kiro_native_harness_module_is_registered() -> None:
|
||||
"""Runtime registry points at the importable Kiro native harness module."""
|
||||
assert _HARNESS_MODULES["kiro-native"] == "omnigent.inner.kiro_native_harness"
|
||||
|
||||
|
||||
def test_kiro_native_harness_create_app_imports() -> None:
|
||||
"""The harness module exports the required FastAPI app factory."""
|
||||
from omnigent.inner.kiro_native_harness import create_app
|
||||
|
||||
assert isinstance(create_app(), FastAPI)
|
||||
@@ -51,6 +51,16 @@ def test_cursor_install_spec_is_login_only_no_npm() -> None:
|
||||
assert spec.login_status_key == "isAuthenticated"
|
||||
|
||||
|
||||
def test_kiro_install_spec_is_manual_installer_no_npm() -> None:
|
||||
"""Kiro ships as a standalone native installer, not an npm package."""
|
||||
spec = hi.harness_install_spec(hi.KIRO_KEY)
|
||||
assert spec is not None
|
||||
assert spec.display == "Kiro"
|
||||
assert spec.binary == "kiro-cli"
|
||||
assert spec.package is None
|
||||
assert spec.install_hint == "curl -fsSL https://cli.kiro.dev/install | bash"
|
||||
|
||||
|
||||
def test_antigravity_install_spec_status_only_no_npm() -> None:
|
||||
"""Antigravity (agy) ships via a shell installer (no npm) and has no login
|
||||
subcommand — the user signs in by launching ``agy`` once. It DOES expose a
|
||||
@@ -87,14 +97,16 @@ def test_harness_setup_hint_antigravity_surfaces_sign_in() -> None:
|
||||
|
||||
|
||||
def test_install_command_rejects_non_npm_harness() -> None:
|
||||
"""A non-npm harness (cursor) has no npm install command; asking for one is
|
||||
"""A non-npm harness has no npm install command; asking for one is
|
||||
a loud error so the caller shows its ``install_hint`` instead."""
|
||||
with pytest.raises(ValueError):
|
||||
hi.harness_install_command(hi.CURSOR_KEY)
|
||||
with pytest.raises(ValueError):
|
||||
hi.harness_install_command(hi.KIRO_KEY)
|
||||
|
||||
|
||||
def test_install_harness_cli_noop_for_non_npm(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``install_harness_cli`` never shells npm for a non-npm CLI (cursor).
|
||||
"""``install_harness_cli`` never shells npm for a non-npm CLI.
|
||||
|
||||
It returns ``False`` without spawning anything, so the menu falls back to
|
||||
the manual ``install_hint`` rather than running a bogus npm command.
|
||||
@@ -106,6 +118,7 @@ def test_install_harness_cli_noop_for_non_npm(monkeypatch: pytest.MonkeyPatch) -
|
||||
|
||||
monkeypatch.setattr(hi.subprocess, "run", _explode)
|
||||
assert hi.install_harness_cli(hi.CURSOR_KEY) is False
|
||||
assert hi.install_harness_cli(hi.KIRO_KEY) is False
|
||||
|
||||
|
||||
def test_unknown_key_has_no_spec_and_is_not_installed() -> None:
|
||||
@@ -125,6 +138,8 @@ def test_unknown_key_has_no_spec_and_is_not_installed() -> None:
|
||||
# ``cursor`` harness, which needs no binary — see the test below).
|
||||
("cursor-native", "cursor-agent"),
|
||||
("native-cursor", "cursor-agent"),
|
||||
("kiro-native", "kiro-cli"),
|
||||
("native-kiro", "kiro-cli"),
|
||||
],
|
||||
)
|
||||
def test_required_cli_for_cli_backed_harness(harness: str, binary: str) -> None:
|
||||
@@ -155,6 +170,15 @@ def test_setup_hint_for_native_cursor_points_at_vendor_installer(harness: str) -
|
||||
assert "omnigent setup" not in hint
|
||||
|
||||
|
||||
@pytest.mark.parametrize("harness", ["kiro-native", "native-kiro"])
|
||||
def test_setup_hint_for_native_kiro_points_at_vendor_installer(harness: str) -> None:
|
||||
"""Native Kiro's missing-binary hint names Kiro's installer, not setup."""
|
||||
hint = hi.harness_setup_hint(harness)
|
||||
assert "kiro-cli" in hint
|
||||
assert "cli.kiro.dev/install" in hint
|
||||
assert "omnigent setup" not in hint
|
||||
|
||||
|
||||
@pytest.mark.parametrize("harness", ["claude-native", "codex", "pi", "claude-sdk", None])
|
||||
def test_setup_hint_defaults_to_omnigent_setup(harness: str | None) -> None:
|
||||
"""Harnesses whose CLI ``omnigent setup`` installs (npm CLIs) — and the
|
||||
|
||||
@@ -80,7 +80,8 @@ def test_sdk_and_unknown_harnesses_are_never_gated(
|
||||
|
||||
# CLI-wrapping harnesses are gated on their binary being on PATH. Native Cursor
|
||||
# (``omni cursor``) joins the list: it wraps the ``cursor-agent`` CLI, unlike the
|
||||
# SDK ``cursor`` harness which gates on a key (covered separately below).
|
||||
# SDK ``cursor`` harness which gates on a key (covered separately below). Native
|
||||
# Kiro wraps the standalone ``kiro-cli`` binary.
|
||||
@pytest.mark.parametrize(
|
||||
"harness",
|
||||
[
|
||||
@@ -92,6 +93,8 @@ def test_sdk_and_unknown_harnesses_are_never_gated(
|
||||
"pi",
|
||||
"cursor-native",
|
||||
"native-cursor",
|
||||
"kiro-native",
|
||||
"native-kiro",
|
||||
"goose-native",
|
||||
"native-goose",
|
||||
"hermes",
|
||||
@@ -145,6 +148,9 @@ def test_configured_harness_map_covers_all_spellings(
|
||||
# Native Cursor (``omni cursor``) — gates on the cursor-agent CLI.
|
||||
"cursor-native",
|
||||
"native-cursor",
|
||||
# Native Kiro (``omni kiro``) — gates on the kiro-cli binary.
|
||||
"kiro-native",
|
||||
"native-kiro",
|
||||
# Goose — native TUI (``omni goose``) + headless ACP harness; both gate
|
||||
# on the goose CLI.
|
||||
"goose",
|
||||
@@ -218,6 +224,8 @@ def test_configured_harness_map_gates_only_cli_harnesses(
|
||||
"pi",
|
||||
"cursor-native",
|
||||
"native-cursor",
|
||||
"kiro-native",
|
||||
"native-kiro",
|
||||
"antigravity-native",
|
||||
"native-antigravity",
|
||||
"goose-native",
|
||||
|
||||
@@ -26,7 +26,12 @@ import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent import claude_native_bridge, codex_native_bridge, cursor_native_bridge
|
||||
from omnigent import (
|
||||
claude_native_bridge,
|
||||
codex_native_bridge,
|
||||
cursor_native_bridge,
|
||||
kiro_native_bridge,
|
||||
)
|
||||
from omnigent.antigravity_native_bridge import (
|
||||
ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY,
|
||||
)
|
||||
@@ -53,9 +58,11 @@ from omnigent.runner.app import (
|
||||
_auto_create_claude_terminal,
|
||||
_auto_create_codex_terminal,
|
||||
_auto_create_cursor_terminal,
|
||||
_auto_create_kiro_terminal,
|
||||
_auto_create_pi_terminal,
|
||||
_auto_create_repl_terminal,
|
||||
_deliver_subagent_wake_post,
|
||||
_KiroNativeLaunchConfig,
|
||||
_log_terminal_lookup_miss,
|
||||
_PiNativeLaunchConfig,
|
||||
_publish_native_terminal_start_error,
|
||||
@@ -70,6 +77,7 @@ from omnigent.runner.resource_registry import (
|
||||
ANTIGRAVITY_NATIVE_TERMINAL_ROLE,
|
||||
CLAUDE_NATIVE_TERMINAL_ROLE,
|
||||
CODEX_NATIVE_TERMINAL_ROLE,
|
||||
KIRO_NATIVE_TERMINAL_ROLE,
|
||||
OMNIGENT_REPL_TERMINAL_ROLE,
|
||||
PI_NATIVE_TERMINAL_ROLE,
|
||||
SessionResourceRegistry,
|
||||
@@ -1163,6 +1171,51 @@ async def test_create_session_threads_cursor_bridge_dir_without_dead_guard_env(
|
||||
assert "HARNESS_CURSOR_NATIVE_REQUEST_SESSION_ID" not in env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_threads_kiro_bridge_dir(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Kiro-native session pre-spawn emits the Kiro bridge dir env."""
|
||||
monkeypatch.setattr(kiro_native_bridge, "_BRIDGE_ROOT", tmp_path / "kiro-bridge")
|
||||
spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="kiro-native-agent",
|
||||
executor=ExecutorSpec(
|
||||
config={"harness": "kiro-native", "model": "auto"},
|
||||
),
|
||||
)
|
||||
harness_client = _ScriptedHarnessClient([])
|
||||
pm = _FakeProcessManager(harness_client)
|
||||
|
||||
async def _resolver(agent_id: str, session_id: str | None = None) -> ResolvedSpec:
|
||||
del agent_id, session_id
|
||||
return ResolvedSpec(spec=spec, workdir=tmp_path)
|
||||
|
||||
app = create_runner_app(
|
||||
process_manager=pm, # type: ignore[arg-type]
|
||||
spec_resolver=_resolver,
|
||||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
async with _runner_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/v1/sessions",
|
||||
json={"session_id": "conv_kiro", "agent_id": "ag_kiro"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert pm.get_client_calls
|
||||
conversation_id, harness, env = pm.get_client_calls[-1]
|
||||
assert conversation_id == "conv_kiro"
|
||||
assert harness == "kiro-native"
|
||||
assert env == {
|
||||
kiro_native_bridge.KIRO_NATIVE_BRIDGE_DIR_ENV_VAR: str(
|
||||
kiro_native_bridge.bridge_dir_for_session_id("conv_kiro")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_threads_workspace_to_pi_cwd(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -10011,6 +10064,96 @@ async def test_required_terminal_clean_quit_publishes_idle_not_failed(
|
||||
assert pm.released == [conv_id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_idle_status_makes_required_terminal_exit_clean(tmp_path: Path) -> None:
|
||||
"""
|
||||
A structured native ``idle`` status prevents a later pane close from failing.
|
||||
|
||||
Kiro completion is observed from its persisted JSONL session, not only from
|
||||
PTY diff-idle. After a web turn marks the required terminal ``running``, the
|
||||
forwarded ``external_session_status: idle`` must update the same exit memo
|
||||
used by the required-terminal watcher; otherwise a normal user close after
|
||||
Kiro answered is misclassified as ``required_terminal_exited``.
|
||||
|
||||
:param tmp_path: Temporary directory for fake terminal paths.
|
||||
"""
|
||||
from omnigent.runner.app import _session_event_queues_ref
|
||||
from tests.runner.helpers import make_test_terminal_instance
|
||||
|
||||
conv_id = f"conv_kiro_external_idle_exit_{uuid.uuid4().hex[:12]}"
|
||||
terminal_registry = TerminalRegistry()
|
||||
instance = make_test_terminal_instance("kiro", "main", tmp_path)
|
||||
terminal_registry._by_conversation.setdefault(conv_id, {})[("kiro", "main")] = instance
|
||||
callbacks: dict[str, Any] = {}
|
||||
|
||||
def _capture_watcher(
|
||||
on_idle: object | None = None,
|
||||
*,
|
||||
on_activity: object | None = None,
|
||||
on_exit: object | None = None,
|
||||
idle_threshold_s: float | None = None,
|
||||
poll_interval_s: float | None = None,
|
||||
replace: bool = False,
|
||||
) -> None:
|
||||
del on_idle, on_activity, idle_threshold_s, poll_interval_s, replace
|
||||
callbacks["on_exit"] = on_exit
|
||||
|
||||
instance.start_idle_watcher_thread = _capture_watcher # type: ignore[method-assign]
|
||||
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
||||
pm._sessions.add(conv_id)
|
||||
app = create_runner_app(
|
||||
process_manager=pm, # type: ignore[arg-type]
|
||||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||||
terminal_registry=terminal_registry,
|
||||
)
|
||||
resource_registry = app.state.session_resource_registry
|
||||
|
||||
try:
|
||||
await resource_registry.observe_required_terminal(
|
||||
conv_id,
|
||||
"kiro",
|
||||
"main",
|
||||
instance,
|
||||
resource_role=KIRO_NATIVE_TERMINAL_ROLE,
|
||||
)
|
||||
resource_registry.note_session_turn_started(conv_id)
|
||||
async with _runner_client(app) as client:
|
||||
status_resp = await client.post(
|
||||
f"/v1/sessions/{conv_id}/events",
|
||||
json={"type": "external_session_status", "data": {"status": "idle"}},
|
||||
)
|
||||
assert status_resp.status_code == 204, status_resp.text
|
||||
|
||||
on_exit = callbacks.get("on_exit")
|
||||
assert callable(on_exit)
|
||||
on_exit()
|
||||
deleted_event = {
|
||||
"type": "session.resource.deleted",
|
||||
"resource_id": "terminal_kiro_main",
|
||||
"resource_type": "terminal",
|
||||
"session_id": conv_id,
|
||||
}
|
||||
queued_events: list[dict[str, Any]] = []
|
||||
for _ in range(1000):
|
||||
queued_events.extend(
|
||||
_drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
||||
)
|
||||
if pm.released and deleted_event in queued_events:
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
finally:
|
||||
_session_event_queues_ref.pop(conv_id, None)
|
||||
|
||||
assert terminal_registry.get(conv_id, "kiro", "main") is None
|
||||
assert deleted_event in queued_events
|
||||
assert [
|
||||
event
|
||||
for event in queued_events
|
||||
if event.get("type") == "session.status" and event.get("status") == "failed"
|
||||
] == []
|
||||
assert pm.released == [conv_id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_effort_change_on_native_session_types_slash_command(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -11442,6 +11585,113 @@ async def test_auto_create_pi_terminal_launches_required_terminal(
|
||||
assert any(evt.get("type") == "session.resource.created" for evt in published)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_kiro_terminal_launches_required_terminal_with_isolated_env(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Kiro-native auto-create launches the TUI and session forwarder."""
|
||||
import omnigent.kiro_native as kiro_native
|
||||
|
||||
monkeypatch.setenv("PATH", "/usr/bin")
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:6767")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "must-not-leak")
|
||||
monkeypatch.setattr(kiro_native_bridge, "_BRIDGE_ROOT", tmp_path / "kiro-bridge")
|
||||
monkeypatch.setattr(
|
||||
kiro_native,
|
||||
"resolve_kiro_executable",
|
||||
lambda **_kwargs: "/usr/bin/kiro-cli",
|
||||
)
|
||||
forwarder_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def _fake_supervise_kiro_session_forwarder(**kwargs: Any) -> None:
|
||||
forwarder_calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"omnigent.kiro_native_session_forwarder.supervise_kiro_session_forwarder",
|
||||
_fake_supervise_kiro_session_forwarder,
|
||||
)
|
||||
|
||||
async def _fake_launch_config(**_kwargs: Any) -> _KiroNativeLaunchConfig:
|
||||
return _KiroNativeLaunchConfig(
|
||||
workspace=tmp_path,
|
||||
terminal_launch_args=["--model", "auto", "--effort", "high", "hello"],
|
||||
external_session_id="kiro-session-123",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("omnigent.runner.app._kiro_native_launch_config", _fake_launch_config)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class _FakeResourceRegistry:
|
||||
"""Records the launch; exposes ONLY the required-terminal launch API."""
|
||||
|
||||
terminal_registry = None
|
||||
|
||||
async def launch_required_terminal(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
terminal_name: str,
|
||||
session_key: str,
|
||||
spec: Any,
|
||||
resource_role: str | None = None,
|
||||
parent_os_env: Any = None,
|
||||
) -> SessionResourceView:
|
||||
del parent_os_env
|
||||
captured["terminal_name"] = terminal_name
|
||||
captured["session_key"] = session_key
|
||||
captured["resource_role"] = resource_role
|
||||
captured["spec"] = spec
|
||||
return SessionResourceView(
|
||||
id="terminal_kiro_main",
|
||||
type="terminal",
|
||||
session_id=session_id,
|
||||
name="kiro:main",
|
||||
metadata={"terminal_name": "kiro", "session_key": "main", "running": True},
|
||||
)
|
||||
|
||||
published: list[dict[str, Any]] = []
|
||||
|
||||
await _auto_create_kiro_terminal(
|
||||
"conv_kiro",
|
||||
_FakeResourceRegistry(), # type: ignore[arg-type]
|
||||
lambda _sid, evt: published.append(evt),
|
||||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
spec = captured["spec"]
|
||||
assert captured["terminal_name"] == "kiro"
|
||||
assert captured["session_key"] == "main"
|
||||
assert captured["resource_role"] == KIRO_NATIVE_TERMINAL_ROLE
|
||||
assert spec.command == "/usr/bin/kiro-cli"
|
||||
assert spec.args == [
|
||||
"chat",
|
||||
"--tui",
|
||||
"--resume-id",
|
||||
"kiro-session-123",
|
||||
"--model",
|
||||
"auto",
|
||||
"--effort",
|
||||
"high",
|
||||
"hello",
|
||||
]
|
||||
assert spec.inherit_env is False
|
||||
assert "OPENAI_API_KEY" not in spec.env
|
||||
assert "OPENAI_API_KEY" in spec.env_unset
|
||||
assert spec.env[kiro_native_bridge.KIRO_NATIVE_BRIDGE_DIR_ENV_VAR] == str(
|
||||
kiro_native_bridge.bridge_dir_for_session_id("conv_kiro")
|
||||
)
|
||||
assert any(evt.get("type") == "session.resource.created" for evt in published)
|
||||
assert forwarder_calls
|
||||
assert forwarder_calls[0]["base_url"] == "http://127.0.0.1:6767"
|
||||
assert forwarder_calls[0]["session_id"] == "conv_kiro"
|
||||
assert forwarder_calls[0]["agent_name"] == "kiro-native-ui"
|
||||
assert forwarder_calls[0]["workspace"] == str(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_pi_terminal_inherits_agent_sandbox(
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -177,6 +177,35 @@ def test_resolve_oldest_returns_content_with_file_blocks() -> None:
|
||||
assert drained.content == content
|
||||
|
||||
|
||||
def test_resolve_matching_text_skips_older_unmatched_entries() -> None:
|
||||
"""Kiro can match the accepted prompt and identify older failed inputs."""
|
||||
first = pending_inputs.record(
|
||||
"conv_a", [_text_block("!!!! XOXOX !!!!")], created_by="alice@example.com"
|
||||
)
|
||||
second = pending_inputs.record("conv_a", [_text_block("tell me a joke")])
|
||||
|
||||
drained = pending_inputs.resolve_matching_text("conv_a", "tell me a joke")
|
||||
|
||||
assert drained.matched is not None
|
||||
assert drained.matched.pending_id == second
|
||||
assert drained.matched.content == [_text_block("tell me a joke")]
|
||||
assert [entry.pending_id for entry in drained.skipped] == [first]
|
||||
assert drained.skipped[0].content == [_text_block("!!!! XOXOX !!!!")]
|
||||
assert drained.skipped[0].created_by == "alice@example.com"
|
||||
assert pending_inputs.snapshot_for("conv_a") == []
|
||||
|
||||
|
||||
def test_resolve_matching_text_leaves_entries_when_no_text_matches() -> None:
|
||||
"""A direct Kiro TUI prompt must not consume unrelated web pending entries."""
|
||||
first = pending_inputs.record("conv_a", [_text_block("web input")])
|
||||
|
||||
drained = pending_inputs.resolve_matching_text("conv_a", "typed in terminal")
|
||||
|
||||
assert drained.matched is None
|
||||
assert drained.skipped == []
|
||||
assert [entry["pending_id"] for entry in pending_inputs.snapshot_for("conv_a")] == [first]
|
||||
|
||||
|
||||
def test_resolve_removes_entry_idempotently() -> None:
|
||||
"""
|
||||
:func:`resolve` drops an entry by id (forward-failed rollback).
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from omnigent.entities import DEFAULT_ENVIRONMENT_ID, Conversation, ConversationItem, PagedList
|
||||
@@ -57,6 +57,17 @@ class _ConversationStore:
|
||||
"omnigent.wrapper": "claude-code-native-ui",
|
||||
},
|
||||
),
|
||||
"conv_kiro": Conversation(
|
||||
id="conv_kiro",
|
||||
created_at=1,
|
||||
updated_at=1,
|
||||
root_conversation_id="conv_kiro",
|
||||
agent_id="ag_kiro",
|
||||
labels={
|
||||
"omnigent.ui": "terminal",
|
||||
"omnigent.wrapper": "kiro-native-ui",
|
||||
},
|
||||
),
|
||||
# A spec-driven native sub-agent child (e.g. a nessie
|
||||
# claude_code reviewer): kind="sub_agent" with a parent
|
||||
# ref and the regular native wrapper label (NOT the
|
||||
@@ -2646,6 +2657,151 @@ async def test_native_dispatch_fast_fails_and_consumes_message_on_terminal_error
|
||||
assert errors[0].data.message == "Native Claude requires the 'claude' CLI on PATH."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kiro_native_dispatch_forwards_without_persisting() -> None:
|
||||
"""Kiro web-chat input is mirrored by Kiro's session forwarder."""
|
||||
from omnigent.runtime import pending_inputs
|
||||
from omnigent.server.routes.sessions import _dispatch_session_event_to_runner
|
||||
|
||||
pending_inputs.reset_for_tests()
|
||||
store = _ConversationStore()
|
||||
conv = store.get_conversation("conv_kiro")
|
||||
assert conv is not None
|
||||
client = _FakeRunnerClient()
|
||||
body = SessionEventInput(
|
||||
type="message",
|
||||
data={"role": "user", "content": [{"type": "input_text", "text": "hello"}]},
|
||||
)
|
||||
|
||||
try:
|
||||
result = await _dispatch_session_event_to_runner(
|
||||
"conv_kiro",
|
||||
conv,
|
||||
body,
|
||||
store, # type: ignore[arg-type]
|
||||
client, # type: ignore[arg-type]
|
||||
agent_name="kiro-native-ui",
|
||||
file_store=None,
|
||||
artifact_store=None,
|
||||
created_by="alice@example.com",
|
||||
)
|
||||
|
||||
assert result.item_id is None
|
||||
assert result.pending_id is not None
|
||||
assert result.pending_id.startswith("pending_")
|
||||
assert [call[0] for call in client.post_json_calls] == [
|
||||
"/v1/sessions/conv_kiro/resources/terminals",
|
||||
"/v1/sessions/conv_kiro/events",
|
||||
]
|
||||
pending = pending_inputs.snapshot_for("conv_kiro")
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["content"] == [{"type": "input_text", "text": "hello"}]
|
||||
assert store.appended_items == []
|
||||
forwarded = client.post_json_calls[1][1]
|
||||
assert forwarded["agent_id"] == "ag_kiro"
|
||||
assert forwarded["model"] == "kiro-native-ui"
|
||||
finally:
|
||||
pending_inputs.reset_for_tests()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kiro_native_dispatch_clears_pending_when_injection_fails() -> None:
|
||||
"""A failed Kiro tmux injection must not leave a ghost pending input."""
|
||||
from omnigent.runtime import pending_inputs
|
||||
from omnigent.server.routes.sessions import _dispatch_session_event_to_runner
|
||||
|
||||
pending_inputs.reset_for_tests()
|
||||
store = _ConversationStore()
|
||||
conv = store.get_conversation("conv_kiro")
|
||||
assert conv is not None
|
||||
client = _FakeRunnerClient(
|
||||
responses={"/v1/sessions/conv_kiro/events": (500, {"error": "tmux failed"})}
|
||||
)
|
||||
body = SessionEventInput(
|
||||
type="message",
|
||||
data={"role": "user", "content": [{"type": "input_text", "text": "hello"}]},
|
||||
)
|
||||
|
||||
try:
|
||||
with pytest.raises(HTTPException):
|
||||
await _dispatch_session_event_to_runner(
|
||||
"conv_kiro",
|
||||
conv,
|
||||
body,
|
||||
store, # type: ignore[arg-type]
|
||||
client, # type: ignore[arg-type]
|
||||
agent_name="kiro-native-ui",
|
||||
file_store=None,
|
||||
artifact_store=None,
|
||||
created_by="alice@example.com",
|
||||
)
|
||||
|
||||
assert [call[0] for call in client.post_json_calls] == [
|
||||
"/v1/sessions/conv_kiro/resources/terminals",
|
||||
"/v1/sessions/conv_kiro/events",
|
||||
]
|
||||
assert store.appended_items == []
|
||||
assert pending_inputs.snapshot_for("conv_kiro") == []
|
||||
finally:
|
||||
pending_inputs.reset_for_tests()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kiro_external_prompt_matches_pending_and_reports_skipped_input() -> None:
|
||||
"""A failed Kiro prompt must not make the next prompt clear the wrong pending input."""
|
||||
from omnigent.runtime import pending_inputs
|
||||
from omnigent.server.routes.sessions import _persist_external_conversation_item
|
||||
|
||||
pending_inputs.reset_for_tests()
|
||||
store = _ConversationStore()
|
||||
conv = store.get_conversation("conv_kiro")
|
||||
assert conv is not None
|
||||
first = pending_inputs.record(
|
||||
"conv_kiro",
|
||||
[{"type": "input_text", "text": "!!!! XOXOX !!!!"}],
|
||||
created_by="alice@example.com",
|
||||
)
|
||||
second = pending_inputs.record(
|
||||
"conv_kiro",
|
||||
[{"type": "input_text", "text": "tell me a joke"}],
|
||||
created_by="alice@example.com",
|
||||
)
|
||||
body = SessionEventInput(
|
||||
type="external_conversation_item",
|
||||
data={
|
||||
"item_type": "message",
|
||||
"item_data": {
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "tell me a joke"}],
|
||||
},
|
||||
"response_id": "kiro:prompt-joke",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
item_id = await _persist_external_conversation_item(
|
||||
"conv_kiro",
|
||||
conv,
|
||||
body,
|
||||
store, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert item_id == "item_2"
|
||||
assert pending_inputs.snapshot_for("conv_kiro") == []
|
||||
assert [item.type for item in store.appended_items] == ["message", "error", "message"]
|
||||
skipped_user, skipped_error, matched_user = store.appended_items
|
||||
assert skipped_user.data.role == "user"
|
||||
assert skipped_user.data.content == [{"type": "input_text", "text": "!!!! XOXOX !!!!"}]
|
||||
assert skipped_user.created_by == "alice@example.com"
|
||||
assert skipped_error.data.code == "kiro_native_prompt_not_recorded"
|
||||
assert matched_user.data.role == "user"
|
||||
assert matched_user.data.content == [{"type": "input_text", "text": "tell me a joke"}]
|
||||
assert matched_user.created_by == "alice@example.com"
|
||||
assert first != second
|
||||
finally:
|
||||
pending_inputs.reset_for_tests()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_dispatch_reports_malformed_runner_error_body() -> None:
|
||||
"""Opaque framework 500 bodies become explicit ensure errors.
|
||||
|
||||
@@ -28,6 +28,7 @@ from omnigent.spec import load, materialize_bundle
|
||||
_BUILDERS = [
|
||||
("_build_claude_native_bundle", "claude-native-ui.yaml", False),
|
||||
("_build_codex_native_bundle", "codex-native-ui.yaml", False),
|
||||
("_build_kiro_native_bundle", "kiro-native-ui.yaml", False),
|
||||
("_build_debby_bundle", "config.yaml", True),
|
||||
("_build_polly_bundle", "config.yaml", True),
|
||||
]
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
from omnigent.harness_aliases import canonicalize_harness, is_native_harness
|
||||
from omnigent.spec._omnigent_compat import OMNIGENT_HARNESSES
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -12,6 +13,7 @@ from omnigent.harness_aliases import canonicalize_harness, is_native_harness
|
||||
[
|
||||
("claude", "claude-sdk"),
|
||||
("native-pi", "pi-native"),
|
||||
("native-kiro", "kiro-native"),
|
||||
# Docs / runtime-dispatch spelling of the openai-agents harness;
|
||||
# specs and OMNIGENT_HARNESSES use "openai-agents".
|
||||
("openai-agents-sdk", "openai-agents"),
|
||||
@@ -49,6 +51,8 @@ def test_canonicalize_harness(alias: str | None, canonical: str | None) -> None:
|
||||
("native-codex", True),
|
||||
("pi-native", True),
|
||||
("native-pi", True),
|
||||
("kiro-native", True),
|
||||
("native-kiro", True),
|
||||
# SDK harnesses are NOT native — they replay the Omnigent
|
||||
# transcript and don't own an on-disk runtime transcript. A
|
||||
# regression that classified these as native would wrongly route a
|
||||
@@ -58,6 +62,7 @@ def test_canonicalize_harness(alias: str | None, canonical: str | None) -> None:
|
||||
("openai-agents", False),
|
||||
("agents_sdk", False),
|
||||
("codex", False),
|
||||
("kiro", False),
|
||||
# The "claude" shorthand canonicalizes to claude-sdk (not native).
|
||||
("claude", False),
|
||||
# cursor is a headless ACP harness, not a native CLI bridge.
|
||||
@@ -74,3 +79,9 @@ def test_is_native_harness(harness: str | None, expected: bool) -> None:
|
||||
in-process SDK turns, or vice versa.
|
||||
"""
|
||||
assert is_native_harness(harness) is expected
|
||||
|
||||
|
||||
def test_kiro_native_is_valid_omnigent_harness_but_plain_kiro_is_not() -> None:
|
||||
"""Kiro's native identity is canonical; plain ``kiro`` is not a generic harness."""
|
||||
assert "kiro-native" in OMNIGENT_HARNESSES
|
||||
assert "kiro" not in OMNIGENT_HARNESSES
|
||||
|
||||
@@ -24,6 +24,25 @@ def test_pi_harnesses_gate_on_pi_cli(harness: str, monkeypatch: pytest.MonkeyPat
|
||||
assert hr.harness_is_configured(harness) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("harness", ["kiro-native", "native-kiro"])
|
||||
def test_kiro_native_harnesses_gate_on_kiro_cli(
|
||||
harness: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Native Kiro is gated on the ``kiro-cli`` binary being installed."""
|
||||
calls: list[str] = []
|
||||
|
||||
def _installed(key: str) -> bool:
|
||||
calls.append(key)
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(hr, "harness_cli_installed", _installed)
|
||||
assert hr.harness_is_configured(harness) is False
|
||||
assert calls[-1] == hr.KIRO_KEY
|
||||
|
||||
monkeypatch.setattr(hr, "harness_cli_installed", lambda _key: True)
|
||||
assert hr.harness_is_configured(harness) is True
|
||||
|
||||
|
||||
def test_sdk_and_unknown_harnesses_still_fail_open(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""SDK and unknown harnesses are never gated, even with no CLI installed.
|
||||
|
||||
@@ -47,3 +66,11 @@ def test_configured_harness_map_exposes_pi_native(monkeypatch: pytest.MonkeyPatc
|
||||
cmap = hr.configured_harness_map()
|
||||
assert cmap.get("pi-native") is False
|
||||
assert cmap.get("pi") is False
|
||||
|
||||
|
||||
def test_configured_harness_map_exposes_kiro_native(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The readiness map carries Kiro native keys for the web picker lookup."""
|
||||
monkeypatch.setattr(hr, "harness_cli_installed", lambda _key: False)
|
||||
cmap = hr.configured_harness_map()
|
||||
assert cmap.get("kiro-native") is False
|
||||
assert cmap.get("native-kiro") is False
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
"""Tests for native Kiro CLI orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from click import ClickException
|
||||
|
||||
from omnigent._wrapper_labels import KIRO_NATIVE_WRAPPER_VALUE, WRAPPER_LABEL_KEY
|
||||
from omnigent.kiro_native import (
|
||||
_KIRO_PATH_ENV,
|
||||
LaunchedKiroTerminal,
|
||||
PreparedKiroTerminal,
|
||||
_attach_terminal_resource,
|
||||
_create_kiro_session,
|
||||
_direct_tmux_unavailable_reason,
|
||||
_ensure_kiro_terminal_on_runner,
|
||||
_fetch_kiro_session,
|
||||
_find_running_kiro_terminal,
|
||||
_launched_kiro_terminal_from_payload,
|
||||
_materialize_kiro_agent_spec,
|
||||
_preflight_local_tools,
|
||||
_resolve_session_id_for_resume,
|
||||
_tmux_attach_env,
|
||||
_update_startup_progress,
|
||||
_wait_for_kiro_terminal_ready,
|
||||
build_kiro_launch,
|
||||
kiro_terminal_resource_id,
|
||||
resolve_kiro_executable,
|
||||
run_kiro_native,
|
||||
)
|
||||
|
||||
_NO_JSON = object()
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Minimal stand-in for ``httpx.Response`` used by the async helpers."""
|
||||
|
||||
def __init__(self, status_code: int, *, json_body: object = _NO_JSON, text: str = "") -> None:
|
||||
self.status_code = status_code
|
||||
self._json_body = json_body
|
||||
self.text = text
|
||||
|
||||
def json(self) -> object:
|
||||
if self._json_body is _NO_JSON:
|
||||
raise ValueError("no JSON body")
|
||||
return self._json_body
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""Records calls and replays queued ``_FakeResponse`` objects per method."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str, dict]] = []
|
||||
self._queued: dict[str, list[_FakeResponse]] = {}
|
||||
|
||||
def queue(self, method: str, *responses: _FakeResponse) -> None:
|
||||
self._queued.setdefault(method, []).extend(responses)
|
||||
|
||||
async def _replay(self, method: str, url: str, kwargs: dict) -> _FakeResponse:
|
||||
self.calls.append((method, url, kwargs))
|
||||
return self._queued[method].pop(0)
|
||||
|
||||
async def get(self, url: str, **kwargs: object) -> _FakeResponse:
|
||||
return await self._replay("get", url, kwargs)
|
||||
|
||||
async def post(self, url: str, **kwargs: object) -> _FakeResponse:
|
||||
return await self._replay("post", url, kwargs)
|
||||
|
||||
async def patch(self, url: str, **kwargs: object) -> _FakeResponse:
|
||||
return await self._replay("patch", url, kwargs)
|
||||
|
||||
|
||||
def test_materialize_kiro_agent_spec_uses_native_identity(tmp_path: Path) -> None:
|
||||
"""The generated wrapper spec targets ``kiro-native`` and terminal-first labels."""
|
||||
path = _materialize_kiro_agent_spec(tmp_path, model="auto")
|
||||
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
|
||||
assert raw["name"] == "kiro-native-ui"
|
||||
assert raw["executor"] == {"harness": "kiro-native", "model": "auto"}
|
||||
assert raw["spawn"] is True
|
||||
|
||||
|
||||
def test_materialized_kiro_agent_spec_passes_current_validator(tmp_path: Path) -> None:
|
||||
"""``omnigent kiro`` must not be rejected as an unknown harness at upload."""
|
||||
from omnigent.spec._omnigent_compat import load_omnigent_yaml
|
||||
|
||||
path = _materialize_kiro_agent_spec(tmp_path, model=None)
|
||||
|
||||
spec = load_omnigent_yaml(path)
|
||||
|
||||
assert spec.executor.config["harness"] == "kiro-native"
|
||||
|
||||
|
||||
def test_launched_kiro_terminal_decodes_tmux_metadata() -> None:
|
||||
"""Runner terminal metadata is converted into attach details."""
|
||||
terminal = _launched_kiro_terminal_from_payload(
|
||||
{
|
||||
"id": "terminal_kiro_main",
|
||||
"metadata": {
|
||||
"tmux_socket": "/tmp/kiro.sock",
|
||||
"tmux_target": "main",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert terminal.terminal_id == "terminal_kiro_main"
|
||||
assert terminal.tmux_socket == Path("/tmp/kiro.sock")
|
||||
assert terminal.tmux_target == "main"
|
||||
|
||||
|
||||
def test_build_kiro_launch_includes_resume_id() -> None:
|
||||
"""Cold resume launches Kiro against the captured native session id."""
|
||||
launch = build_kiro_launch(
|
||||
["--effort", "high"],
|
||||
resume_id="kiro-session-123",
|
||||
env={},
|
||||
which=lambda _cmd: "/usr/bin/kiro-cli",
|
||||
)
|
||||
|
||||
assert launch.argv == [
|
||||
"/usr/bin/kiro-cli",
|
||||
"chat",
|
||||
"--tui",
|
||||
"--resume-id",
|
||||
"kiro-session-123",
|
||||
"--effort",
|
||||
"high",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_terminal_resource_requires_tmux_metadata() -> None:
|
||||
"""A runner response without tmux attach metadata fails clearly."""
|
||||
prepared = PreparedKiroTerminal(
|
||||
session_id="conv_abc",
|
||||
terminal_id="terminal_kiro_main",
|
||||
tmux_socket=None,
|
||||
tmux_target=None,
|
||||
reattached=False,
|
||||
)
|
||||
|
||||
with pytest.raises(ClickException, match="Runner-owned Kiro terminal"):
|
||||
await _attach_terminal_resource(prepared)
|
||||
|
||||
|
||||
def test_session_labels_use_kiro_wrapper_value() -> None:
|
||||
"""Kiro wrapper sessions stamp the centralized wrapper label."""
|
||||
from omnigent.kiro_native import _SESSION_LABELS
|
||||
|
||||
assert _SESSION_LABELS[WRAPPER_LABEL_KEY] == KIRO_NATIVE_WRAPPER_VALUE
|
||||
|
||||
|
||||
def test_live_kiro_cli_binary_reports_version_when_installed() -> None:
|
||||
"""Skippable smoke test for the native Kiro binary expected by the harness."""
|
||||
binary = shutil.which("kiro-cli")
|
||||
if binary is None:
|
||||
pytest.skip("kiro-cli is not installed on PATH")
|
||||
|
||||
result = subprocess.run(
|
||||
[binary, "--version"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "kiro-cli" in result.stdout.lower()
|
||||
|
||||
|
||||
def test_resolve_kiro_executable_errors_when_missing() -> None:
|
||||
"""A missing kiro-cli yields an actionable install/login hint."""
|
||||
with pytest.raises(ClickException, match="kiro-cli"):
|
||||
resolve_kiro_executable(env={}, which=lambda _cmd: None)
|
||||
|
||||
|
||||
def test_resolve_kiro_executable_honors_path_override() -> None:
|
||||
"""``OMNIGENT_KIRO_PATH`` selects the executable to resolve."""
|
||||
seen: list[str] = []
|
||||
|
||||
def _which(command: str) -> str:
|
||||
seen.append(command)
|
||||
return f"/opt/{command}"
|
||||
|
||||
resolved = resolve_kiro_executable(env={_KIRO_PATH_ENV: "custom-kiro"}, which=_which)
|
||||
|
||||
assert resolved == "/opt/custom-kiro"
|
||||
assert seen == ["custom-kiro"]
|
||||
|
||||
|
||||
def test_build_kiro_launch_appends_model_then_prompt() -> None:
|
||||
"""Model flag precedes passthrough args; a prompt is the final argv token."""
|
||||
launch = build_kiro_launch(
|
||||
["--foo"],
|
||||
model="claude",
|
||||
prompt="hello world",
|
||||
env={},
|
||||
which=lambda _cmd: "/usr/bin/kiro-cli",
|
||||
)
|
||||
|
||||
assert launch.argv == [
|
||||
"/usr/bin/kiro-cli",
|
||||
"chat",
|
||||
"--tui",
|
||||
"--model",
|
||||
"claude",
|
||||
"--foo",
|
||||
"hello world",
|
||||
]
|
||||
|
||||
|
||||
def test_launched_kiro_terminal_rejects_non_object_payload() -> None:
|
||||
"""A non-dict runner payload is reported as malformed."""
|
||||
with pytest.raises(ClickException, match="non-object JSON"):
|
||||
_launched_kiro_terminal_from_payload(["not", "a", "dict"])
|
||||
|
||||
|
||||
def test_launched_kiro_terminal_requires_terminal_id() -> None:
|
||||
"""A payload without an id cannot be turned into attach details."""
|
||||
with pytest.raises(ClickException, match="terminal id"):
|
||||
_launched_kiro_terminal_from_payload({"metadata": {}})
|
||||
|
||||
|
||||
def test_launched_kiro_terminal_without_metadata_has_no_tmux() -> None:
|
||||
"""Missing tmux metadata leaves the attach fields unset (cold terminal)."""
|
||||
terminal = _launched_kiro_terminal_from_payload({"id": "terminal_kiro_main"})
|
||||
|
||||
assert terminal.terminal_id == "terminal_kiro_main"
|
||||
assert terminal.tmux_socket is None
|
||||
assert terminal.tmux_target is None
|
||||
|
||||
|
||||
def test_tmux_attach_env_filters_to_allowlist(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Only allowlisted, set environment keys reach the tmux attach process."""
|
||||
monkeypatch.setenv("TERM", "xterm-256color")
|
||||
monkeypatch.setenv("OMNIGENT_UNLISTED_VAR", "present")
|
||||
|
||||
env = _tmux_attach_env()
|
||||
|
||||
assert env["TERM"] == "xterm-256color"
|
||||
assert "OMNIGENT_UNLISTED_VAR" not in env
|
||||
|
||||
|
||||
def test_direct_tmux_unavailable_reason_reports_each_gap(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Each missing prerequisite yields a distinct, specific reason."""
|
||||
|
||||
def _prepared(socket: Path | None, target: str | None) -> PreparedKiroTerminal:
|
||||
return PreparedKiroTerminal(
|
||||
session_id="conv",
|
||||
terminal_id="terminal_kiro_main",
|
||||
tmux_socket=socket,
|
||||
tmux_target=target,
|
||||
reattached=False,
|
||||
)
|
||||
|
||||
assert "tmux socket path" in (_direct_tmux_unavailable_reason(_prepared(None, "main")) or "")
|
||||
assert "tmux target" in (
|
||||
_direct_tmux_unavailable_reason(_prepared(tmp_path / "s.sock", None)) or ""
|
||||
)
|
||||
assert "not reachable" in (
|
||||
_direct_tmux_unavailable_reason(_prepared(tmp_path / "missing.sock", "main")) or ""
|
||||
)
|
||||
|
||||
socket = tmp_path / "live.sock"
|
||||
socket.touch()
|
||||
monkeypatch.setattr(shutil, "which", lambda _cmd: None)
|
||||
assert "tmux is not available" in (
|
||||
_direct_tmux_unavailable_reason(_prepared(socket, "main")) or ""
|
||||
)
|
||||
|
||||
monkeypatch.setattr(shutil, "which", lambda _cmd: "/usr/bin/tmux")
|
||||
assert _direct_tmux_unavailable_reason(_prepared(socket, "main")) is None
|
||||
|
||||
|
||||
def test_update_startup_progress_is_a_noop_without_renderer() -> None:
|
||||
"""A ``None`` progress renderer is tolerated silently."""
|
||||
_update_startup_progress(None, "anything")
|
||||
|
||||
|
||||
def test_update_startup_progress_forwards_to_renderer() -> None:
|
||||
"""An active renderer receives the milestone message verbatim."""
|
||||
seen: list[str] = []
|
||||
|
||||
class _Progress:
|
||||
def update(self, message: str) -> None:
|
||||
seen.append(message)
|
||||
|
||||
_update_startup_progress(_Progress(), "Starting Kiro terminal...")
|
||||
|
||||
assert seen == ["Starting Kiro terminal..."]
|
||||
|
||||
|
||||
def test_preflight_local_tools_requires_tmux(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The native wrapper refuses to start without a local tmux."""
|
||||
monkeypatch.setattr(shutil, "which", lambda _cmd: None)
|
||||
|
||||
with pytest.raises(ClickException, match="tmux was not found"):
|
||||
_preflight_local_tools()
|
||||
|
||||
|
||||
def test_preflight_local_tools_passes_with_tmux(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A present tmux satisfies the preflight check."""
|
||||
monkeypatch.setattr(shutil, "which", lambda _cmd: "/usr/bin/tmux")
|
||||
|
||||
_preflight_local_tools()
|
||||
|
||||
|
||||
def test_kiro_terminal_resource_id_is_deterministic() -> None:
|
||||
"""The terminal resource id is stable across calls."""
|
||||
assert kiro_terminal_resource_id() == kiro_terminal_resource_id()
|
||||
assert isinstance(kiro_terminal_resource_id(), str)
|
||||
|
||||
|
||||
def test_resolve_session_id_for_resume_passthrough() -> None:
|
||||
"""An explicit session id is returned without touching the network."""
|
||||
resolved = _resolve_session_id_for_resume(
|
||||
base_url="http://server",
|
||||
headers={},
|
||||
session_id="conv_explicit",
|
||||
resume_picker=False,
|
||||
)
|
||||
|
||||
assert resolved == "conv_explicit"
|
||||
|
||||
|
||||
def test_resolve_session_id_for_resume_no_picker_returns_none() -> None:
|
||||
"""Without a session id or picker there is nothing to resume."""
|
||||
resolved = _resolve_session_id_for_resume(
|
||||
base_url="http://server",
|
||||
headers={},
|
||||
session_id=None,
|
||||
resume_picker=False,
|
||||
)
|
||||
|
||||
assert resolved is None
|
||||
|
||||
|
||||
def test_run_kiro_native_requires_server(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A missing server URL is a programming error surfaced as a clear message."""
|
||||
monkeypatch.setattr("omnigent.kiro_native._preflight_local_tools", lambda: None)
|
||||
|
||||
with pytest.raises(ClickException, match="resolved Omnigent server URL"):
|
||||
run_kiro_native(server=None, session_id=None, kiro_args=())
|
||||
|
||||
|
||||
def test_run_kiro_native_materializes_spec_and_delegates(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The launcher writes a spec and hands a trimmed base URL to the server path."""
|
||||
monkeypatch.setattr("omnigent.kiro_native._preflight_local_tools", lambda: None)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _fake_remote(base_url: str, spec_path: Path, **kwargs: object) -> None:
|
||||
captured["base_url"] = base_url
|
||||
captured["spec_exists"] = spec_path.exists()
|
||||
captured["kwargs"] = kwargs
|
||||
|
||||
monkeypatch.setattr("omnigent.kiro_native._run_with_remote_server", _fake_remote)
|
||||
|
||||
run_kiro_native(
|
||||
server="http://server/",
|
||||
session_id=None,
|
||||
kiro_args=("--foo",),
|
||||
model="claude",
|
||||
prompt="hi",
|
||||
)
|
||||
|
||||
assert captured["base_url"] == "http://server"
|
||||
assert captured["spec_exists"] is True
|
||||
assert captured["kwargs"]["model"] == "claude"
|
||||
|
||||
|
||||
async def test_create_kiro_session_returns_id_and_persists_args() -> None:
|
||||
"""A successful create returns the new id and forwards launch args as metadata."""
|
||||
client = _FakeClient()
|
||||
client.queue("post", _FakeResponse(200, json_body={"session_id": "conv_new"}))
|
||||
|
||||
session_id = await _create_kiro_session(
|
||||
client, b"bundle-bytes", terminal_launch_args=["--foo"]
|
||||
)
|
||||
|
||||
assert session_id == "conv_new"
|
||||
_method, _url, kwargs = client.calls[0]
|
||||
metadata = json.loads(kwargs["data"]["metadata"])
|
||||
assert metadata["terminal_launch_args"] == ["--foo"]
|
||||
assert WRAPPER_LABEL_KEY in metadata["labels"]
|
||||
|
||||
|
||||
async def test_create_kiro_session_raises_on_error_status() -> None:
|
||||
"""A 4xx/5xx create response is surfaced with the status code."""
|
||||
client = _FakeClient()
|
||||
client.queue("post", _FakeResponse(500, json_body={"error": "boom"}))
|
||||
|
||||
with pytest.raises(ClickException, match="creation failed \\(500\\)"):
|
||||
await _create_kiro_session(client, b"bundle")
|
||||
|
||||
|
||||
async def test_create_kiro_session_requires_session_id_in_body() -> None:
|
||||
"""A success body lacking a session id is treated as malformed."""
|
||||
client = _FakeClient()
|
||||
client.queue("post", _FakeResponse(200, json_body={}))
|
||||
|
||||
with pytest.raises(ClickException, match="did not include session_id"):
|
||||
await _create_kiro_session(client, b"bundle")
|
||||
|
||||
|
||||
async def test_fetch_kiro_session_maps_404_to_not_found() -> None:
|
||||
"""A 404 fetch is reported as a missing conversation."""
|
||||
client = _FakeClient()
|
||||
client.queue("get", _FakeResponse(404))
|
||||
|
||||
with pytest.raises(ClickException, match="not found"):
|
||||
await _fetch_kiro_session(client, "conv_missing")
|
||||
|
||||
|
||||
async def test_fetch_kiro_session_raises_on_error_status() -> None:
|
||||
"""A non-404 error fetch is surfaced with the status code."""
|
||||
client = _FakeClient()
|
||||
client.queue("get", _FakeResponse(500, json_body={"error": "boom"}))
|
||||
|
||||
with pytest.raises(ClickException, match="Failed to fetch conversation"):
|
||||
await _fetch_kiro_session(client, "conv")
|
||||
|
||||
|
||||
async def test_fetch_kiro_session_rejects_non_object_payload() -> None:
|
||||
"""A non-object fetch payload is malformed."""
|
||||
client = _FakeClient()
|
||||
client.queue("get", _FakeResponse(200, json_body=["unexpected"]))
|
||||
|
||||
with pytest.raises(ClickException, match="non-object JSON"):
|
||||
await _fetch_kiro_session(client, "conv")
|
||||
|
||||
|
||||
async def test_fetch_kiro_session_returns_payload() -> None:
|
||||
"""A healthy fetch returns the decoded session object."""
|
||||
client = _FakeClient()
|
||||
client.queue("get", _FakeResponse(200, json_body={"labels": {"x": "y"}}))
|
||||
|
||||
payload = await _fetch_kiro_session(client, "conv")
|
||||
|
||||
assert payload == {"labels": {"x": "y"}}
|
||||
|
||||
|
||||
async def test_ensure_kiro_terminal_on_runner_posts_native_flag() -> None:
|
||||
"""Ensuring the terminal asks the runner for a native terminal."""
|
||||
client = _FakeClient()
|
||||
client.queue("post", _FakeResponse(200, json_body={}))
|
||||
|
||||
await _ensure_kiro_terminal_on_runner(client, "conv")
|
||||
|
||||
_method, _url, kwargs = client.calls[0]
|
||||
assert kwargs["json"]["ensure_native_terminal"] is True
|
||||
|
||||
|
||||
async def test_ensure_kiro_terminal_on_runner_raises_on_error() -> None:
|
||||
"""A failed ensure surfaces the status code."""
|
||||
client = _FakeClient()
|
||||
client.queue("post", _FakeResponse(503, json_body={"error": "no runner"}))
|
||||
|
||||
with pytest.raises(ClickException, match="ensure failed \\(503\\)"):
|
||||
await _ensure_kiro_terminal_on_runner(client, "conv")
|
||||
|
||||
|
||||
async def test_find_running_kiro_terminal_absent_is_none() -> None:
|
||||
"""A 404 terminal lookup means no running terminal yet."""
|
||||
client = _FakeClient()
|
||||
client.queue("get", _FakeResponse(404))
|
||||
|
||||
assert await _find_running_kiro_terminal(client, "conv") is None
|
||||
|
||||
|
||||
async def test_find_running_kiro_terminal_unbound_runner_is_none() -> None:
|
||||
"""A transient 'not bound to a runner' is treated as not-yet-running."""
|
||||
client = _FakeClient()
|
||||
client.queue("get", _FakeResponse(409, text="session not bound to a runner"))
|
||||
|
||||
assert await _find_running_kiro_terminal(client, "conv") is None
|
||||
|
||||
|
||||
async def test_find_running_kiro_terminal_hard_error_raises() -> None:
|
||||
"""An unexpected error status is surfaced rather than swallowed."""
|
||||
client = _FakeClient()
|
||||
client.queue("get", _FakeResponse(500, json_body={"error": "boom"}))
|
||||
|
||||
with pytest.raises(ClickException, match="Failed to fetch Kiro terminal"):
|
||||
await _find_running_kiro_terminal(client, "conv")
|
||||
|
||||
|
||||
async def test_find_running_kiro_terminal_not_running_metadata_is_none() -> None:
|
||||
"""A terminal explicitly flagged not-running is ignored."""
|
||||
client = _FakeClient()
|
||||
client.queue(
|
||||
"get",
|
||||
_FakeResponse(200, json_body={"id": "terminal_kiro_main", "metadata": {"running": False}}),
|
||||
)
|
||||
|
||||
assert await _find_running_kiro_terminal(client, "conv") is None
|
||||
|
||||
|
||||
async def test_find_running_kiro_terminal_returns_attach_details() -> None:
|
||||
"""A live terminal payload is decoded into attach details."""
|
||||
client = _FakeClient()
|
||||
client.queue(
|
||||
"get",
|
||||
_FakeResponse(
|
||||
200,
|
||||
json_body={
|
||||
"id": "terminal_kiro_main",
|
||||
"metadata": {"tmux_socket": "/tmp/k.sock", "tmux_target": "main"},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
terminal = await _find_running_kiro_terminal(client, "conv")
|
||||
|
||||
assert isinstance(terminal, LaunchedKiroTerminal)
|
||||
assert terminal.tmux_socket == Path("/tmp/k.sock")
|
||||
|
||||
|
||||
async def test_wait_for_kiro_terminal_ready_returns_first_hit() -> None:
|
||||
"""The poll loop returns as soon as the terminal resource appears."""
|
||||
client = _FakeClient()
|
||||
client.queue("get", _FakeResponse(200, json_body={"id": "terminal_kiro_main"}))
|
||||
|
||||
terminal = await _wait_for_kiro_terminal_ready(client, "conv", timeout_s=1.0)
|
||||
|
||||
assert terminal.terminal_id == "terminal_kiro_main"
|
||||
|
||||
|
||||
async def test_wait_for_kiro_terminal_ready_times_out() -> None:
|
||||
"""An absent terminal eventually fails with a timeout message."""
|
||||
client = _FakeClient()
|
||||
client.queue("get", _FakeResponse(404))
|
||||
|
||||
with pytest.raises(ClickException, match="did not create the Kiro terminal"):
|
||||
await _wait_for_kiro_terminal_ready(client, "conv", timeout_s=0.05)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Tests for Kiro native tmux bridge helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import omnigent.kiro_native_bridge as bridge
|
||||
from omnigent.kiro_native_bridge import (
|
||||
inject_user_message,
|
||||
write_forwarder_ready,
|
||||
write_tmux_target,
|
||||
)
|
||||
|
||||
_READY_PANE = (
|
||||
"old output\n────────────────\nkiro_default · auto\n\n ask a question or describe a task ↵"
|
||||
)
|
||||
|
||||
|
||||
def _install_fake_tmux(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
pane_outputs: list[str] | None = None,
|
||||
) -> list[list[str]]:
|
||||
"""Replace subprocess.run with a successful tmux stub."""
|
||||
calls: list[list[str]] = []
|
||||
captures = list(pane_outputs or [_READY_PANE])
|
||||
last_capture = captures[-1]
|
||||
|
||||
def _fake_run(args: list[str], **_kwargs: Any) -> SimpleNamespace:
|
||||
nonlocal last_capture
|
||||
calls.append(args)
|
||||
if "capture-pane" in args:
|
||||
if captures:
|
||||
last_capture = captures.pop(0)
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout=last_capture,
|
||||
stderr="",
|
||||
)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", _fake_run)
|
||||
return calls
|
||||
|
||||
|
||||
def test_inject_user_message_does_not_wait_for_forwarder_on_fresh_kiro_session(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A brand-new Kiro session has no JSONL yet, so injection cannot require it."""
|
||||
monkeypatch.setattr(bridge, "_TYPE_COMMIT_TIMEOUT_S", 0.0)
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
calls = _install_fake_tmux(monkeypatch)
|
||||
write_tmux_target(
|
||||
bridge_dir,
|
||||
socket_path=Path("/tmp/tmux.sock"),
|
||||
tmux_target="main",
|
||||
)
|
||||
|
||||
inject_user_message(bridge_dir, content="hello", timeout_s=0.1)
|
||||
|
||||
assert any(call[-1] == "Enter" for call in calls)
|
||||
assert any(call[-1] == "hello" and "-l" in call for call in calls)
|
||||
assert not any("load-buffer" in call or "paste-buffer" in call for call in calls)
|
||||
|
||||
|
||||
def test_inject_user_message_waits_for_forwarder_on_resumed_kiro_session(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A resumed Kiro session waits for JSONL forwarder catch-up before typing."""
|
||||
monkeypatch.setattr(bridge, "_TYPE_COMMIT_TIMEOUT_S", 0.0)
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
calls = _install_fake_tmux(monkeypatch)
|
||||
write_tmux_target(
|
||||
bridge_dir,
|
||||
socket_path=Path("/tmp/tmux.sock"),
|
||||
tmux_target="main",
|
||||
requires_forwarder_ready=True,
|
||||
)
|
||||
write_forwarder_ready(bridge_dir)
|
||||
|
||||
inject_user_message(bridge_dir, content="hello", timeout_s=0.1)
|
||||
|
||||
assert any(call[-1] == "Enter" for call in calls)
|
||||
|
||||
|
||||
def test_inject_user_message_waits_for_kiro_input_prompt(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A restarted Kiro TUI must render its input prompt before typing."""
|
||||
monkeypatch.setattr(bridge, "_TYPE_COMMIT_TIMEOUT_S", 0.0)
|
||||
monkeypatch.setattr(bridge, "_POLL_INTERVAL_S", 0.0)
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
calls = _install_fake_tmux(
|
||||
monkeypatch,
|
||||
pane_outputs=[
|
||||
"Kiro loading...",
|
||||
_READY_PANE,
|
||||
],
|
||||
)
|
||||
write_tmux_target(
|
||||
bridge_dir,
|
||||
socket_path=Path("/tmp/tmux.sock"),
|
||||
tmux_target="main",
|
||||
)
|
||||
|
||||
inject_user_message(bridge_dir, content="hello", timeout_s=0.1)
|
||||
|
||||
capture_indexes = [index for index, call in enumerate(calls) if "capture-pane" in call]
|
||||
type_index = next(index for index, call in enumerate(calls) if "-l" in call)
|
||||
assert len(capture_indexes) >= 2
|
||||
assert max(capture_indexes[:2]) < type_index
|
||||
|
||||
|
||||
def test_inject_user_message_fails_when_kiro_input_prompt_never_renders(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Lost first input should fail instead of typing into a booting pane."""
|
||||
monkeypatch.setattr(bridge, "_POLL_INTERVAL_S", 0.0)
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
_install_fake_tmux(monkeypatch, pane_outputs=["Kiro loading..."])
|
||||
write_tmux_target(
|
||||
bridge_dir,
|
||||
socket_path=Path("/tmp/tmux.sock"),
|
||||
tmux_target="main",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="input prompt was not ready"):
|
||||
inject_user_message(bridge_dir, content="hello", timeout_s=0.01)
|
||||
|
||||
|
||||
def test_inject_user_message_chunks_literal_typing(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Kiro text delivery avoids tmux's command-length cap."""
|
||||
monkeypatch.setattr(bridge, "_TYPE_COMMIT_TIMEOUT_S", 0.0)
|
||||
monkeypatch.setattr(bridge, "_SEND_KEYS_LITERAL_CHARS_PER_CALL", 4)
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
calls = _install_fake_tmux(monkeypatch)
|
||||
write_tmux_target(
|
||||
bridge_dir,
|
||||
socket_path=Path("/tmp/tmux.sock"),
|
||||
tmux_target="main",
|
||||
)
|
||||
|
||||
inject_user_message(bridge_dir, content="helloworld", timeout_s=0.1)
|
||||
|
||||
typed = [call[-1] for call in calls if "-l" in call]
|
||||
assert typed == ["hell", "owor", "ld"]
|
||||
|
||||
|
||||
def test_inject_user_message_dash_prefixed_text_is_sent_literally(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A message starting with '-' injects as literal text, not a tmux flag.
|
||||
|
||||
``send-keys -l`` parses a leading ``-`` (e.g. ``-N``) as an option even in
|
||||
literal mode, so the send must pass ``--`` before the content — otherwise a
|
||||
dash-prefixed message (or a 1024-char chunk boundary landing on one) fails
|
||||
to inject silently.
|
||||
"""
|
||||
monkeypatch.setattr(bridge, "_TYPE_COMMIT_TIMEOUT_S", 0.0)
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
calls = _install_fake_tmux(monkeypatch)
|
||||
write_tmux_target(
|
||||
bridge_dir,
|
||||
socket_path=Path("/tmp/tmux.sock"),
|
||||
tmux_target="main",
|
||||
)
|
||||
|
||||
inject_user_message(bridge_dir, content="-N5 dangerous", timeout_s=0.1)
|
||||
|
||||
literal_calls = [call for call in calls if "-l" in call]
|
||||
assert literal_calls, "expected a literal send-keys call"
|
||||
for call in literal_calls:
|
||||
# ``--`` must immediately precede the literal content so a leading '-'
|
||||
# is sent as text, never parsed as a flag.
|
||||
assert "--" in call
|
||||
assert call.index("--") == len(call) - 2
|
||||
assert call[-1] == "-N5 dangerous"
|
||||
|
||||
|
||||
def test_inject_user_message_fails_when_resumed_forwarder_is_not_ready(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A resumed first message must fail instead of being pasted too early."""
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
_install_fake_tmux(monkeypatch)
|
||||
write_tmux_target(
|
||||
bridge_dir,
|
||||
socket_path=Path("/tmp/tmux.sock"),
|
||||
tmux_target="main",
|
||||
requires_forwarder_ready=True,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="session forwarder was not ready"):
|
||||
inject_user_message(bridge_dir, content="hello", timeout_s=0.1)
|
||||
|
||||
|
||||
def test_draft_in_input_region_ignores_matching_history_and_baseline() -> None:
|
||||
"""Short messages like '2' must match only a changed Kiro input region."""
|
||||
baseline = "kiro_default · auto · ◔ 2%\n\n ask a question or describe a task ↵"
|
||||
pane_with_history_only = "2\n\nold answer\n────────────────\n" + baseline
|
||||
pane_with_draft = "old 2\n────────────────\nkiro_default · auto · ◔ 2%\n\n 2"
|
||||
|
||||
assert not bridge._draft_in_input_region(pane_with_history_only, "2", baseline)
|
||||
assert bridge._draft_in_input_region(pane_with_draft, "2", baseline)
|
||||
|
||||
|
||||
def test_draft_in_input_region_ignores_kiro_chrome_for_short_messages() -> None:
|
||||
"""One-character prompts must not match cwd, branch, or placeholder chrome."""
|
||||
baseline = (
|
||||
"kiro_default · auto · ◔ 3% ~/Work/omnigent · "
|
||||
"(feat/kiro-cli-harness)\n\n ask a question or describe a task ↵"
|
||||
)
|
||||
pane_after_submit = (
|
||||
"c\n\n🙂\n────────────────\nkiro_default · auto · ◔ 4% "
|
||||
"~/Work/omnigent · (feat/kiro-cli-harness)\n\n "
|
||||
"ask a question or describe a task ↵\n/copy to clipboard"
|
||||
)
|
||||
pane_with_draft = (
|
||||
"old answer\n────────────────\nkiro_default · auto · ◔ 3% "
|
||||
"~/Work/omnigent · (feat/kiro-cli-harness)\n\n c"
|
||||
)
|
||||
|
||||
assert not bridge._draft_in_input_region(pane_after_submit, "c", baseline)
|
||||
assert bridge._draft_in_input_region(pane_with_draft, "c", baseline)
|
||||
@@ -0,0 +1,489 @@
|
||||
"""Tests for mirroring Kiro's persisted CLI session into Omnigent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import omnigent.kiro_native_session_forwarder as forwarder
|
||||
|
||||
|
||||
def _write_kiro_session(
|
||||
root: Path,
|
||||
*,
|
||||
session_id: str,
|
||||
cwd: Path,
|
||||
created_at: str = "2026-06-21T01:39:34.528139806Z",
|
||||
updated_at: str = "2026-06-21T01:40:41.838294036Z",
|
||||
lines: list[dict[str, Any]] | None = None,
|
||||
) -> Path:
|
||||
"""Create a minimal Kiro CLI session metadata + JSONL fixture."""
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / f"{session_id}.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"session_id": session_id,
|
||||
"cwd": str(cwd),
|
||||
"created_at": created_at,
|
||||
"updated_at": updated_at,
|
||||
"title": "hello",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
jsonl_path = root / f"{session_id}.jsonl"
|
||||
jsonl_path.write_text(
|
||||
"\n".join(json.dumps(line) for line in (lines or [])) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return jsonl_path
|
||||
|
||||
|
||||
def test_discover_kiro_session_jsonl_filters_by_workspace_and_launch_time(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Discovery chooses the newest Kiro session for the runner workspace."""
|
||||
sessions_dir = tmp_path / "sessions" / "cli"
|
||||
workspace = tmp_path / "repo"
|
||||
other = tmp_path / "other"
|
||||
workspace.mkdir()
|
||||
other.mkdir()
|
||||
_write_kiro_session(
|
||||
sessions_dir,
|
||||
session_id="old",
|
||||
cwd=workspace,
|
||||
created_at="2026-06-21T01:00:00Z",
|
||||
updated_at="2026-06-21T01:00:01Z",
|
||||
)
|
||||
_write_kiro_session(
|
||||
sessions_dir,
|
||||
session_id="wrong-cwd",
|
||||
cwd=other,
|
||||
created_at="2026-06-21T01:39:35Z",
|
||||
updated_at="2026-06-21T01:41:00Z",
|
||||
)
|
||||
expected = _write_kiro_session(
|
||||
sessions_dir,
|
||||
session_id="current",
|
||||
cwd=workspace,
|
||||
created_at="2026-06-21T01:39:35Z",
|
||||
updated_at="2026-06-21T01:40:00Z",
|
||||
)
|
||||
|
||||
discovered = forwarder._discover_kiro_session_jsonl(
|
||||
workspace=str(workspace),
|
||||
launch_epoch_ms=forwarder._parse_iso_epoch_ms("2026-06-21T01:39:34Z"),
|
||||
sessions_dir=sessions_dir,
|
||||
)
|
||||
|
||||
assert discovered == ("current", expected)
|
||||
|
||||
|
||||
def test_read_new_kiro_messages_returns_user_and_assistant_text(tmp_path: Path) -> None:
|
||||
"""The JSONL reader mirrors Kiro prompt and assistant message text."""
|
||||
jsonl_path = tmp_path / "session.jsonl"
|
||||
jsonl_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
json.dumps(
|
||||
{
|
||||
"version": "v1",
|
||||
"kind": "Prompt",
|
||||
"data": {
|
||||
"message_id": "user-1",
|
||||
"content": [{"kind": "text", "data": "hey"}],
|
||||
},
|
||||
}
|
||||
),
|
||||
json.dumps(
|
||||
{
|
||||
"version": "v1",
|
||||
"kind": "AssistantMessage",
|
||||
"data": {
|
||||
"message_id": "assistant-1",
|
||||
"content": [{"kind": "text", "data": "Hello there"}],
|
||||
},
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
messages, byte_offset = forwarder._read_new_kiro_messages(jsonl_path, 0)
|
||||
|
||||
assert messages == [
|
||||
forwarder._KiroConversationMessage(message_id="user-1", role="user", text="hey"),
|
||||
forwarder._KiroConversationMessage(
|
||||
message_id="assistant-1", role="assistant", text="Hello there"
|
||||
),
|
||||
]
|
||||
assert byte_offset == jsonl_path.stat().st_size
|
||||
|
||||
|
||||
def test_read_new_kiro_messages_holds_offset_at_partial_trailing_line(tmp_path: Path) -> None:
|
||||
"""A record still mid-write (no trailing newline) is not skipped.
|
||||
|
||||
Kiro appends to the JSONL live, so a poll can catch a partial final line.
|
||||
The reader must hold the offset at the last complete (newline-terminated)
|
||||
line so the partial record is re-read once Kiro finishes it — persisting
|
||||
``handle.tell()`` past the partial line would drop that record for good.
|
||||
"""
|
||||
complete = json.dumps(
|
||||
{
|
||||
"version": "v1",
|
||||
"kind": "Prompt",
|
||||
"data": {"message_id": "user-1", "content": [{"kind": "text", "data": "first"}]},
|
||||
}
|
||||
)
|
||||
partial = json.dumps(
|
||||
{
|
||||
"version": "v1",
|
||||
"kind": "AssistantMessage",
|
||||
"data": {"message_id": "assistant-1", "content": [{"kind": "text", "data": "second"}]},
|
||||
}
|
||||
)
|
||||
jsonl_path = tmp_path / "session.jsonl"
|
||||
# Complete line + a partial second line with NO trailing newline.
|
||||
jsonl_path.write_text(complete + "\n" + partial, encoding="utf-8")
|
||||
|
||||
messages, offset = forwarder._read_new_kiro_messages(jsonl_path, 0)
|
||||
|
||||
# Only the complete record is delivered; the offset stops at its newline,
|
||||
# not at EOF (which would skip the partial record once it's finished).
|
||||
assert messages == [
|
||||
forwarder._KiroConversationMessage(message_id="user-1", role="user", text="first")
|
||||
]
|
||||
assert offset == len((complete + "\n").encode("utf-8"))
|
||||
assert offset < jsonl_path.stat().st_size
|
||||
|
||||
# Kiro finishes the second record; re-reading from the held offset delivers it.
|
||||
with jsonl_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write("\n")
|
||||
messages, offset = forwarder._read_new_kiro_messages(jsonl_path, offset)
|
||||
|
||||
assert messages == [
|
||||
forwarder._KiroConversationMessage(
|
||||
message_id="assistant-1", role="assistant", text="second"
|
||||
)
|
||||
]
|
||||
assert offset == jsonl_path.stat().st_size
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_kiro_session_posts_conversation_messages(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""One forwarder poll posts Kiro assistant messages as external items."""
|
||||
sessions_dir = tmp_path / "home" / ".kiro" / "sessions" / "cli"
|
||||
workspace = tmp_path / "repo"
|
||||
workspace.mkdir()
|
||||
_write_kiro_session(
|
||||
sessions_dir,
|
||||
session_id="kiro-session",
|
||||
cwd=workspace,
|
||||
lines=[
|
||||
{
|
||||
"version": "v1",
|
||||
"kind": "Prompt",
|
||||
"data": {"message_id": "user-1", "content": [{"kind": "text", "data": "hey"}]},
|
||||
},
|
||||
{
|
||||
"version": "v1",
|
||||
"kind": "AssistantMessage",
|
||||
"data": {
|
||||
"message_id": "assistant-1",
|
||||
"content": [{"kind": "text", "data": "Hey!"}],
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir)
|
||||
posted: list[tuple[str, str, forwarder._KiroConversationMessage]] = []
|
||||
statuses: list[tuple[str, str, str | None]] = []
|
||||
external_ids: list[tuple[str, str]] = []
|
||||
|
||||
async def _fake_post(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
agent_name: str,
|
||||
message: forwarder._KiroConversationMessage,
|
||||
) -> None:
|
||||
del client
|
||||
posted.append((session_id, agent_name, message))
|
||||
|
||||
async def _fake_status(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
status: str,
|
||||
response_id: str | None = None,
|
||||
) -> None:
|
||||
del client
|
||||
statuses.append((session_id, status, response_id))
|
||||
|
||||
async def _fake_patch_external_session_id(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
external_session_id: str,
|
||||
) -> None:
|
||||
del client
|
||||
external_ids.append((session_id, external_session_id))
|
||||
|
||||
async def _cancel_sleep(_seconds: float) -> None:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr(forwarder, "_post_conversation_message", _fake_post)
|
||||
monkeypatch.setattr(forwarder, "_post_session_status", _fake_status)
|
||||
monkeypatch.setattr(
|
||||
forwarder,
|
||||
"_patch_external_session_id",
|
||||
_fake_patch_external_session_id,
|
||||
)
|
||||
monkeypatch.setattr(forwarder.asyncio, "sleep", _cancel_sleep)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await forwarder.forward_kiro_session_to_omnigent(
|
||||
base_url="http://127.0.0.1:6767",
|
||||
headers={},
|
||||
session_id="conv_kiro",
|
||||
bridge_dir=tmp_path / "bridge",
|
||||
agent_name="kiro-native-ui",
|
||||
workspace=str(workspace),
|
||||
launch_epoch_ms=forwarder._parse_iso_epoch_ms("2026-06-21T01:39:34Z"),
|
||||
)
|
||||
|
||||
assert posted == [
|
||||
(
|
||||
"conv_kiro",
|
||||
"kiro-native-ui",
|
||||
forwarder._KiroConversationMessage(message_id="user-1", role="user", text="hey"),
|
||||
),
|
||||
(
|
||||
"conv_kiro",
|
||||
"kiro-native-ui",
|
||||
forwarder._KiroConversationMessage(
|
||||
message_id="assistant-1", role="assistant", text="Hey!"
|
||||
),
|
||||
),
|
||||
]
|
||||
assert statuses == [
|
||||
("conv_kiro", "running", None),
|
||||
("conv_kiro", "idle", "kiro:assistant-1"),
|
||||
]
|
||||
assert external_ids == [("conv_kiro", "kiro-session")]
|
||||
state = json.loads((tmp_path / "bridge" / "kiro_session_forwarder.json").read_text())
|
||||
assert state["session_id"] == "kiro-session"
|
||||
assert state["byte_offset"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_kiro_session_prefers_expected_resume_session(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A resumed Kiro session id is authoritative over discovery and stale state."""
|
||||
sessions_dir = tmp_path / "home" / ".kiro" / "sessions" / "cli"
|
||||
workspace = tmp_path / "repo"
|
||||
workspace.mkdir()
|
||||
_write_kiro_session(
|
||||
sessions_dir,
|
||||
session_id="resumed-session",
|
||||
cwd=workspace,
|
||||
created_at="2026-06-21T01:00:00Z",
|
||||
updated_at="2026-06-21T01:05:00Z",
|
||||
lines=[
|
||||
{
|
||||
"version": "v1",
|
||||
"kind": "Prompt",
|
||||
"data": {
|
||||
"message_id": "resumed-user",
|
||||
"content": [{"kind": "text", "data": ":0"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"version": "v1",
|
||||
"kind": "AssistantMessage",
|
||||
"data": {
|
||||
"message_id": "resumed-assistant",
|
||||
"content": [{"kind": "text", "data": "resumed reply"}],
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
_write_kiro_session(
|
||||
sessions_dir,
|
||||
session_id="newer-discovery-session",
|
||||
cwd=workspace,
|
||||
created_at="2026-06-21T02:00:00Z",
|
||||
updated_at="2026-06-21T02:01:00Z",
|
||||
lines=[
|
||||
{
|
||||
"version": "v1",
|
||||
"kind": "AssistantMessage",
|
||||
"data": {
|
||||
"message_id": "wrong-assistant",
|
||||
"content": [{"kind": "text", "data": "wrong reply"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
forwarder._write_state(
|
||||
bridge_dir,
|
||||
forwarder._ForwardState(session_id="stale-session", byte_offset=0),
|
||||
)
|
||||
monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir)
|
||||
posted: list[forwarder._KiroConversationMessage] = []
|
||||
statuses: list[tuple[str, str | None]] = []
|
||||
external_ids: list[str] = []
|
||||
|
||||
async def _fake_post(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
agent_name: str,
|
||||
message: forwarder._KiroConversationMessage,
|
||||
) -> None:
|
||||
del client, session_id, agent_name
|
||||
posted.append(message)
|
||||
|
||||
async def _fake_status(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
status: str,
|
||||
response_id: str | None = None,
|
||||
) -> None:
|
||||
del client, session_id
|
||||
statuses.append((status, response_id))
|
||||
|
||||
async def _fake_patch_external_session_id(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
external_session_id: str,
|
||||
) -> None:
|
||||
del client, session_id
|
||||
external_ids.append(external_session_id)
|
||||
|
||||
async def _cancel_sleep(_seconds: float) -> None:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr(forwarder, "_post_conversation_message", _fake_post)
|
||||
monkeypatch.setattr(forwarder, "_post_session_status", _fake_status)
|
||||
monkeypatch.setattr(
|
||||
forwarder,
|
||||
"_patch_external_session_id",
|
||||
_fake_patch_external_session_id,
|
||||
)
|
||||
monkeypatch.setattr(forwarder.asyncio, "sleep", _cancel_sleep)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await forwarder.forward_kiro_session_to_omnigent(
|
||||
base_url="http://127.0.0.1:6767",
|
||||
headers={},
|
||||
session_id="conv_kiro",
|
||||
bridge_dir=bridge_dir,
|
||||
agent_name="kiro-native-ui",
|
||||
workspace=str(workspace),
|
||||
launch_epoch_ms=forwarder._parse_iso_epoch_ms("2026-06-21T02:00:00Z"),
|
||||
expected_session_id="resumed-session",
|
||||
)
|
||||
|
||||
assert posted == [
|
||||
forwarder._KiroConversationMessage(message_id="resumed-user", role="user", text=":0"),
|
||||
forwarder._KiroConversationMessage(
|
||||
message_id="resumed-assistant", role="assistant", text="resumed reply"
|
||||
),
|
||||
]
|
||||
assert statuses == [("running", None), ("idle", "kiro:resumed-assistant")]
|
||||
assert external_ids == ["resumed-session"]
|
||||
state = json.loads((bridge_dir / "kiro_session_forwarder.json").read_text())
|
||||
assert state["session_id"] == "resumed-session"
|
||||
assert state["byte_offset"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_kiro_session_waits_for_expected_resume_session(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When resuming, do not fall back to a different Kiro session id."""
|
||||
sessions_dir = tmp_path / "home" / ".kiro" / "sessions" / "cli"
|
||||
workspace = tmp_path / "repo"
|
||||
workspace.mkdir()
|
||||
_write_kiro_session(
|
||||
sessions_dir,
|
||||
session_id="wrong-session",
|
||||
cwd=workspace,
|
||||
created_at="2026-06-21T02:00:00Z",
|
||||
updated_at="2026-06-21T02:01:00Z",
|
||||
lines=[
|
||||
{
|
||||
"version": "v1",
|
||||
"kind": "AssistantMessage",
|
||||
"data": {
|
||||
"message_id": "wrong-assistant",
|
||||
"content": [{"kind": "text", "data": "wrong reply"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir)
|
||||
posted: list[forwarder._KiroConversationMessage] = []
|
||||
external_ids: list[str] = []
|
||||
|
||||
async def _fake_post(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
agent_name: str,
|
||||
message: forwarder._KiroConversationMessage,
|
||||
) -> None:
|
||||
del client, session_id, agent_name
|
||||
posted.append(message)
|
||||
|
||||
async def _fake_patch_external_session_id(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
external_session_id: str,
|
||||
) -> None:
|
||||
del client, session_id
|
||||
external_ids.append(external_session_id)
|
||||
|
||||
async def _cancel_sleep(_seconds: float) -> None:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr(forwarder, "_post_conversation_message", _fake_post)
|
||||
monkeypatch.setattr(
|
||||
forwarder,
|
||||
"_patch_external_session_id",
|
||||
_fake_patch_external_session_id,
|
||||
)
|
||||
monkeypatch.setattr(forwarder.asyncio, "sleep", _cancel_sleep)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await forwarder.forward_kiro_session_to_omnigent(
|
||||
base_url="http://127.0.0.1:6767",
|
||||
headers={},
|
||||
session_id="conv_kiro",
|
||||
bridge_dir=tmp_path / "bridge",
|
||||
agent_name="kiro-native-ui",
|
||||
workspace=str(workspace),
|
||||
launch_epoch_ms=forwarder._parse_iso_epoch_ms("2026-06-21T02:00:00Z"),
|
||||
expected_session_id="missing-resume-session",
|
||||
)
|
||||
|
||||
assert posted == []
|
||||
assert external_ids == []
|
||||
@@ -92,6 +92,8 @@ def test_validate_model_override_rejects_unsafe_values(value: str) -> None:
|
||||
"openai-agents",
|
||||
"cursor",
|
||||
"antigravity",
|
||||
"kiro-native",
|
||||
"native-kiro",
|
||||
],
|
||||
)
|
||||
def test_harness_supports_model_override_for_plumbed_harnesses(harness: str) -> None:
|
||||
@@ -160,6 +162,8 @@ class TestModelFamilyMismatch:
|
||||
("agy", "gemini-3.5-flash"),
|
||||
("google-antigravity", "gemini-2.5-flash"),
|
||||
("antigravity", "gemini-2.5-pro"),
|
||||
("kiro-native", "claude-sonnet-4.5"),
|
||||
("native-kiro", "gpt-5.4-mini"),
|
||||
],
|
||||
)
|
||||
def test_compatible_pairs_pass(self, harness: str, model: str) -> None:
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from omnigent._wrapper_labels import (
|
||||
KIRO_NATIVE_WRAPPER_VALUE,
|
||||
PI_NATIVE_WRAPPER_VALUE,
|
||||
UI_MODE_LABEL_KEY,
|
||||
UI_MODE_TERMINAL_VALUE,
|
||||
WRAPPER_LABEL_KEY,
|
||||
)
|
||||
from omnigent.native_coding_agents import (
|
||||
KIRO_NATIVE_CODING_AGENT,
|
||||
PI_NATIVE_CODING_AGENT,
|
||||
native_coding_agent_for_harness,
|
||||
native_coding_agent_for_wrapper_label,
|
||||
)
|
||||
|
||||
|
||||
@@ -33,10 +36,26 @@ def test_native_pi_alias_resolves_like_canonical() -> None:
|
||||
|
||||
def test_canonical_native_harnesses_resolve() -> None:
|
||||
"""The canonical native spellings all resolve to their agents."""
|
||||
for harness in ("claude-native", "codex-native", "pi-native"):
|
||||
for harness in ("claude-native", "codex-native", "pi-native", "kiro-native"):
|
||||
assert native_coding_agent_for_harness(harness) is not None
|
||||
|
||||
|
||||
def test_kiro_native_agent_metadata_and_aliases() -> None:
|
||||
"""Kiro has a canonical native identity and reversed alias."""
|
||||
assert KIRO_NATIVE_CODING_AGENT.key == "kiro"
|
||||
assert KIRO_NATIVE_CODING_AGENT.display_name == "Kiro"
|
||||
assert KIRO_NATIVE_CODING_AGENT.agent_name == "kiro-native-ui"
|
||||
assert KIRO_NATIVE_CODING_AGENT.harness == "kiro-native"
|
||||
assert KIRO_NATIVE_CODING_AGENT.wrapper_label == KIRO_NATIVE_WRAPPER_VALUE
|
||||
assert KIRO_NATIVE_CODING_AGENT.terminal_name == "kiro"
|
||||
assert native_coding_agent_for_harness("native-kiro") is KIRO_NATIVE_CODING_AGENT
|
||||
assert native_coding_agent_for_harness("kiro-native") is KIRO_NATIVE_CODING_AGENT
|
||||
assert (
|
||||
native_coding_agent_for_wrapper_label(KIRO_NATIVE_WRAPPER_VALUE)
|
||||
is KIRO_NATIVE_CODING_AGENT
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_harness_returns_none() -> None:
|
||||
"""A non-native harness stays unresolved (no terminal presentation)."""
|
||||
assert native_coding_agent_for_harness("claude-sdk") is None
|
||||
|
||||
@@ -198,6 +198,32 @@ def test_dispatch_by_runtime_codex_native_local_routes_to_wrapper(
|
||||
assert captured["codex_args"] == ()
|
||||
|
||||
|
||||
def test_dispatch_by_runtime_kiro_native_remote_routes_to_wrapper(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Remote kiro-native conv routes to ``run_kiro_native``."""
|
||||
monkeypatch.setattr(
|
||||
resume_dispatch,
|
||||
"_read_wrapper_label_remote",
|
||||
lambda *, server, conv_id: "kiro-native-ui",
|
||||
)
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _capture(**kwargs: Any) -> None:
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setattr("omnigent.kiro_native.run_kiro_native", _capture)
|
||||
|
||||
resume_dispatch._dispatch_by_runtime(
|
||||
target="conv_kiro",
|
||||
server="https://example.com/",
|
||||
)
|
||||
|
||||
assert captured["session_id"] == "conv_kiro"
|
||||
assert captured["server"] == "https://example.com"
|
||||
assert captured["kiro_args"] == ()
|
||||
|
||||
|
||||
def test_dispatch_by_runtime_antigravity_native_remote_routes_to_wrapper(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -65,6 +65,23 @@ def test_codex_native_session_uses_codex_harness_for_web_messages() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_kiro_native_session_uses_kiro_harness_for_web_messages() -> None:
|
||||
"""Kiro-native web messages use the native bypass, like Codex."""
|
||||
from omnigent.server.routes import sessions as sessions_routes
|
||||
|
||||
conv = _conversation_with_wrapper("kiro-native-ui")
|
||||
|
||||
assert sessions_routes._is_native_terminal_session(conv) is True
|
||||
assert sessions_routes._build_native_terminal_message_event(conv, _message_event()) == {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hello"}],
|
||||
"model": "kiro-native-ui",
|
||||
"harness": "kiro-native",
|
||||
"agent_id": "ag_native_test",
|
||||
}
|
||||
|
||||
|
||||
def test_antigravity_native_session_uses_antigravity_harness_for_web_messages() -> None:
|
||||
"""
|
||||
Antigravity-native sessions use the native bypass and dispatch web
|
||||
@@ -108,6 +125,21 @@ def test_antigravity_native_runtime_maps_wrapper_to_agy_terminal() -> None:
|
||||
assert sessions_routes._native_terminal_name_for_harness(harness) == "antigravity"
|
||||
|
||||
|
||||
def test_transcript_forwarded_native_sessions_use_native_bypass() -> None:
|
||||
"""Transcript-forwarded native sessions skip AP-side message persistence."""
|
||||
from omnigent.server.routes import sessions as sessions_routes
|
||||
|
||||
assert sessions_routes._is_native_terminal_session(
|
||||
_conversation_with_wrapper("claude-code-native-ui")
|
||||
)
|
||||
assert sessions_routes._is_native_terminal_session(
|
||||
_conversation_with_wrapper("codex-native-ui")
|
||||
)
|
||||
assert sessions_routes._is_native_terminal_session(
|
||||
_conversation_with_wrapper("kiro-native-ui")
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_wrapper_session_does_not_use_native_bypass() -> None:
|
||||
"""
|
||||
Non-native wrapper labels must not enter the native terminal
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
from omnigent._wrapper_labels import (
|
||||
CLAUDE_NATIVE_WRAPPER_VALUE,
|
||||
CODEX_NATIVE_WRAPPER_VALUE,
|
||||
KIRO_NATIVE_WRAPPER_VALUE,
|
||||
PI_NATIVE_WRAPPER_VALUE,
|
||||
WRAPPER_LABEL_KEY,
|
||||
)
|
||||
@@ -140,3 +141,13 @@ def test_pi_native_wrapper_constants_match_registry() -> None:
|
||||
assert PI_NATIVE_CODING_AGENT.harness == "pi-native"
|
||||
assert PI_NATIVE_CODING_AGENT.wrapper_label == PI_NATIVE_WRAPPER_VALUE
|
||||
assert PI_NATIVE_CODING_AGENT.terminal_name == "pi"
|
||||
|
||||
|
||||
def test_kiro_native_wrapper_constants_match_registry() -> None:
|
||||
"""The native coding-agent registry owns the Kiro wrapper metadata."""
|
||||
from omnigent.native_coding_agents import KIRO_NATIVE_CODING_AGENT
|
||||
|
||||
assert KIRO_NATIVE_CODING_AGENT.agent_name == "kiro-native-ui"
|
||||
assert KIRO_NATIVE_CODING_AGENT.harness == "kiro-native"
|
||||
assert KIRO_NATIVE_CODING_AGENT.wrapper_label == KIRO_NATIVE_WRAPPER_VALUE
|
||||
assert KIRO_NATIVE_CODING_AGENT.terminal_name == "kiro"
|
||||
|
||||
Reference in New Issue
Block a user