Skip to content

Layer Keiki onto the existing comet shell, with Copilot as the only harness - #5

Open
devin-ai-integration[bot] wants to merge 31 commits into
mainfrom
devin/1787970048-keiki-on-comet
Open

Layer Keiki onto the existing comet shell, with Copilot as the only harness#5
devin-ai-integration[bot] wants to merge 31 commits into
mainfrom
devin/1787970048-keiki-on-comet

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 29, 2026

Copy link
Copy Markdown

Summary

Layers Keiki onto comet's existing shell instead of replacing it: Keiki agents become Space rows (keiki-agent:<id>), conversations become Chat rows (keiki-conv:<phone>), and messages map onto SessionMessageEntry, so the existing sidebar disclosure groups, transcript, composer, pickers and dialogs render Keiki with no new surface. Everything talks to the live onkeiki.com webapp API; nothing is stubbed and no action reports success it did not get from the server.

Auth is OAuth 2.1 + PKCE with dynamic client registration, refresh and revocation, persisted under keiki://oauth. GPUI's register_url_scheme is unimplemented on Linux, so sign-in uses the RFC 8252 loopback redirect (http://127.0.0.1:<port>/oauth/callback, accepted by onkeiki.com's dynamic registration) and keeps keiki://oauth/callback for packaged builds; scheme-registration failure is non-fatal.

Keiki and the engine both own rows in the same store, so the reducers are Keiki-scoped: apply_keiki_snapshot replaces only Keiki rows and engine apply_chats preserves them — without that, each poll of either side wiped the other's chats and spaces.

Conversation controls (take over / hand back / block / unblock / send / steer) live on the conversation menu, scoped to the selected chat so a menu opened elsewhere can't act on stale state, with stale async results dropped after navigation. Send is gated on a live takeover derived from the server's expiresAt:

if crate::keiki::is_keiki_chat(selected_chat) {
    return state.keiki_conversation()
        .is_none_or(|conversation| !crate::keiki::takeover_live(conversation));
}

A 409 from /messages clears the local takeover and surfaces the server's text rather than retrying. Successful send and steer refetch the conversation instead of synthesizing a bubble.

Two behaviors worth knowing, both learned from the server and not the docs:

  • /steer is not a dry run. runSteerTurn calls executeTurn({ internal: true }) and markTurnInternal, so the steering text and the reply are persisted on the real conversation (delivery is skipped; internal mode refuses every messaging tool). The UI says "Real agent turn — recorded on this conversation / Nothing was sent to the contact", and internal rows are labeled Internal turn — <staff> so they don't read as the contact talking.
  • GET /conversations/:phone pages created_at ASC LIMIT/OFFSET with a default of 200, i.e. the oldest page. Long threads therefore never showed their recent messages, regardless of refetching. The client now asks for the newest window:
let Some(initial_offset) = newest_page_offset(detail.meta.message_count) else {
    return Ok(detail); // first page already is the whole conversation
};
// walk forward from message_count - 500 while pages come back full, keep the newest 500

Agent creation goes through the server's template path (GET /agent-templatesPOST /agents), surfaces server validation verbatim, treats missing_secrets as a notice rather than a failure, and refreshes and selects the new agent.

Conversation rows carry two device-local actions instead of agent management, which was removed: Pin/Unpin promotes a conversation to the top of its agent group, and View conversation opens the thread on the dashboard. Pinning stores keiki-conv: ids in ui-settings.json and partitions each group while preserving relative order, so it survives a poll replacing every row; the ids self-heal, since a pin whose conversation is gone from a populated snapshot is dropped, while an empty one (signed out, before the first poll) is left alone rather than wiping every pin. The dashboard URL is built from the client's configured base — so KEIKI_API_URL is honoured — with the phone percent-encoded as a path segment and agentId added only when the conversation has one; no token or API key ever reaches the URL.

The Keiki-tools-over-MCP wiring that an earlier commit on this branch added is gone again, deleted rather than left dormant: the Copilot is taking that job, so McpServerSpec/RunControls.mcp_servers, the engine-owned holder and its SetMcpServers RPC, claude's generated --mcp-config file, codex's mcp_servers.* overrides and ACP's mcpCapabilities.http probing all come out, along with the mcp scope upgrade and its "Connect Keiki tools…" entry. Sign-in asks for manage only; discover_oauth stays scope-parameterised because the Copilot rides the same token.

