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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions docs/features/agy-stream-json-backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# agy Stream-JSON Backend

Feature document for the Happy CLI integration with the agy (Antigravity) CLI
through its native stream-json mode.

## Goal

Drive agy as a single long-lived child process per Happy session, eliminating
per-turn cold starts (harness boot, keyring, eligibility checks) while
supporting multi-turn conversation, mid-session model switching, and crash
recovery with full context preservation.

## Core Entry Points

| File | Role |
|---|---|
| `packages/happy-cli/src/agy/runAgy.ts` | Session loop: remote message intake, metadata updates, turn orchestration |
| `packages/happy-cli/src/agy/AgyBackend.ts` | `AgentBackend` implementation owning the persistent agy child process |
| `packages/happy-cli/src/agy/streamJson.ts` | NDJSON stream parser (text deltas, thinking, tool calls, results) |
| `packages/happy-cli/src/agy/discoverModels.ts` | `agy models` discovery + slug/display-name resolution |
| `packages/happy-cli/src/agy/AgySdkBackend.ts` | Alternative Python SDK-bridge backend (model is passed per-turn, so it needs no restart-on-model-change) |

## Architecture

```
Happy iOS/Web client
β”‚ (encrypted WebSocket; user messages carry meta.model / meta.permissionMode)
β–Ό
runAgy.ts session.onUserMessage
β”‚ β”œβ”€ backend.setModel(meta.model) ← may trigger process restart
β”‚ └─ messageQueue.push(text)
β–Ό
AgyBackend (persistent process driver)
β”‚ spawn: agy --input-format stream-json --output-format stream-json
β”‚ [--dangerously-skip-permissions] [--model <name>]
β”‚ [--conversation <id>] --add-dir <cwd>
β–Ό
agy child process ──NDJSON over stdout──▢ StreamJsonParser ──▢ AgentMessage events
```

## Data Flow

1. **Startup**: `startSession()` β†’ `ensureChildRunning()` β†’ `spawnPersistentChild()`.
Startup completes when the child emits `{"event":"init","conversation_id":...}`;
the conversation id is stored and surfaced via `onConversationId` listeners.
2. **Turn**: `sendPrompt()` writes `{"event":"user","message":{"content":...}}` to
the child's stdin and resolves when a `result` event with `status: "SUCCESS"`
arrives for the turn. Turns are serialized by the message queue in `runAgy.ts`.
3. **Model switch**: the client's model picker applies the new model to the
*next* user message via `meta.model`. `runAgy.ts` calls
`backend.setModel()` and updates session metadata (`currentModelCode`) so the
UI reflects the selection immediately.

## Model Switching Restarts the Process

`--model` is a **spawn-time flag**; a running agy process keeps using the model
it was started with. To make client-side model changes actually take effect:

- `AgyBackend.setModel()` compares the resolved model against the current one.
Unchanged β†’ no-op.
- If the child is alive and **no turn is running**, the child is killed
(`SIGTERM`) immediately; the next `sendPrompt()` respawns with the new
`--model` and the same `--conversation <id>` (context preserved).
- If a turn **is running**, killing mid-turn would fail the in-flight prompt,
so the restart is deferred via `pendingModelRestart` and executed in the
`sendPrompt()` `finally` block after the turn settles.

Consequence: the model reported in session metadata and the model actually in
use can briefly diverge while a turn is in flight; they converge once the turn
completes and the next prompt respawns the process.

`AgySdkBackend` does **not** need this: it passes `model` inside every `chat`
bridge request, so changes apply on the next turn with no restart.

## Key Data Structures

- `AgyBackendOptions` β€” `cwd`, `permissionMode`, `model`, `models`
(discovered list for slug resolution), `conversationId`, `spawnFn`
(injectable for tests), `maxRetries`.
- Stream-json events (see `streamJson.ts`): `init`, `step_update`
(text/thinking/tool deltas), `result` (`SUCCESS` | `ERROR`).
- `DiscoveredModel { code, value, slug?, description? }` β€” `code`/`value` are
display names accepted by `agy --model`; `resolveAgyModelName()` maps slugs
(e.g. `gemini-3.7-flash-high`) to display names.

## External Dependencies

- `agy` CLI on PATH (resolved via `resolveAgyBin()`), with stream-json support
and the `agy models` subcommand for discovery.
- Google OAuth / network access for agy's eligibility checks.

## Error Paths

