Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -593,9 +593,10 @@ port with a generated bearer) or external (`MECATL_BASE_URL`; every local contro
surface answers 409 as deployment-owned). Studio is daemon-only — an unreachable
daemon renders as an offline state, never demo content — and decodes the wire in
one typed seam (`studio/src/lib/protocol/`) that surfaces unknown event kinds
instead of dropping them. Live re-attach to a running session is a stated non-goal
today: the live tail is gRPC-only (`StreamSessionLive`), so Studio shows running
state from the session inventory and reads the transcript when the run ends. A
instead of dropping them. Live re-attach to a running session rides the
durable session watch (`GET /v1/sessions/{id}/watch`, ADR 0250): any tab can
follow a driving run live, with one residual — the driving tab's own
`POST …/prompt` stream still cancels the run on disconnect. A
breaking wire change owes a Studio update in the same PR. See ADR 0288/0289.

**mecatequi — the single-shot headless runner (`cmd/mecatequi`).** A fourth composition
Expand Down
2 changes: 1 addition & 1 deletion docs/design/PRODUCTION-READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ record; current behaviour is in the linked [architecture](../architecture.md) do
| mecak8s (storage-free k8s-native agent) | ✅ shipped (MVP) · ✅ OPT-IN `/metrics` loopback scrape + OTLP push (ADR 0098) · ✅ verified external Redis TLS/ACL with transactional projected-file reload + last-valid generations (ADR 0240) · ✅ Helm 0.3.0 secure real-provider in-pod TLS+OIDC or edge-terminated TLS+OIDC (ClusterIP h2c), nullable spend ceilings, and pod scheduling controls · ⛔ CRD/Operator · ⛔ HPA (custom-metrics on active-runs) · ⛔ managed Redis provisioning (ElastiCache/MemoryStore — endpoint only) · ⛔ fix `mecated`'s unbounded `GracefulStop` (pre-existing, follow-up) | [mecak8s.md](../adr/0048-mecak8s.md) · [0098](../adr/0098-headless-telemetry.md) · [0240](../adr/0240-mecak8s-credential-reload-and-chart-security.md) · [0278](../adr/0278-mecak8s-edge-terminated-tls.md) · [MECAK8S-PLAN.md](./MECAK8S-PLAN.md) | [overview](../architecture.md) |
| ACP adapter (editor stdio surface) | ✅ Phase 1+2 + bounded Phase 3 + multimodal shipped · ⛔ Phase 3 long-tail (rule persistence, grep-over-buffers, fs/* on resume) | [0001-acp-adapter.md](../adr/0001-acp-adapter.md) | [api surface](../architecture/api-surface.md) |
| Conversation fork (peer session from a history snapshot) | ✅ shipped · ✅ effort override (mid-conversation effort switch, keeps the transcript — [0068](../adr/0068-effort-change-via-fork.md)) · ⛔ cross-provider/model fork (v2: replay-blob stripping) · ⛔ workspace-branching fork · ⛔ fork-from-event-log-at-arbitrary-point · ⛔ fork lineage (`forked_from` label) | [0065-conversation-fork.md](../adr/0065-conversation-fork.md) | [overview](../architecture.md) |
| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ✅ Skills (browse/create/upload/enable-disable, controller-mediated; learned-skills panel) · ✅ Memory (read-only table + detail + consolidate, honest disabled/empty states) · ✅ Settings core (Personalize, agent identity, learning review) · ✅ provider management + model router + MCP gateway (controller-mediated; server-side auth.yaml ownership, no key material on the wire) · ✅ external-mode OIDC sign-in (PKCE, server-held tokens) · ⛔ advanced chat tiers (attachments, tool drill-down, steer/queue, threads, re-attach, modes, mobile) · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) |
| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ✅ Skills (browse/create/upload/enable-disable, controller-mediated; learned-skills panel) · ✅ Memory (read-only table + detail + consolidate, honest disabled/empty states) · ✅ Settings core (Personalize, agent identity, learning review) · ✅ provider management + model router + MCP gateway (controller-mediated; server-side auth.yaml ownership, no key material on the wire) · ✅ external-mode OIDC sign-in (PKCE, server-held tokens) · ✅ advanced chat: attachments/previews, tool drill-down + context meter + typed retry + live delegation cards, queue + steer, threads + canvas, live re-attach via the session watch (residual: the driving tab's prompt stream still cancels on disconnect) · ⛔ session modes/model-switch/debug · ⛔ Labs · ⛔ mobile round · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) |
| _Historical / retired_ | — | [ARCHITECTURE.md](../adr/0004-v1-architecture.md) · [STEP-CHAIN.md](../adr/0006-v1-step-chain.md) · [TWELVE-PATTERNS-AUDIT.md](../adr/0007-twelve-patterns-audit.md) · [REPOMAP-TREE-SITTER.md](../adr/0029-repomap-tree-sitter.md) | — |

## Security
Expand Down
5 changes: 5 additions & 0 deletions studio/src/app/workspace/chat/_components/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,11 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
cancelChat,
} = useAgentChat(hookSessionId, {
onSessionCreated: handleSessionCreated,
// The inventory poll's lifecycle state: running/awaiting attaches the
// durable watch so an externally-driven run renders live (ADR 0250).
sessionState: hookSessionId
? sessions.find((s) => s.id === hookSessionId)?.state
: undefined,
});

/** Esc with nothing else open interrupts the in-flight run (close.esc). */
Expand Down
173 changes: 172 additions & 1 deletion studio/src/features/agent/hooks/use-agent-chat.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
import type { AgentMessage } from "../types";
import type { AgentMessage, StreamEvent } from "../types";
import {
applyDelegationUpdate,
attachmentsFromSteerParts,
reduceWatchEvent,
splitPendingSteersOnWatermark,
} from "./use-agent-chat";

Expand Down Expand Up @@ -36,6 +37,148 @@ describe("splitPendingSteersOnWatermark", () => {
});
});