Visible copy is Keiki: no Zeron branding, "projects" → "agents", "pull requests" → "conversations", and Keiki Light/Dark (the onkeiki.com blue palette, no purple) is the genuine first-run default. Internal zeron crate, binary, daemon and iOS identifiers are deliberately unchanged — renaming them churns packaging and the update/daemon paths.

Also fixed along the way: Keiki requests ran on GPUI's executor with no Tokio reactor and panicked on first sign-in (now gpui_tokio::Tokio::spawn); OAuth task/join failures were misreported as contract violations; production timestamps like 2026-08-28 16:58:29.714004+00 failed RFC3339 parsing and dropped rows; sign-out left Keiki rows behind; and the signed-out gate set a duplicate hover style, panicking every launch.

Verified against a real account in Adam's Organization: sign-in, agent folders, transcripts, send gating, takeover/hand-back, block/unblock, steer, and template creation. Linux sign-in needs a Secret Service — run under dbus-run-session with gnome-keyring unlocked (captured in the blueprint).

Copilot replaces the coding harnesses

Keiki's dashboard Copilot is now a real harness (HarnessId::Copilot) rather than a bespoke panel, and the seven coding harnesses it replaces are deleted rather than hidden: claude-code, codex, cursor, grok, hermes, pi, opencode, the whole ACP layer, adapter installation, the provider-account plumbing that existed only to log those tools in, and the Harnesses settings page. ~28k lines out. Copilot's runs stream, get steered, get cancelled and raise approval cards exactly like a harness turn, so the engine's run/session/transcript/approval machinery carries it unchanged and nothing is left orphaned behind a deleted backend.

crates/copilot is a small client for POST /api/copilot/chat's AG-UI SSE stream: an incremental SseDecoder (partial chunks, multi-data: frames, comments, final-frame flush) plus a TurnMapper folding AG-UI into AgentEvents — text/reasoning deltas, tool-call chunks accumulated into one ToolCall, TOOL_CALL_RESULTToolResult, and RUN_FINISHED deciding between continuing, completing and pausing:

match (outcome, finish_reason) {
    (Some(Interrupt { interrupts }), _) => pause and surface the cards, // no Done
    (_, Some("tool_calls")) => the run continues, emit nothing terminal,
    _ => Done { Completed },
}

That middle arm is not a nicety: a model turn that asked for tools ends with its own RUN_FINISHED, so a delegating turn streams two of them and the answer arrives after the first. Treating the first as terminal ended the turn before the assistant's text and before the interrupt that followed it — the user saw an empty turn and never got the approval card. The finish reason lives at metadata.tanstack.finishReason with a top-level fallback, which is exactly what the server's own isTerminalFinish reads; an absent reason stays terminal, since the durable log's synthetic replay finish carries none.

Three protocol facts the server's persistence layer dictates, none of them documented:

  • The server owns the transcript. withPersistence's onConfig does patch.messages = config.messages.length > 0 ? config.messages : stored — incoming messages replace the stored thread wholesale. The dashboard can satisfy that because it holds the whole conversation in React state; a desktop client cannot, and the only ModelMessage-shaped transcript lives server-side (GET /threads/:id reads the dashboard's thread store, which this client never writes, and GET /chat?threadId= hands back UI messages). So sms-kit#782 grew an appendToTranscript flag: a turn posts one user message and the server prepends the thread's own ownership-checked transcript. Without it every turn silently truncated the thread to its newest exchange, and the copilot answered each question as if it were the first.
  • A resume posts no new message. Resuming sends messages: [] with parentRunId and resume — inventing a "continue" turn pollutes what the user sees on the dashboard and replaces the paused run's saved state. The server accepts an empty list only for a request it parses as a real resume.
  • An approval needs a payload. The dashboard answers both buttons through resolveInterrupt({ approved }), and the copilot's approved-program path checks approval?.approved === true, so a bare {status: "resolved"} approves nothing and the run quietly does nothing. Approve/decline send resolved + {"approved": true|false}; cancelled is reserved for abandoning a card.
  • A pause is not always on the wire. The interrupt outcome is an optimisation, not the only signal: the thread's pending interrupts are authoritative and readable at GET /chat?threadId= as interrupts.{runId,pending}. The harness hydrates that at the start of every turn and again after a stream that looked complete, raises the cards through the existing input-prompt UI, and resumes against interrupts.runId — which is also what unwedges a thread whose pause was missed, since the server refuses new input while an interrupt is pending. Cancellation posts the run id the client minted, not an id read off the stream (the provider's message ids are not durable run ids and 404 on /runs/:id/cancel).