- **Transient startup failures** (eligibility check, EOF, TLS handshake,
rate limit β€” see `isRetryableAgyError`): retried up to `maxRetries` times
with linear backoff inside `ensureChildRunning()`.
- **Non-retryable startup failure**: `startSession()`/`sendPrompt()` reject;
the turn ends as failed.
- **Mid-turn process exit**: the active turn rejects with
"agy process exited unexpectedly"; the *next* prompt respawns the process
with `--conversation <id>`.
- **Stale events from a replaced process**: a killed process's `close`/`error`/
stdout events arrive asynchronously and may land after a replacement has
already spawned (model-change restart). All child-event handlers are guarded
by `this.child === child` so a stale process can neither clear the live
reference nor feed/reject the active turn. (`cancel()` nulls the reference
before `close` arrives, so the turn-rejection path also fires on
`this.child === null` to keep aborts working.)
- **Model discovery failure**: falls back to the static `AGY_MODELS` list.

## Testing

`npx vitest run src/agy/AgyBackend.test.ts src/agy/streamJson.test.ts`

Covers: persistent multi-turn in one process, startup retry, single error
status on failed turns, mid-turn crash, and model switching (no-op on
unchanged model, immediate restart when idle, deferred restart mid-turn,
respawn args carrying new `--model` + preserved `--conversation`).

## Change Log

- 2026-08-21: Fix race where a late `close` event from the killed process
cleared the replacement's `this.child` reference after a model-change
restart, silently dropping the next prompt (`this.child?.stdin?.write` on
null) and hanging the turn forever. Child-event handlers are now guarded by
identity checks.
- 2026-08-21: `setModel()` now restarts the persistent process on model change
(deferred while a turn is running), so client-side model switching takes
effect on the next prompt instead of only after a crash/respawn.
- 2026-08-21: Native persistent single-process stream-json driver introduced
(replaces per-turn process spawning), with explicit conversation id
management and startup retry.
5 changes: 5 additions & 0 deletions docs/session-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ Agent-only service text shown to the user as-is. Supports markdown.
|---|---|---|
| `text` | string | Service message text (markdown) |

Producers also use `service` to surface backend errors in the chat: when an
agent backend reports `status: 'error'` (e.g. a failed or interrupted turn),
the CLI emits a `⚠️ <detail>` service message before the failed `turn-end`, so
mobile/web clients can see why the turn failed instead of it stopping silently.

### `tool-call-start`