// ── reduceWatchEvent ─────────────────────────────────────────────────────────

/**
* Pins the watch transcript reducer (ADR 0250): replayed durable-log events
* rebuild the same message shape the live prompt path produces.
*/
describe("reduceWatchEvent", () => {
let serial = 0;
const nextId = () => `id-${++serial}`;
const run = (events: StreamEvent[]): AgentMessage[] =>
events.reduce<AgentMessage[]>(
(messages, event) => reduceWatchEvent(messages, event, nextId),
[],
);

it("rebuilds a user → assistant exchange with tool activity", () => {
const messages = run([
{ type: "user_prompt", text: "list the files" },
{ type: "token", text: "Sure — " },
{
type: "tool_call",
callId: "c1",
name: "Bash",
input: "command: ls",
},
{ type: "tool_result", callId: "c1", output: "a.txt", isError: false },
{ type: "token", text: "done." },
]);
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
role: "user",
content: "list the files",
});
expect(messages[1]).toMatchObject({
role: "assistant",
content: "Sure — done.",
toolCalls: [
{
callId: "c1",
name: "Bash",
output: "a.txt",
status: "completed",
},
],
});
});

it("opens a fresh assistant bubble after each user-authored record", () => {
const messages = run([
{ type: "user_prompt", text: "first" },
{ type: "token", text: "answer one" },
{ type: "user_prompt", text: "second" },
{ type: "token", text: "answer two" },
]);
expect(messages.map((m) => [m.role, m.content])).toEqual([
["user", "first"],
["assistant", "answer one"],
["user", "second"],
["assistant", "answer two"],
]);
});

it("renders a steer echo as a user message, like the committed record it is", () => {
const messages = run([
{ type: "token", text: "working" },
{ type: "steer", text: "focus on tests", messageId: "s-1" },
{ type: "token", text: "ok" },
]);
expect(messages.map((m) => [m.role, m.content])).toEqual([
["assistant", "working"],
["user", "focus on tests"],
["assistant", "ok"],
]);
});

it("renders an approval verdict as a quiet notice line", () => {
const messages = run([
{
type: "approval_verdict",
approvalId: "a1",
toolName: "Bash",
verdict: "allow_once",
},
]);
expect(messages[0].notices).toEqual(["Permission: Bash allowed once"]);
});

it("marks a failed terminal on the trailing assistant, failing its running calls", () => {
const messages = run([
{ type: "token", text: "trying" },
{ type: "tool_call", callId: "c9", name: "Edit", input: "" },
{
type: "run_result",
stop: "error",
text: "",
errorText: "boom",
permanent: true,
},
]);
expect(messages[0]).toMatchObject({
failed: true,
failureDetail:
"boom (permanent — retrying the identical request cannot succeed)",
toolCalls: [{ callId: "c9", status: "failed" }],
});
});

it("fills an empty assistant bubble from a clean terminal's final text", () => {
const messages = run([
{ type: "tool_call", callId: "c2", name: "Read", input: "" },
{
type: "run_result",
stop: "end_turn",
text: "final",
errorText: "",
permanent: false,
},
]);
expect(messages[0].content).toBe("final");
});

it("leaves the transcript untouched for hook-state kinds (asks, usage)", () => {
const before: AgentMessage[] = [
{ id: "m1", role: "assistant", content: "hi", timestamp: 0 },
];
expect(
reduceWatchEvent(
before,
{
type: "approval",
approvalId: "a1",
sessionId: "s",
toolName: "Bash",
description: "",
details: "",
},
nextId,
),
).toBe(before);
});
});

// ── delegation cards (D1) ────────────────────────────────────────────────────

describe("applyDelegationUpdate", () => {
Expand Down Expand Up @@ -136,3 +279,31 @@ describe("attachmentsFromSteerParts", () => {
expect(attachmentsFromSteerParts([])).toBeUndefined();
});
});

describe("reduceWatchEvent steer parts", () => {
it("keeps the committed steer's media on the rebuilt user bubble", () => {
let serial = 100;
const messages = reduceWatchEvent(
[],
{
type: "steer",
text: "look at this",
messageId: "m-1",
parts: [{ kind: "image", mimeType: "image/png", data: "aGk=" }],
},
() => `id-${++serial}`,
);
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
role: "user",
content: "look at this",
attachments: [
{
name: "image-1.png",
type: "image/png",
url: "data:image/png;base64,aGk=",
},
],
});
});
});
Loading
Loading