Session identity is the thread id, not the run id: the engine feeds Done.session_id back as RunRequest.resume, which the harness reads as the thread to continue — carrying the run id there would start a fresh empty thread every turn and undo the transcript work above.

The sidebar gets a permanent Copilot row above the agent groups (muted with "Sign in to Keiki to use Copilot" while signed out). It selects a device-local, project-less chat minted through the normal createChat mutation with HarnessId::Copilot, its id persisted as copilot_chat_id in ui-settings.json, so the transcript, composer, stop and approval prompts are the ones already in the app. The bearer reaches the engine over SET_COPILOT_CREDENTIALS on sign-in, keyring restore and every refresh — an access token lives about an hour, so a stale header would break long runs — and SIGN_OUT clears it.

Deleting the HarnessId variants can't break devices that already hold claude-code/codex chats, so unknown ids deserialize to HarnessId::Unknown, with the seven retired ids mapped to &'static str literals so those rows round-trip byte-identically when a chat is archived or renamed (interning arbitrary strings would leak on every sync). Legacy chats stay visible, archivable and deletable.

Server side: Mail-0/sms-kit#782 (merged) lets an OAuth bearer with manage reach /api/copilot through the existing requireSession guard — one auth path, a short-lived derived session for bearer-started durable runs, credential-free requests advertising /.well-known/oauth-protected-resource/api/copilot, and the appendToTranscript flag above. It is live on onkeiki.com, so this branch needs nothing further server-side.

Driven end-to-end headed against a local sms-kit carrying #782, with every UI claim checked in Postgres: multi-turn context (the stored transcript grows instead of being overwritten), approval cards raised in the turn that provokes them, approve executing the action and decline leaving it alone, cancellation landing as status = aborted on the durable run, and relaunch resuming the same thread. Details and frame-level evidence are in a comment on this PR.

One thing that pass found, fixed here: the composer was gated on spaces existing, and Keiki agents are the spaces — so a project-less Copilot chat in an org with no agents rendered no input at all, which the Copilot could bring about itself by deleting the last agent. The composer now follows the selection; the no-selection onboarding card is unchanged.

Release Notes:

  • Added Keiki agents and conversations to the sidebar, with sign-in, conversation controls (take over, hand back, block, send, steer), agent creation from templates, and per-conversation pinning
  • Added "View conversation", which opens the selected Keiki conversation on the dashboard in your browser

Link to Devin session: https://app.devin.ai/sessions/45ed77efb9cd4fe39d69b35ee5c9076f
Open in Devin Desktop: https://app.devin.ai/desktop/session/45ed77efb9cd4fe39d69b35ee5c9076f?variant=devin
Requested by: @MrgSub

@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration

Copy link
Copy Markdown
Author

Runtime test report — headed run on Linux/X11 (llvmpipe)

Ran target/debug/zeron headed with a fresh ~/.zeron/ui-settings.json. No Keiki account, so the signed-in data path was not exercised. Two blocking defects, one cosmetic — fixes are in progress on this branch.

1. "Sign in to Keiki" crashes the app (reproduced 2/2)

Clicking the new gate button kills the process instantly:

thread 'main' panicked at hyper-util-0.1.20/src/client/legacy/connect/dns.rs:119:
there is no reactor running, must be called from the context of a Tokio 1.x runtime
  5: <reqwest::dns::gai::GaiResolver as reqwest::dns::resolve::Resolve>::resolve
 …  keiki_api client request polled on gpui/calloop executor

keiki-api's reqwest futures are polled on the GPUI executor, which has no Tokio reactor, so begin_sign_in's intended failure path (set_sidebar_notice) never runs — it panics first. Every keiki-api call needs gpui_tokio::Tokio::spawn, as the engine RPC paths already do.

2. Keiki is not the first-run default theme

First run selects Zeron Light / Zeron Dark, with the purple Zeron accent in the settings chrome — ThemeSelection::default() (crates/theme/src/lib.rs:400-407) still returns zeron-light/zeron-dark and overrides the Keiki fallbacks in crates/ui/src/theme.rs.

Default Appearance shows Zeron + purple

Picking Keiki manually works correctly — blue accent, no purple:

Keiki Dark applied

3. View-options menu headings off by one