Agent begins a tool invocation.
Expand Down
15 changes: 15 additions & 0 deletions packages/happy-app/sources/app/(app)/session/[id]/info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,21 @@ function SessionInfoContent({ session }: { session: Session }) {
}}
/>
)}
{session.metadata?.agyConversationId && (
<Item
title="Antigravity Conversation ID"
subtitle={`${session.metadata.agyConversationId.substring(0, 8)}...${session.metadata.agyConversationId.substring(session.metadata.agyConversationId.length - 8)}`}
icon={<Ionicons name="hardware-chip-outline" size={29} color="#4285F4" />}
onPress={async () => {
try {
await Clipboard.setStringAsync(session.metadata!.agyConversationId!);
Modal.alert(t('common.success'), 'Antigravity Conversation ID copied to clipboard');
} catch (error) {
Modal.alert(t('common.error'), 'Failed to copy Antigravity Conversation ID');
}
}}
/>
)}
{/* Resume command β€” shown for disconnected sessions with a backend session ID */}
{/* TODO: migrate to `happy resume <happy-session-id>` once it works without happy-agent auth */}
{!sessionStatus.isConnected && getResumeCommand(session) && (
Expand Down
4 changes: 2 additions & 2 deletions packages/happy-app/sources/components/HomeDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ const styles = StyleSheet.create((theme) => ({
focusConfigValue: {
flex: 1,
minWidth: 0,
color: theme.colors.text,
color: '#FFFFFF',
fontSize: 17,
...Typography.default(),
},
Expand Down Expand Up @@ -979,7 +979,7 @@ export const HomeDock = React.memo(({
<Ionicons
name={row.icon}
size={compact ? 21 : 18}
color={theme.colors.text}
color={compact ? '#FFFFFF' : theme.colors.text}
/>
</View>
{compact ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as React from 'react';
import { StyleSheet, View } from 'react-native';
import { useUnistyles } from 'react-native-unistyles';
import {
Button,
Host,
Expand Down Expand Up @@ -52,7 +51,6 @@ export function NativeOptionsPicker({
onSelect,
children,
}: NativeOptionsPickerProps) {
const { theme } = useUnistyles();
return (
<View style={styles.container}>
<View
Expand All @@ -69,12 +67,12 @@ export function NativeOptionsPicker({
avoidance would drag the trigger off it. */}
<Host ignoreSafeArea="keyboard" style={styles.host}>
<Menu
// The tint is what colors the label, so it has to follow the
// theme: SwiftUI draws the visible row here, and a fixed
// white would render it invisible in light mode.
// NativeOptionsPicker sits over the dark focus overlay (focusBackdrop),
// so its label must always use a white tint to stay legible in both
// light and dark modes.
// No glass capsule: the plain style leaves the system less
// chrome to morph when the menu opens.
modifiers={[tint(theme.colors.text), buttonStyle('plain')]}
modifiers={[tint('#FFFFFF'), buttonStyle('plain')]}
label={(
// The whole row is the label, so every part of it opens
// the menu: the icon, the value, and the space between.
Expand Down
3 changes: 3 additions & 0 deletions packages/happy-app/sources/components/modelModeOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ export function getOpenClawModelModes(): ModelMode[] {
// Keys are the exact display names `agy --model` accepts (as printed by `agy models`).
export function getAgyModelModes(): ModelMode[] {
return [
{ key: 'Gemini 3.7 Flash (High)', name: 'gemini 3.7 flash (high)', description: null },
{ key: 'Gemini 3.7 Flash (Medium)', name: 'gemini 3.7 flash (medium)', description: null },
{ key: 'Gemini 3.7 Flash (Low)', name: 'gemini 3.7 flash (low)', description: null },
{ key: 'Gemini 3.6 Flash (High)', name: 'gemini 3.6 flash (high)', description: null },
{ key: 'Gemini 3.6 Flash (Medium)', name: 'gemini 3.6 flash (medium)', description: null },
{ key: 'Gemini 3.6 Flash (Low)', name: 'gemini 3.6 flash (low)', description: null },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,11 @@ describe('iOS Expo-native menu triggers', () => {
expect(icons[0].props.systemName).toBe('desktopcomputer');
});

// The menu tint, not foregroundColor, is what paints a SwiftUI label. Once
// the label became the visible chip a fixed white turned every trigger
// invisible in light mode, so the tint has to track the theme.
it('paints both native triggers with the theme text color, not a fixed white', () => {
// The menu tint, not foregroundColor, is what paints a SwiftUI label.
// NativeOptionsPicker sits over the dark focus overlay (focusBackdrop) and
// uses a white tint to stay legible in both light and dark modes, while
// NativeSettingsMenu sits over the composer chip and tracks theme text color.
it('paints native picker trigger with white and settings trigger with theme text color', () => {
const picker = render(React.createElement(NativeOptionsPicker, {
title: 'Machine',
triggerLabel: 'Mac',
Expand All @@ -162,7 +163,7 @@ describe('iOS Expo-native menu triggers', () => {
children: React.createElement('Trigger'),
}));
expect(picker.root.findByType('ExpoMenu' as any).props.modifiers)
.toContainEqual({ type: 'tint', value: THEME_TEXT_COLOR });
.toContainEqual({ type: 'tint', value: '#FFFFFF' });

const settings = render(React.createElement(NativeSettingsMenu, {
accessibilityLabel: 'Model',
Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/sync/storageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export const MetadataSchema = z.object({
machineId: z.string().optional(),
claudeSessionId: z.string().optional(), // Claude Code session ID
codexThreadId: z.string().optional(), // Codex app-server thread ID
agyConversationId: z.string().optional(), // Antigravity CLI conversation ID
tools: z.array(z.string()).optional(),
slashCommands: z.array(z.string()).optional(),
mcpServers: z.array(z.object({ name: z.string(), status: z.string() })).optional(),
Expand Down
8 changes: 8 additions & 0 deletions packages/happy-app/sources/utils/resumeCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ describe('buildResumeCommand', () => {
})).toBe('happy claude --resume 93a9705e-bc6a-406d-8dce-8acc014dedbd');
});

it('builds an agy resume command for agy sessions', () => {
expect(buildResumeCommand({
path: '/tmp/project',
flavor: 'agy',
agyConversationId: 'c3b66b04-1234-5678',
})).toBe(`cd '/tmp/project' && happy agy --resume c3b66b04-1234-5678`);
});

it('returns null when there is no resumable session identifier', () => {
expect(buildResumeCommand({
path: '/tmp/project',
Expand Down
4 changes: 4 additions & 0 deletions packages/happy-app/sources/utils/resumeCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type ResumeCommandMetadata = {
flavor?: string | null;
claudeSessionId?: string | null;
codexThreadId?: string | null;
agyConversationId?: string | null;
client?: { id?: string | null } | null;
capabilities?: { resume?: boolean | null } | null;
};
Expand Down Expand Up @@ -32,6 +33,9 @@ function buildResumeInvocation(metadata: ResumeCommandMetadata): string | null {
if ((metadata.flavor === 'codex' || metadata.flavor === 'openai' || metadata.flavor === 'gpt') && metadata.codexThreadId) {
return `happy codex --resume ${metadata.codexThreadId}`;
}
if ((metadata.flavor === 'agy' || metadata.flavor === 'antigravity') && metadata.agyConversationId) {
return `happy agy --resume ${metadata.agyConversationId}`;
}
if (metadata.claudeSessionId) {
return `happy claude --resume ${metadata.claudeSessionId}`;
}
Expand Down
34 changes: 32 additions & 2 deletions packages/happy-cli/src/agent/acp/AcpSessionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,45 @@ describe('AcpSessionManager turn lifecycle', () => {
expect(isCuid(start2[0].turn!)).toBe(true);
});

it('ignores status messages in mapMessage', () => {
it('ignores non-error status messages in mapMessage', () => {
const mapper = new AcpSessionManager();
expect(mapper.mapMessage({ type: 'status', status: 'running' })).toHaveLength(0);
expect(mapper.mapMessage({ type: 'status', status: 'idle' })).toHaveLength(0);
expect(mapper.mapMessage({ type: 'status', status: 'error' })).toHaveLength(0);
expect(mapper.mapMessage({ type: 'status', status: 'stopped' })).toHaveLength(0);
expect(mapper.mapMessage({ type: 'status', status: 'starting' })).toHaveLength(0);
});

it('maps error status to a visible service message attached to the turn', () => {
const mapper = new AcpSessionManager();
const start = mapper.startTurn()[0];

const envelopes = mapper.mapMessage({ type: 'status', status: 'error', detail: 'Model rate limit reached' });
expect(envelopes).toHaveLength(1);
expect(envelopes[0].ev).toEqual({ t: 'service', text: '⚠️ Model rate limit reached' });
expect(envelopes[0].role).toBe('agent');
expect(envelopes[0].turn).toBe(start.turn);
});

it('uses a fallback service text for error status without detail', () => {
const mapper = new AcpSessionManager();
mapper.startTurn();

const envelopes = mapper.mapMessage({ type: 'status', status: 'error' });
expect(envelopes).toHaveLength(1);
expect(envelopes[0].ev).toEqual({ t: 'service', text: '⚠️ Agent error' });
});

it('flushes pending output before the error service message', () => {
const mapper = new AcpSessionManager();
mapper.startTurn();
mapper.mapMessage({ type: 'model-output', textDelta: 'partial answer' });

const envelopes = mapper.mapMessage({ type: 'status', status: 'error', detail: 'stream interrupted' });
expect(envelopes).toHaveLength(2);
expect(envelopes[0].ev).toEqual({ t: 'text', text: 'partial answer' });
expect(envelopes[1].ev).toEqual({ t: 'service', text: '⚠️ stream interrupted' });
});

it('flushes pending text on endTurn()', () => {
const mapper = new AcpSessionManager();
mapper.startTurn();
Expand Down
16 changes: 15 additions & 1 deletion packages/happy-cli/src/agent/acp/AcpSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export class AcpSessionManager {

this.currentTurnId = createId();
this.acpCallToSessionCall.clear();
this.pendingText = '';
this.pendingType = null;
return [
createEnvelope('agent', { t: 'turn-start' }, { turn: this.currentTurnId, time: this.nextTime() }),
];
Expand All @@ -94,6 +96,8 @@ export class AcpSessionManager {
const turnId = this.currentTurnId;
this.currentTurnId = null;
this.acpCallToSessionCall.clear();
this.pendingText = '';
this.pendingType = null;
return [
...flushed,
createEnvelope('agent', { t: 'turn-end', status }, { turn: turnId, time: this.nextTime() }),
Expand Down Expand Up @@ -127,7 +131,17 @@ export class AcpSessionManager {
}

if (msg.type === 'status') {
return [];
// Surface backend errors as visible service messages so mobile/web
// clients can see why a turn failed. Other statuses only drive local
// thinking state and stay invisible.
if (msg.status !== 'error') {
return [];
}
const detail = msg.detail?.trim();
return [
...this.flush(),
createEnvelope('agent', { t: 'service', text: detail ? `⚠️ ${detail}` : '⚠️ Agent error' }, turnOptions(this.currentTurnId, this.nextTime())),
];
}

if (msg.type === 'model-output') {
Expand Down
Loading