"By agent" is present and is the default organization, but the section splits at crates/ui/src/shell/spaces.rs:684-685 weren't bumped for the new row, so "In one list" renders under SORT and "Created" under SHOW.

View menu section headers misgrouped

What passed
  • Launches and renders the shell under llvmpipe with no corruption
  • Gate card shows both "Log in" and "Sign in to Keiki"
  • Keiki Light/Dark apply correctly (near-white/near-black surfaces, blue accent, no purple)
  • All other families, including "Shades of Purple", still listed and switchable both ways
  • By device → In one list → By agent switching keeps the sidebar rendering, no panic
  • Regressions checked: sidebar collapse/expand, settings navigation, window close — clean, zero panics
Not tested (no credentials)

Signed-in agent/conversation mapping, populated By agent folders, and the read-only composer for Keiki chats.

@devin-ai-integration

Copy link
Copy Markdown
Author

Runtime test report — signed-in Keiki path (headed Linux/X11, real onkeiki.com account)

Signed in end to end against production with a real account (5 agents, 7 conversations). The loopback OAuth rewrite works and the earlier Tokio-reactor panic is gone, but the data path had two blocking bugs. Both are being fixed on this branch.

1. All 7 conversations were silently dropped (timestamp parse)

Sign-in succeeds and the 5 agents appear, but every agent is empty and the sidebar stays on "No sessions yet":

5 Keiki agents, zero conversations

The API returns data — the parse fails. /api/webapp/conversations sends Postgres-style timestamps, not RFC 3339:

Raw API JSON: "lastMessageAt":"2026-08-28 16:58:29.714004+00"

parse_timestamp used DateTime::parse_from_rfc3339, which rejects the space separator and the +00 offset, so map_conversation returned None for every row and they were filtered out with nothing logged — indistinguishable from an empty account. Fix accepts both forms and makes an unparseable timestamp degrade visibly (warn + fallback) instead of dropping the row.

2. "Sign out of Keiki" left the Keiki rows behind

The session cleared, but all 5 Keiki agent projects were still listed 20s later and the keiki-cloud device row remained under Settings → Devices:

Keiki agents still present after sign-out

OAuth loopback flow: all green
  • "Sign in to Keiki" → hosted consent with redirect_uri=http://127.0.0.1:<port>/oauth/callback → callback → signed in, no panic
  • 4 attempts each bound a fresh port with a fresh dynamic registration (HTTP 201); no leaked listeners afterwards
  • 3 rapid clicks opened exactly one consent tab; app stayed responsive
  • Denying authorization shows authorization was rejected: access_denied on the gate rather than hanging
  • Default theme is Keiki Light / Keiki Dark with Keiki-blue accents, no purple in the chrome

Denied authorization shows a readable error

Not covered / environment note
  • Transcript role mapping and the send-blocked composer are unreachable until Rebuild Comet as Keiki desktop shell #1 lands; re-run after the fix.
  • The "engine rows survive a Keiki refresh" check was weak: this box had no local zeron sessions to regress.
  • On a Linux box without a Secret Service, the credential write fails with DBus error … Failed to connect to address 'unix:path=/run/user/1000/bus'. It surfaces readably and doesn't crash, but packaged Linux users will hit the same wall — worth deciding on a fallback.

Written by Devin

@devin-ai-integration

Copy link
Copy Markdown
Author

Runtime re-test — 11eacf2 (headed Linux/X11, real onkeiki.com account)

Both defects from the previous run are fixed and the two assertions they blocked are now covered. Read-only against production throughout — nothing was sent.

1. All 7 conversations render, nested under their agent folders (was 0)

7 conversations nested under 4 agent folders

4 folders (fairfaxer! ×4, Adam's 15-451 bot, gstack, adambot) — the 5th agent has no conversations and still appears in the picker. Row order matches the API's lastMessageAt descending exactly and ages render as 13h / 1d rather than "now", so the parser is producing real timestamps instead of the Utc::now() fallback. Zero Keiki timestamp could not be parsed warnings across all four run logs.

2. Transcript renders with inbound = user / outbound = assistant

Keiki transcript, inbound as user bubbles, outbound as assistant

3. Composer is send-blocked and does not fake a send

Typing test, pressing Enter, then clicking the dimmed send button leaves the text in the composer with no optimistic bubble, and messageCount for that conversation is still 3 in the live API afterwards.

Typed text remains after Enter and click, no new bubble

4. Sign out clears every Keiki row, and signing back in restores them

Sidebar drops to "No sessions yet", the picker keeps only "All projects", and Settings → Devices goes 2 → 1 with the local devin-box row intact. Signing in again (fresh loopback port, fresh dynamic client) brings all 4 folders, 7 conversations and 5 picker entries back.

after sign out (sidebar) after sign out (devices)
sidebar cleared devices: only devin-box
Minor observations (not blocking)
  • Keiki sidebar rows show the conversation id plus an <agent> @ Keiki subtitle; they don't render last_message_preview, so message text is only visible in the transcript.
  • After "Sign out of Keiki" the account menu offers no "Sign in to Keiki" entry, so reconnecting requires getting back to the sign-in gate. Being addressed in the follow-up that makes Keiki the default sign-in.
  • Not re-run (unchanged since the last report): adversarial repeat-click / deny flows and the theme defaults.

Written by Devin

@devin-ai-integration

Copy link
Copy Markdown
Author

Runtime delta re-test — 1ad63ef (headed Linux/X11, real onkeiki.com account)

All three defects from b8db718 are fixed and the small-conversation regression does not reproduce.
No /steer and no /messages this pass — the internal rows below are the ones recorded previously.
Every conversation touched was left handed back and unblocked; no new agent created.

1. Large thread now shows its newest messages (was stuck on the oldest page)

The 272-message fairfaxer! / tg:chat:-5395443460 thread ends on the API's current lastMessage
(pending: adam still has kitchen wipe …), with the previously recorded internal Steer rows visible
just above it — no more Aug-28 stale tail, no restart needed.

Large thread newest tail

2. Small conversations are not emptied

Checked by hand against the API's messageCount: gstack / tg:8934592711 8,
Adam's 15-451 bot / tg:8934592711 6, fairfaxer! / tg:8925660861 6,
adambot / adam · #home · thread 8 — all render their full transcript. The two conversations
sharing tg:8934592711 still render their own history, so the locator keeps agentId.

Small thread renders fully, internal rows plain text

3. Long composer errors are bounded and scrollable

Forced a one-line request error to wrap far past the cap (20px interface font + narrow composer
column). The notice stops growing, clips mid-URL, and scrolling inside it reveals the rest — while
the input, Steer and send stay visible and clickable.

notice capped (top of error) after scrolling inside the notice
capped scrolled

4–5. Plain-text internal rows and relocated entries

  • Inbound reads Internal turn — Devin, outbound Internal turn; no literal ** anywhere.
  • Take over / Block are now on the main context-menu page (between Archive and Copy), and the
    Copy submenu holds only Conversation link. Take over → Taken over · 29m remaining → Hand back
    restores the original state.
  • New Keiki agent… is now in the sidebar agents dropdown and opens the dialog with templates
    loaded (cancelled — no agent created).

New Keiki agent in the sidebar agents dropdown

Method notes / caveats
  • The failing request for the error test was induced by temporarily blocking onkeiki.com's IP at the
    firewall, so the message is a client-side error sending request for url (…) rather than a server
    4xx — same composer notice path, but worth knowing. The rule was removed and the transcript
    refetched fine afterwards.
  • Regression: the new-session canvas still has no Steer/hint and a live send; returning to a Keiki
    conversation restores all three.
  • 0 panics and 0 Keiki timestamp could not be parsed warnings in the run log.

Written by Devin

@devin-ai-integration

Copy link
Copy Markdown
Author

Runtime test — Keiki agent settings/delete + MCP scope upgrade, e74531a (headed Linux/X11, real onkeiki.com org)

Drove the whole delta through the UI against Adam's Organization. No /messages, no /steer; mutations confined to Devin test agent, which was deleted once at the very end.

Agent settings dialog loads real server config, errors are verbatim, saves round-trip

Right-click an agent in All agents ⌄Agent settings… loads the live config (id e291a0ee-…, google/gemini-3.5-flash, real prompt, 10 / 16, Low, 11 feature toggles) and it matches /api/webapp/agents exactly.

Settings dialog with real server values

A failing save keeps the dialog open with every field intact and the error rendered verbatim above Cancel/Save; Cancel discards edits; a saved change reloads from the server on reopen (verified in the API too) and the restore round-trips as well.

Failed save: dialog stays open, fields intact, verbatim error

Connect Keiki tools…scope=mcp manage → non-clickable "Keiki tools connected"

Consent URL carries scope=mcp+manage&resource=https://onkeiki.com/mcp; after approval the row is dimmed Keiki tools connected and clicking it opens nothing. With the replaced token the sidebar still lists all agents, transcripts still render, and send is still gated (Take over this conversation to reply, Enter delivers nothing). Sign out clears every Keiki row (local device survives) and sign in restores them — the row then correctly reads Connect Keiki tools… again, because plain sign-in requests scope=manage only.

consent requests mcp manage row after the grant
consent connected

Delete agent, once, named correctly

Delete Keiki agent? quotes "Devin test agent"; after confirming the row vanishes without a refresh and the other five agents remain — confirmed against /api/webapp/agents.

Confirm names the right agent

Two things to look at

  1. Server accepts a nonexistent model. Saving model = google/gemini-does-not-exist returned success and persisted (visible in /api/webapp/agents). The desktop sent a correct partial update — this looks like missing platform-side validation. Restored immediately.

  2. Cosmetic (fix in progress): the System prompt text overflows its box onto the Max steps / History limit fields.

    prompt overlaps numeric fields

Coverage caveats
  • The invalid assigned line case could not be produced: GET /api/webapp/lines returns {"lines":[]} for this org and every agent has lineNumber: null, so the Line picker only offers No line. The error path was instead exercised by temporarily blocking onkeiki.com at the firewall, so the message shown is a client-side error sending request for url (…) rather than a server 4xx. Rule removed; the next load succeeded.
  • MCP tool execution in harness runs is not testable on this box (no harness CLI / model credentials).
  • 0 panics and 0 timestamp-parse warnings in the run log.

Written by Devin

@devin-ai-integration devin-ai-integration Bot changed the title Layer Keiki onto the existing comet shell Layer Keiki onto the existing comet shell, with Copilot as the only harness Aug 29, 2026
@devin-ai-integration

Copy link
Copy Markdown
Author

Runtime test — Copilot, clean build of f7ee84d + local sms-kit e9a239df (sms-kit#782)

Local stack only (Postgres + platform API on http://localhost:8080, desktop launched with KEIKI_API_URL=http://localhost:8080, built from a clean worktree). Every UI claim below was confirmed in Postgres.

Copilot end to end

  • Multi-turn context. Turn 2 answered with a token introduced in turn 1, and the server-side transcript now grows (jsonb_array_length(messages) 4 → 8 → 12 → 16 → 27) instead of being overwritten at 2. Zero GET /api/copilot/threads/… requests — that client surface is gone.
  • Approvals in the same turn. A code_task-delegating deletion renders the assistant's pre-approval prose and the Approve/Decline card in the turn that provokes it. Approve → {"approved": true} and the agent row is gone; Decline → {"approved": false} and it survives.
  • Cancel. POST /api/copilot/runs/<uuid>/cancel → 200 on the durable run id (not the openrouter-… stream id); the row reads status = aborted, cancel_requested = t, partial output is kept and the next turn answers normally.
  • Ordinary turns complete exactly once; relaunch reuses the same Copilot chat with its transcript and context intact; 0 panics, no 5xx.

The dropped pre-approval text

Diagnosed and fixed in f7ee84d. The prose was always TEXT_MESSAGE_CONTENT — the bug was that the client treated a mid-run RUN_FINISHED as terminal. A model turn that asked for tools ends with RUN_FINISHED carrying metadata.tanstack.finishReason = "tool_calls" and no outcome (sms-kit says so itself in isTerminalFinish), so we ended the turn before the text and before the interrupt that followed:

seq  67 → RUN_FINISHED  finishReason = tool_calls, outcome = null      ← previously ended the turn here
seq  69 → TEXT_MESSAGE_CONTENT  "Found "Delete Me C" … Approve to proceed."
seq 106 → RUN_FINISHED  finishReason = tool_calls, outcome = interrupt ← the real end; card raised

The mapper now reads that finish reason and only completes on a non-tool_calls finish, with an interrupt outcome taking priority. Verified on a run with exactly that shape (rf = 2, first_rf = 67 < first_text = 69), so the client fix — not a different copilot path — is what changed the outcome.

Follow-up found while testing

A project-less Copilot chat had no composer at all when the org has zero agents (the composer was gated on spaces existing, and Keiki agents are the spaces) — so the Copilot could delete the last agent and render its own chat unusable. Being fixed on this branch.

Still untested

Keiki Pin/Unpin/View conversation and send gating — the local org has no conversations and hand-seeding them risks an invalid fixture. They were verified earlier against the real onkeiki.com account.

@devin-ai-integration

Copy link
Copy Markdown
Author

Runtime test — zero-agent composer fix, clean comet c74f80f + local sms-kit e9a239df (#782)

Rebuilt from the clean worktree (git status clean at c74f80f), local stack only (Postgres + platform API on http://localhost:8080, desktop launched with KEIKI_API_URL=http://localhost:8080). Every UI claim cross-checked in Postgres and the API log.

The round-4 zero-agent gap is fixed

With the local org at zero Keiki agents, a fresh project-less Copilot chat — the exact state that painted a bare, composer-less canvas before — now renders the composer, and a turn actually sends and streams:

Zero agents: composer present in a fresh Copilot chat

Zero agents: turn sent and streamed

Server side: POST /api/copilot/chat → 200, runs 2941f6cb-… and de7fcf10-… both status = completed, no 5xx.

The onboarding card is untouched, and the fix does not over-reach

Zero agents with nothing selected still shows the card and no composer:

Onboarding card, no composer

Normal Keiki case unregressed

Seeding one agent (Round5 Agent) brings it back in the agent picker tagged @ Keiki, the onboarding card disappears, the full new-session composer returns with its Keiki / agent target pickers, and a turn against that agent streams normally. Transcript history survived three relaunches.

One pre-existing quirk worth knowing (not from this commit)

At zero agents, pressing the titlebar + after having opened the Copilot chat gives a bare canvas — no composer (correct) but also no onboarding card. Selecting the project-less Copilot chat sets no_project = true (state.rs:1827-1830) and select_chat(None) intentionally keeps that project pick, so the card's !no_project condition fails. That condition is byte-identical before and after this commit, so it is pre-existing; the card was verified in the first-boot state it governs. Changing it would be a separate tweak to no_project stickiness.

0 panics, no 5xx, stack left running.

@devin-ai-integration

Copy link
Copy Markdown
Author

Runtime test — desktop Copilot against production https://onkeiki.com (comet c74f80f, sms-kit #782 merged)

First non-local Copilot run. Desktop built from a clean worktree at c74f80f and launched with no KEIKI_API_URL, so it used the code default DEFAULT_API_URL = "https://onkeiki.com". Signed in with the production account over loopback OAuth (/oauth/register → 201). Strictly read-only prompts. All UI claims cross-checked against the production read-only replica.

Bearer auth reaches production /api/copilot — the new thing

Baseline: unauthenticated POST https://onkeiki.com/api/copilot/chat401. Signed in, a read-only turn streamed and completed, naming all 6 real agents in the org:

Read-only turn listing all 6 production agents

Server side, note the run id shape — every pre-existing production run uses the old run-<ts>-… form, while all three of mine are durable UUIDs, so the merged durable path is live:

run_id thread_id status
dbe7682c-3189-4263-83c4-7a750a49ddba 6384c41a-… completed
744cef54-4a6e-4805-a26a-c01a383ef532 6384c41a-… completed
f74d1d17-6083-45e9-82d9-f67d90e3dbfe 6384c41a-… completed

appendToTranscript works against the merged server

Turn 1 planted a token; turn 2 recalled it (the token is absent from turn 2's request, so only the server-stored transcript can supply it), and copilot_transcripts grew 4 → 6 messages:

Second turn recalls the planted token

Relaunch reopens the same chat and the same server thread

copilotChatId unchanged after relaunch with both turns still rendered; a third turn then reused the same production thread 6384c41a-… (transcript 6 → 8) and recalled the token again — so continuity is server-side, not just local storage:

Same chat and transcript after relaunch

0 panics, no 401/403 anywhere in the desktop logs. Read-only discipline held: the org's 6 agents are untouched, no approval card was raised (so the decline path was not exercised this round — it was verified locally), nothing sent or steered, no contact messaged.

Two observations, neither a defect in this PR
  • The bottom-left account chip still reads Local only while fully signed in to Keiki (production agents load, three production runs completed) — it tracks comet's own cloud-sync account, not the Keiki session. Cosmetic, but it is the only signed-in indicator a user looks at.
  • The desktop's copilotChatId and the server transcript thread_id are distinct identifiers, so "same chat reopened" and "same server thread reused" were verified separately rather than inferred from each other.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant