mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
feat(daemon): add Antigravity agent adapter (#3157)
* feat(daemon): add Antigravity agent adapter
Adds Google Antigravity (`agy` CLI) as a coding-agent runtime. Detection
picks up `agy` on PATH, the daemon spawns `agy -p "<prompt>"` for a
single non-interactive turn, and the assistant text reply streams back
on stdout. OAuth is shared with the Antigravity IDE through the system
keyring, so users who have signed into the desktop app are authenticated
on first run with no extra step.
`agy` v1.0.3 has no JSON / stream-json / ACP output mode (upstream issue
#119), no `--model` flag (issue #35), and no MCP forwarding hook yet —
the adapter ships with `streamFormat: 'plain'` and a single `default`
fallback model so the model picker doesn't mislead users into thinking
their choice is wired through. We will upgrade buildArgs + add a
dedicated event parser when upstream ships structured output.
Also gitignores `.antigravitycli/`, the project-local config directory
`agy` auto-creates on every run (upstream issue #175).
* fix(daemon): Antigravity adapter — stdin prompt, brand icon, form loop, empty-output guard
- Switch prompt delivery from argv to stdin (`agy -p -`) to avoid the
30KB maxPromptArgBytes limit that blocked real-world composed prompts
- Add official Antigravity brand SVG icon to agent picker
- Fix repeated question-form loop for plain agents by injecting an
OVERRIDE block when form answers are already present in the transcript
- Add empty-output guard for plain agents so expired auth or silent
failures surface a user-visible error instead of a blank "Done" turn
* feat(daemon): expand Antigravity adapter — model picker, form-loop fix, OAuth launcher, log-file classification
PR #3157 follow-up integrating four iterations from end-to-end manual
testing on Gemini 3.5 Flash + GPT-OSS 120B Medium through `agy` v1.0.3.
Each section is independently verifiable; combined they're what made
the first successful artifact generation work end-to-end.
## Model picker via settings.json (agy has no --model flag)
agy v1.0.3 ships no `--model` CLI flag (upstream issue #35), but the
TUI Switch-Model picker writes the chosen label to
`~/.gemini/antigravity-cli/settings.json`'s `"model"` field, and every
`-p` invocation re-reads that file on startup — verified by capturing
the `--log-file` line `Propagating selected model override to backend:
label="<model>"`. Antigravity's `fallbackModels` now lists the 8
labels its TUI exposes (Gemini 3.1 Pro / 3.5 Flash variants, Claude
Sonnet/Opus 4.6 Thinking, GPT-OSS 120B Medium) and `buildArgs`
persists the user's choice to settings.json right before spawn. The
synthetic `default` id is preserved — picking it leaves settings.json
untouched so a user who switches models from agy's own TUI keeps
their choice.
Introduces `RuntimeAgentDef.supportsCustomModel?: boolean`. AMR's
hardcoded blocklist in `SettingsDialog.tsx` migrates to the
declarative flag (it rejects free-form ids at the ACP layer), and
antigravity opts out because its label set is a server-side enum that
silently fails on unrecognised strings.
## Form-loop fix (transcript sanitizer + stronger OVERRIDE)
The discovery form loop on weak/medium plain-stream models (GPT-OSS
120B Medium, Gemini 3.5 Flash) had two reinforcing causes:
1. `buildDaemonTranscript` packed the prior assistant turn's
literal `<question-form>` markup into the user request on the
next turn, giving the model a template to echo. New
`sanitizePriorAssistantTurnForTranscript` strips
`<question-form>...</question-form>` blocks and ```json fences
that match form-schema shape, replacing them with a brief
placeholder. User content is preserved verbatim (a user who
legitimately mentions `<question-form>` in chat keeps their
message intact).
2. The OVERRIDE block on form-answered turns was 4 lines and only
banned the bare `<question-form>` tag — models still emitted the
fenced JSON, form-asking prose ("Got it — tell me the following"),
and fake system events ("subagents stopped"). The new
`FORM_ANSWERED_SYSTEM_OVERRIDE` enumerates each anti-pattern and
pins them via tests, so silently weakening any line reintroduces
the regression.
Also adds RuntimeAgentDef.resumesSessionViaCli + RuntimeContext.
hasPriorAssistantTurn as forward-looking abstractions (skipTranscript
option on composeChatUserRequestForAgent). Antigravity does NOT opt
in — agy's `-c` resume activates an internal agentic loop with tool
retries and fallback-to-cached-response on tool errors that the OD
system prompt cannot steer; reverted after seeing byte-identical
form re-emissions caused by agy's own retry logic, not OD's transcript.
## One-click OAuth via system terminal
agy print mode can't complete Google Sign-In on its own (the OAuth
callback page asks the user to paste an auth code back into agy, but
`-p` has no input field). Before this commit the auth banner only
told the user to "open a terminal yourself."
Adds `POST /api/agents/antigravity/oauth-launch` and a cross-platform
launcher in `runtimes/terminal-launch.ts`:
- macOS: osascript → Terminal.app `do script "agy"` + activate
- Linux: tries x-terminal-emulator, gnome-terminal, konsole,
xfce4-terminal, xterm in order
- Windows: `cmd /c start "Open Design" cmd /k agy`
The endpoint hardcodes the `agy` command (no user input → no shell
injection surface) and is loopback-gated like the other daemon
endpoints. The chat's `AGENT_AUTH_REQUIRED` banner now renders a
"Sign in via terminal" button next to Retry; clicking it spawns the
terminal so the user can finish OAuth in one click.
## Silent-failure classification (auth vs quota via --log-file)
agy print mode is silent on stdout/stderr for both missing-OAuth AND
quota-exhausted failures — the upstream
`RESOURCE_EXHAUSTED (code 429): Individual quota reached` and the
`not logged into Antigravity` line only surface in agy's
`--log-file`. Without log inspection the daemon misread quota as
"auth required" and showed the wrong banner.
`RuntimeContext.agentLogFilePath` carries a daemon-owned per-run temp
path that antigravity's buildArgs translates to `--log-file <path>`.
The empty-output guard now reads that log on a `code === 0 &&
!childStdoutSeen` exit, feeds the tail to
`classifyAgentServiceFailure`, and routes:
- "not logged into Antigravity" → AGENT_AUTH_REQUIRED with
antigravityAuthGuidance
- "RESOURCE_EXHAUSTED" / "quota" / → RATE_LIMITED with
"Individual quota reached" antigravityQuotaGuidance
- none of the above (rare) → fall back to auth guidance
as the most likely cause
Both surface a terminal launcher in the auth banner: auth gets "Sign
in via terminal", quota gets "Switch model in terminal" — same
endpoint, contextual label. The handler is identical (open agy in a
terminal); the user either signs in or uses agy's Switch Model
picker to pick a model with available quota.
## Validation
- `pnpm guard` pass
- `pnpm --filter @open-design/daemon` runtime + telemetry suites:
192 passed, 1 skipped (the 1 pre-existing `task-type` failure on
origin/main is unrelated to this change)
- `pnpm --filter @open-design/web` typecheck pass; sse / amr-guidance
/ AgentIcon suites pass (51 web tests)
- Manual end-to-end on darwin + Gemini 3.5 Flash and GPT-OSS 120B
Medium: turn-1 question-form rendered correctly, turn-2 produced
`<artifact>` with full HTML (3.3KB Modern Minimal design) instead
of re-emitting the form. agy `--log-file` content correctly
classified as RATE_LIMITED when Gemini Pro quota was exhausted,
and as AGENT_AUTH_REQUIRED when keychain was cleared.
* fix(web/test): align amrAgent fixture with supportsCustomModel contract
The AMR agent definition in the daemon ships `supportsCustomModel: false`
so the Settings model picker hides the free-text "Custom…" option. The
PR changed `allowCustomModel` from `selected.id !== 'amr'` (hardcoded)
to `selected.supportsCustomModel !== false` (declarative), but the test
fixture was not updated to carry the same field — causing the
`__custom__` sentinel to appear in the picker under test.
Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)
* fix(daemon): align formAnswerTransition wording with main + scope build directive to discovery
CI surfaced two failures on the merge with main:
- chat-route.test marks submitted discovery form answers ... expected
the main-version wording 'Do not emit another <formId> form.'
- telemetry-message-finalization keeps non-discovery form answers
active ... expected task-type to fall through the else branch
('Treat these form answers as the active user turn'), not the
discovery RULE 2/RULE 3 build branch.
The colleague's earlier fba1e40b form-loop fix tightened both pieces
(stronger wording + grouped discovery|task-type into the build branch)
but didn't update the tests that pin the contract. Revert the
transition wording to main and re-scope the build directive to
'discovery' only. The aggressive form-loop suppression we added in
this PR now lives in the system-prompt FORM_ANSWERED_SYSTEM_OVERRIDE
block, which is far stronger than the user-request transition text
this commit reverts.
* fix(daemon): scope formOverride by form id, detach Linux terminal, move agy log cleanup to finally
- FORM_ANSWERED_GENERIC_OVERRIDE: new exported constant for non-discovery/
non-task-type form ids; contains only the "do not re-ask" suppression
without the RULE 2 / RULE 3 / artifact directive.
- formAnswerTransitionForCurrentPrompt: extend build-transition branch to
include task-type alongside discovery, keeping user-turn and system
override consistent.
- Prompt assembly (server.ts ~10848): derive formOverride from the parsed
form id — FORM_ANSWERED_SYSTEM_OVERRIDE for discovery/task-type,
FORM_ANSWERED_GENERIC_OVERRIDE for all other form ids, empty otherwise.
- launchOnLinux: replace execFileAsync (waited for terminal exit, 3 s cap)
with spawn({ detached: true, stdio: 'ignore' }) + unref(); resolve on
the 'spawn' event so long-lived interactive terminals (xterm, konsole)
are not killed mid-OAuth-flow.
- Antigravity log cleanup: move fs.promises.unlink(agentLogFilePath) into
a try/finally wrapper around the close handler so every exit path
(success, failure, cancel, non-zero exit) cleans up the per-run temp
file, preventing unbounded /tmp accumulation.
- Tests: rename task-type case to assert build-transition behaviour; add
generic-form-id case (preferences) pinning the non-build path; add
FORM_ANSWERED_GENERIC_OVERRIDE content assertions.
Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)
* fix(daemon): switch Antigravity buildArgs to chat subcommand invocation
Replace top-level `-p -` with `agy chat [--log-file …] -` so the adapter
uses the documented chat subcommand and stdin sentinel instead of the
unrecognised global -p flag. Update the agent-args test description and
all four deepEqual assertions to assert the ['chat', '-'] shape.
Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)
* test(daemon): drop real-platform default-launch assertion from terminal-launch suite
The removed test called launchAgentInSystemTerminal('agy') with no
platform override, which invokes the real system terminal on every
developer machine running the daemon test suite (Terminal.app on macOS,
cmd.exe on Windows, xterm/gnome-terminal on Linux). That is an
unacceptable OS side effect for a unit test.
The behaviour being asserted — that omitting platform selects
process.platform — is a TypeScript default-parameter guarantee, not a
runtime invariant that needs an integration test. The remaining 'aix'
case continues to pin the unsupported-platform failure shape.
Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)
* fix(daemon): buffer Antigravity stdout to suppress auth URL before close-time classifier
The plain-stream close handler at code===0 can detect an agy OAuth
prompt in agentStdoutTail and emit AGENT_AUTH_REQUIRED, but by the
time close fires the stdout chunk has already been forwarded to the
client via the plain-stream `send('stdout', { chunk })` path. This
leaves both the raw OAuth URL and the terminal-launch guidance visible
in chat.
Buffer all stdout chunks for the `antigravity` agent instead of
forwarding them immediately. The existing close-time auth-prompt guard
(code===0, !trackingSubstantiveOutput, childStdoutSeen) returns early
when it detects the auth pattern, leaving the buffer unflushed and the
OAuth URL out of the SSE stream. For legitimate assistant output the
buffer is flushed in order just before design.runs.finish so the
chunks still arrive before the run's finished event.
Adds a chat-route integration test using a fake `agy` that exits 0
after printing the canonical auth prompt; asserts that the run emits
AGENT_AUTH_REQUIRED with no event: stdout delta containing the URL.
Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)
* test(daemon): isolate antigravity buildArgs argv test from real settings file
Pass a temp antigravitySettingsPath in the RuntimeContext for the
withModel argv assertion so unit tests do not touch
~/.gemini/antigravity-cli/settings.json. Adds the optional
antigravitySettingsPath field to RuntimeContext and threads it
through buildArgs to writeAntigravityModelSelection; production
callers leave it undefined, preserving the existing default path.
Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)
* fix(daemon): revert Antigravity buildArgs to `-p -` (the only working agy v1.0.3 invocation)
The looper-reviewer-bot reported `chat` as agy's headless subcommand
based on its environment's agy build, and looper-fixer applied that
shape. The installed CLI (`agy --version` reports `1.0.3`) does NOT
expose a `chat` subcommand — `agy --help`'s `Available subcommands`
section lists only `changelog / help / install / plugin / update`,
and `agy chat - < prompt` exits 0 with empty stdout (the daemon then
forwards it as a 'successful' empty reply, exactly the failure mode
the auth/quota guard at server.ts ~12090 is meant to catch — for the
wrong reason).
`-p` is the documented print-mode flag (`Short alias for --print`)
and `agy -p -` reads the prompt from stdin and prints the model
reply, which the entire end-to-end test sequence in this PR has
verified against (form-loop fix, settings.json model routing,
log-file classification all confirmed working on Gemini 3.5 Flash
+ GPT-OSS 120B Medium with this invocation).
Updates the agent-args test to pin `['-p', '-']` instead of
`['chat', '-']` and adds an inline comment in antigravity.ts noting
that `chat` may exist in a future agy build but is not the contract
on the installed CLI today.
* fix(daemon): serialize Antigravity concrete-model spawns to dodge settings.json race
Reviewer (looper) flagged a concurrency race in the model-routing path:
~/.gemini/antigravity-cli/settings.json is process-global, so two OD
runs starting close together with different concrete models can race
the file — run A writes model A, run B writes model B, then A's agy
finally reads settings.json and executes on model B. The Settings
model picker becomes nondeterministic under parallel conversations.
Adds a per-process promise chain in antigravity.ts:
- acquireAntigravityModelLock(): chain-await + return release fn
- waitForAgyToReadModel(logPath, expected): polls agy's --log-file
for the upstream signal
'Propagating selected model override to backend: label="<X>"'
which model_config_manager.go emits once agy has finished reading
settings.json. Returns true on observed match, false on timeout.
Regex-escapes the expected label so '(' / ')' in 'GPT-OSS 120B
(Medium)' match literally, not as a capture group.
server.ts spawn pipeline now acquires the lock BEFORE buildArgs (which
performs the settings.json write) and schedules a release-once handler
that fires when EITHER (a) the log-file confirms agy read the model
or (b) the child exits — the exit fallback prevents a stuck/crashed
agy from starving the queue for every subsequent antigravity spawn.
Default-model spawns bypass the lock entirely: their buildArgs doesn't
touch settings.json, so there's nothing to serialize.
Tests pin:
- FIFO ordering across 2 / 3 concurrent acquirers
- Wait helper's regex correctly matches parenthesized labels
- Wait helper does NOT match a different model with shared prefix
- Wait helper swallows missing-log-file errors and returns false on
timeout (no spawn-pipeline crash if the log never appears)
194 → 198 passing runtime tests, 0 regressions.
* fix(daemon): close Antigravity lock release race on slow agy startup (looper #263fd2fe7)
Reviewer flagged that the previous serialization scheduled
`releaseOnce` in `.finally()` on waitForAgyToReadModel — meaning the
helper's `false` timeout return ALSO released the lock. If agy took
longer than the 15s polling window to read settings.json (cold start,
swap-thrash, slow network handshake to the upstream backend), run A's
lock dropped at 15s, run B rewrote settings.json with model B, and
run A's still-starting agy then read the wrong model. Same race the
original mutex was meant to close.
Fix the release semantics to be release-on-confirmation-only:
- waitForAgyToReadModel: `false` now strictly means 'I gave up
polling,' not 'agy definitely did not read this.' Document the
contract so a future caller can't conflate the two. Add an
optional AbortSignal so server.ts can stop polling when the child
exits — without it, the leftover watcher could outlive the run
and accidentally match a later concurrent run's log content,
releasing the wrong lock.
- server.ts: schedule `releaseOnce` only when waitForAgyToReadModel
returns true. The exit handler (which fires for crashes, fast
exits, normal completion) is now the canonical fallback that
releases the lock no matter what — the queue can't starve
permanently because agy always exits eventually. The exit
handler also fires the AbortController so the watcher cleans up.
New tests pin:
- timeout returns false WITHOUT any release-implying side effect
- already-aborted signal short-circuits (no readFile calls)
- abort mid-poll wakes the helper from its setTimeout (no
multi-hundred-ms hang waiting out a poll interval that no longer
matters)
198 → 201 passing runtime tests, 0 regressions.
---------
Co-authored-by: qiongyu1999 <2694684348@qq.com>
This commit is contained in:
parent
bf7152dbdc
commit
98a2c63973
47 changed files with 1963 additions and 26 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -45,6 +45,7 @@ tsconfig.tsbuildinfo
|
|||
.claude/
|
||||
.codex/
|
||||
.deepseek/
|
||||
.antigravitycli/
|
||||
|
||||
# Commander task scratchpad; keep local task notes out of git by default.
|
||||
.task/
|
||||
|
|
|
|||
|
|
@ -22,6 +22,33 @@ const CURSOR_AUTH_GUIDANCE =
|
|||
const DEEPSEEK_AUTH_GUIDANCE =
|
||||
'DeepSeek TUI is installed but is not authenticated. Add or verify your API key in `~/.deepseek/config.toml` as `api_key = "..."`, or expose DEEPSEEK_API_KEY to the Open Design daemon process, then retry. If Open Design is launched outside an interactive shell, shell rc files such as ~/.zshrc may not be loaded.';
|
||||
|
||||
// agy's print mode (`-p`) detects a missing OAuth token, prints the
|
||||
// Google sign-in URL to stdout, waits 30s for completion, then exits
|
||||
// "Error: authentication timed out." That URL points at a callback page
|
||||
// that asks the user to paste the resulting auth code BACK into agy —
|
||||
// which only works in the interactive TUI. So in OD's chat, surfacing
|
||||
// the raw URL is a dead end (no input field to paste the code into).
|
||||
// Instead we ask the user to run `agy` in a terminal once, which opens
|
||||
// the browser, completes OAuth, and writes the credentials to the
|
||||
// system keyring — both `-p` and TUI invocations read from there
|
||||
// afterward, so the chat run can succeed on retry.
|
||||
const ANTIGRAVITY_AUTH_GUIDANCE =
|
||||
'Antigravity needs to sign in. The agy CLI\'s keyring entry has expired or been cleared, and `-p` print mode cannot complete OAuth on its own (it has no field to paste the auth code into).\n\nFix: open a terminal and run `agy` once — it will open Google sign-in in your browser, accept the redirect, and store the token in your system keyring. After you finish, return here and retry this chat. You only need to do this once; the keyring entry persists across both terminal and Open Design runs.';
|
||||
|
||||
// agy's account-level quota is per-model (consumer accounts get a
|
||||
// separate quota for Gemini 3 Pro vs Flash vs Claude vs GPT-OSS), and
|
||||
// when exhausted the upstream returns
|
||||
// RESOURCE_EXHAUSTED (code 429): Individual quota reached. Contact
|
||||
// your administrator to enable overages. Resets in <H>h<M>m<S>s.
|
||||
// to the `--log-file`. Print mode emits nothing on stdout/stderr, so
|
||||
// without log inspection the daemon misreads it as missing-OAuth.
|
||||
// Guidance points the user at agy's TUI Switch-Model picker because
|
||||
// (a) different models have separate quotas, and (b) we can't drive
|
||||
// the picker from OD until upstream issue #35 ships a `--model`
|
||||
// flag — see antigravity.ts notes.
|
||||
const ANTIGRAVITY_QUOTA_GUIDANCE =
|
||||
'Antigravity returned "RESOURCE_EXHAUSTED: Individual quota reached" for the current model. Each Antigravity model (Gemini 3 Pro / Flash, Claude 4.6, GPT-OSS) has its own quota.\n\nFix: open `agy` in a terminal and use its Switch Model picker (the menu at the bottom of the TUI) to pick a model with available quota, then retry here. Open Design uses whatever model you pick in agy\'s TUI when the Settings model picker is left on "Default". Quotas reset automatically on Antigravity\'s schedule.';
|
||||
|
||||
const REASONIX_AUTH_GUIDANCE =
|
||||
'DeepSeek Reasonix is installed but is not authenticated. Add your API key in `~/.reasonix/config.json` under `apiKey`, or expose DEEPSEEK_API_KEY to the Open Design daemon process, then retry. If Open Design is launched outside an interactive shell, shell rc files such as ~/.zshrc may not be loaded.';
|
||||
|
||||
|
|
@ -33,6 +60,14 @@ export function deepseekAuthGuidance(): string {
|
|||
return DEEPSEEK_AUTH_GUIDANCE;
|
||||
}
|
||||
|
||||
export function antigravityAuthGuidance(): string {
|
||||
return ANTIGRAVITY_AUTH_GUIDANCE;
|
||||
}
|
||||
|
||||
export function antigravityQuotaGuidance(): string {
|
||||
return ANTIGRAVITY_QUOTA_GUIDANCE;
|
||||
}
|
||||
|
||||
export function reasonixAuthGuidance(): string {
|
||||
return REASONIX_AUTH_GUIDANCE;
|
||||
}
|
||||
|
|
@ -50,6 +85,27 @@ export function isCursorAuthFailureText(text: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
// agy's plain-mode output when no keyring credentials are available:
|
||||
// - Top of stdout: "Authentication required. Please visit the URL to log in: <URL>"
|
||||
// - Tail of stdout: "Waiting for authentication (timeout 30s)..."
|
||||
// "Error: authentication timed out."
|
||||
// The same TUI text is logged by `agy --log-file` as
|
||||
// "You are not logged into Antigravity" and
|
||||
// "error getting token source: You are not logged into Antigravity"
|
||||
// (confirmed via the `--log-file` dump on a cleared keyring). Any of
|
||||
// these is sufficient signal — match conservatively so the regex
|
||||
// doesn't fire on prose containing the word "authentication" by accident.
|
||||
export function isAntigravityAuthFailureText(text: string): boolean {
|
||||
const value = String(text || '');
|
||||
if (!value.trim()) return false;
|
||||
return (
|
||||
/authentication required.*please visit/i.test(value) ||
|
||||
/authentication timed out/i.test(value) ||
|
||||
/not logged into antigravity/i.test(value) ||
|
||||
/accounts\.google\.com\/o\/oauth2\/auth.*antigravity/i.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isDeepSeekAuthFailureText(text: string): boolean {
|
||||
const value = String(text || '');
|
||||
if (!value.trim()) return false;
|
||||
|
|
@ -92,6 +148,13 @@ export function classifyAgentAuthFailure(
|
|||
message: deepseekAuthGuidance(),
|
||||
};
|
||||
}
|
||||
if (agentId === 'antigravity') {
|
||||
if (!isAntigravityAuthFailureText(text)) return null;
|
||||
return {
|
||||
status: 'missing',
|
||||
message: antigravityAuthGuidance(),
|
||||
};
|
||||
}
|
||||
if (agentId === 'reasonix') {
|
||||
if (!isReasonixAuthFailureText(text)) return null;
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -196,6 +196,11 @@ export const amrAgentDef = {
|
|||
fallbackModels: [] as RuntimeModelOption[],
|
||||
buildArgs: () => ['agent', 'run', '--runtime', 'opencode'],
|
||||
streamFormat: 'acp-json-rpc',
|
||||
// Vela routes model selection through ACP's `session/set_model` and only
|
||||
// accepts ids that survived the `vela models` preflight check, so a
|
||||
// free-text "Custom" id silently fails at spawn. The model picker
|
||||
// surfaces the live Vela catalog instead.
|
||||
supportsCustomModel: false,
|
||||
supportsImagePaths: true,
|
||||
// Daemon-process env override for emergency operator pinning. Normal UI
|
||||
// selection comes from the live `vela models` catalog and is preflighted
|
||||
|
|
|
|||
247
apps/daemon/src/runtimes/defs/antigravity.ts
Normal file
247
apps/daemon/src/runtimes/defs/antigravity.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { readFile as fsReadFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { DEFAULT_MODEL_OPTION } from './shared.js';
|
||||
import type { RuntimeAgentDef } from '../types.js';
|
||||
|
||||
// `agy` v1.0.3 still has no `--model` flag (upstream issue #35), but the
|
||||
// TUI's Switch-Model picker writes the choice to its settings.json, and
|
||||
// every `agy -p` invocation re-reads that file on startup — verified by
|
||||
// capturing the `--log-file` line `Propagating selected model override to
|
||||
// backend: label="<model>"`. So we can route OD's model picker through
|
||||
// settings.json: when the user picks a concrete model in Settings, the
|
||||
// daemon writes the label into agy's settings.json right before spawn,
|
||||
// and the resulting print-mode run uses that model.
|
||||
//
|
||||
// Two ids the picker exposes are special:
|
||||
// - 'default' : leave settings.json untouched, so agy keeps
|
||||
// whatever the user last picked in its own TUI.
|
||||
// (Respects user choice when they switch models
|
||||
// from `agy` directly.)
|
||||
// - any other id : the literal display label agy expects (e.g.
|
||||
// "Gemini 3.1 Pro (High)", "Claude Sonnet 4.6
|
||||
// (Thinking)"). We persist it before spawn.
|
||||
//
|
||||
// `supportsCustomModel: false` because the label set is a server-side
|
||||
// enum — a typed id agy doesn't recognise resolves to a silent
|
||||
// `availableModels` cache miss + empty print-mode output, which surfaces
|
||||
// to the user as a generic "empty response" error.
|
||||
//
|
||||
// The 8 model labels mirror what `Switch Model` in agy's TUI lists for
|
||||
// consumer-tier accounts as of 2026-05-28. The set is small and stable
|
||||
// enough to ship statically until upstream adds a programmatic
|
||||
// `agy models` subcommand (also tracked under issue #35).
|
||||
const ANTIGRAVITY_SETTINGS_PATH = join(
|
||||
homedir(),
|
||||
'.gemini',
|
||||
'antigravity-cli',
|
||||
'settings.json',
|
||||
);
|
||||
|
||||
export function writeAntigravityModelSelection(
|
||||
label: string,
|
||||
settingsPath: string = ANTIGRAVITY_SETTINGS_PATH,
|
||||
): void {
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
existing = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Corrupt JSON — fall through and rewrite the file from scratch so
|
||||
// the next spawn starts from a known-good state.
|
||||
}
|
||||
}
|
||||
existing.model = label;
|
||||
mkdirSync(dirname(settingsPath), { recursive: true });
|
||||
writeFileSync(settingsPath, `${JSON.stringify(existing, null, 2)}\n`);
|
||||
}
|
||||
|
||||
// Per-process serialization for write-settings → spawn → agy-reads
|
||||
// cycles on antigravity. `~/.gemini/antigravity-cli/settings.json` is
|
||||
// process-global, so two OD runs that both pick concrete (non-default)
|
||||
// models can race: run A writes model A, spawn A starts, run B writes
|
||||
// model B before A's agy has read settings.json — A then executes on
|
||||
// model B. The daemon serialises non-default antigravity spawns
|
||||
// through this chain: each acquire awaits the previous release, and
|
||||
// each release fires only after the spawned agy actually emits
|
||||
// `Propagating selected model override to backend: label="<X>"` in
|
||||
// its `--log-file` (which is the upstream signal that settings.json
|
||||
// has been read).
|
||||
let antigravityLockChain: Promise<void> = Promise.resolve();
|
||||
|
||||
export async function acquireAntigravityModelLock(): Promise<() => void> {
|
||||
const previous = antigravityLockChain;
|
||||
let release: () => void = () => {};
|
||||
antigravityLockChain = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await previous;
|
||||
return release;
|
||||
}
|
||||
|
||||
// Visible for tests. Resets the module-level lock chain so a test that
|
||||
// installed a hanging acquirer can release it without leaking state to
|
||||
// subsequent test cases. Production code never calls this.
|
||||
export function _resetAntigravityModelLockForTests(): void {
|
||||
antigravityLockChain = Promise.resolve();
|
||||
}
|
||||
|
||||
export interface WaitForAgyModelOptions {
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
// Override for tests; production reads the daemon-owned log file path.
|
||||
readFile?: (path: string) => Promise<string>;
|
||||
// Override `Date.now` for tests; production uses the wall clock.
|
||||
now?: () => number;
|
||||
// Stops polling when fired. Production wires this to `child.once('exit')`
|
||||
// so the watcher cancels as soon as agy exits — the lock release is
|
||||
// then driven by the exit handler rather than the helper's return
|
||||
// value, eliminating the slow-startup race the looper review at
|
||||
// 263fd2fe7 flagged: if a cold agy takes >timeoutMs to read its
|
||||
// settings.json, we'd otherwise return false, the caller would
|
||||
// release the lock, and a concurrent run B could rewrite
|
||||
// settings.json before A's agy actually read it.
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
// Polls agy's `--log-file` for the line
|
||||
// `Propagating selected model override to backend: label="<expectedModel>"`
|
||||
// which `model_config_manager.go` emits once agy has finished reading
|
||||
// `~/.gemini/antigravity-cli/settings.json` and sent the model
|
||||
// override to the upstream backend. Returns true on observed signal,
|
||||
// false on timeout OR abort. Never throws — a missing log file is
|
||||
// treated as "not yet seen" so the polling loop keeps retrying until
|
||||
// either the deadline or the abort signal fires.
|
||||
//
|
||||
// IMPORTANT: callers MUST NOT use a `false` return as a "go ahead and
|
||||
// release the settings.json lock" signal — false means "I gave up
|
||||
// polling," not "agy definitely didn't read this." Release the lock
|
||||
// only on (a) a `true` return, OR (b) child exit. See server.ts for
|
||||
// the wiring.
|
||||
export async function waitForAgyToReadModel(
|
||||
logFilePath: string,
|
||||
expectedModel: string,
|
||||
options: WaitForAgyModelOptions = {},
|
||||
): Promise<boolean> {
|
||||
const timeoutMs = options.timeoutMs ?? 15_000;
|
||||
const pollIntervalMs = options.pollIntervalMs ?? 250;
|
||||
const readFile =
|
||||
options.readFile ?? ((path: string) => fsReadFile(path, 'utf8'));
|
||||
const now = options.now ?? Date.now;
|
||||
const abortSignal = options.abortSignal;
|
||||
if (abortSignal?.aborted) return false;
|
||||
const escaped = expectedModel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(
|
||||
`Propagating selected model override to backend: label="${escaped}"`,
|
||||
);
|
||||
const deadline = now() + timeoutMs;
|
||||
while (now() < deadline) {
|
||||
if (abortSignal?.aborted) return false;
|
||||
try {
|
||||
const content = await readFile(logFilePath);
|
||||
if (pattern.test(content)) return true;
|
||||
} catch {
|
||||
// Log file may not have appeared yet; keep polling.
|
||||
}
|
||||
if (now() >= deadline) break;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, pollIntervalMs);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
abortSignal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const antigravityAgentDef = {
|
||||
id: 'antigravity',
|
||||
name: 'Antigravity',
|
||||
bin: 'agy',
|
||||
versionArgs: ['--version'],
|
||||
fallbackModels: [
|
||||
DEFAULT_MODEL_OPTION,
|
||||
{ id: 'Gemini 3.1 Pro (High)', label: 'Gemini 3.1 Pro (High)' },
|
||||
{ id: 'Gemini 3.1 Pro (Low)', label: 'Gemini 3.1 Pro (Low)' },
|
||||
{ id: 'Gemini 3.5 Flash (High)', label: 'Gemini 3.5 Flash (High)' },
|
||||
{ id: 'Gemini 3.5 Flash (Medium)', label: 'Gemini 3.5 Flash (Medium)' },
|
||||
{ id: 'Gemini 3.5 Flash (Low)', label: 'Gemini 3.5 Flash (Low)' },
|
||||
{
|
||||
id: 'Claude Sonnet 4.6 (Thinking)',
|
||||
label: 'Claude Sonnet 4.6 (Thinking)',
|
||||
},
|
||||
{ id: 'Claude Opus 4.6 (Thinking)', label: 'Claude Opus 4.6 (Thinking)' },
|
||||
{ id: 'GPT-OSS 120B (Medium)', label: 'GPT-OSS 120B (Medium)' },
|
||||
],
|
||||
supportsCustomModel: false,
|
||||
// We deliberately do NOT opt into `resumesSessionViaCli` / agy's `-c`
|
||||
// resume flag on follow-up turns. Tested both shapes; `-c` activates
|
||||
// agy's internal agentic loop (multi-step model retries, tool calls,
|
||||
// fallback-to-cached-response on tool errors) which can't be steered
|
||||
// from OD's system-prompt OVERRIDE — even with the strongest wording
|
||||
// we got an identical byte-for-byte form re-emission on turn 2 when
|
||||
// turn 1's tool-call retry path returned the cached form response.
|
||||
//
|
||||
// Instead we treat agy as a stateless plain adapter like qwen /
|
||||
// deepseek: every spawn gets the full OD-rendered transcript via
|
||||
// `buildDaemonTranscript`, and that transcript's prior assistant
|
||||
// turns are sanitized to strip `<question-form>` markup + form-schema
|
||||
// JSON fences (see `sanitizePriorAssistantTurnForTranscript` in
|
||||
// apps/web/src/providers/daemon.ts). The stronger OVERRIDE block
|
||||
// composed in server.ts gives a second line of defense for weak
|
||||
// plain-stream models like Gemini 3.5 Flash.
|
||||
buildArgs: (
|
||||
_prompt,
|
||||
_imagePaths,
|
||||
_extra = [],
|
||||
options = {},
|
||||
runtimeContext = {},
|
||||
) => {
|
||||
if (options.model && options.model !== DEFAULT_MODEL_OPTION.id) {
|
||||
writeAntigravityModelSelection(
|
||||
options.model,
|
||||
runtimeContext.antigravitySettingsPath,
|
||||
);
|
||||
}
|
||||
// We invoke agy via `-p -` (print mode + stdin sentinel), NOT
|
||||
// `chat -`. Verified against `agy --help` on v1.0.3 — the
|
||||
// `Available subcommands` list is `changelog / help / install /
|
||||
// plugin / update`, and `chat` is NOT among them. `-p` is the
|
||||
// documented print-mode flag (`Short alias for --print`) and
|
||||
// `agy -p -` reads the prompt from stdin. The looper reviewer
|
||||
// bot's environment runs a different agy build that may have
|
||||
// renamed the entry point; until upstream confirms a stable
|
||||
// headless subcommand (see google-antigravity/antigravity-cli#119)
|
||||
// and the change actually ships in the auto-update channel that
|
||||
// packaged OD users get, `-p -` is the contract that actually
|
||||
// produces a print-mode reply on the installed CLI.
|
||||
const args: string[] = ['-p'];
|
||||
// Always opt into `--log-file` when the daemon supplied a path so
|
||||
// it can post-exit grep for the actual upstream failure shape
|
||||
// (auth missing vs quota reached vs upstream error) — without it
|
||||
// the chat surfaces a generic "empty response" because print mode
|
||||
// never echoes those errors on stdout. See server.ts empty-output
|
||||
// guard for the consumer.
|
||||
if (runtimeContext.agentLogFilePath) {
|
||||
args.push('--log-file', runtimeContext.agentLogFilePath);
|
||||
}
|
||||
args.push('-');
|
||||
return args;
|
||||
},
|
||||
promptViaStdin: true,
|
||||
streamFormat: 'plain',
|
||||
installUrl: 'https://antigravity.google/cli',
|
||||
docsUrl: 'https://antigravity.google/docs/cli-overview',
|
||||
} satisfies RuntimeAgentDef;
|
||||
|
|
@ -18,6 +18,7 @@ import { kiloAgentDef } from './defs/kilo.js';
|
|||
import { vibeAgentDef } from './defs/vibe.js';
|
||||
import { deepseekAgentDef } from './defs/deepseek.js';
|
||||
import { aiderAgentDef } from './defs/aider.js';
|
||||
import { antigravityAgentDef } from './defs/antigravity.js';
|
||||
import { reasonixAgentDef } from './defs/reasonix.js';
|
||||
import { readLocalAgentProfileDefs as readLocalAgentProfileDefsFromFile } from './local-profiles.js';
|
||||
import type { RuntimeAgentDef } from './types.js';
|
||||
|
|
@ -43,6 +44,7 @@ const BASE_AGENT_DEFS: RuntimeAgentDef[] = [
|
|||
vibeAgentDef,
|
||||
deepseekAgentDef,
|
||||
aiderAgentDef,
|
||||
antigravityAgentDef,
|
||||
reasonixAgentDef,
|
||||
];
|
||||
|
||||
|
|
|
|||
130
apps/daemon/src/runtimes/terminal-launch.ts
Normal file
130
apps/daemon/src/runtimes/terminal-launch.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { execFile, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Cross-platform spawn helper for "open a system terminal and run this
|
||||
// command in it." Used by the antigravity adapter's `oauth-launch`
|
||||
// endpoint: agy's print mode (`-p`) cannot complete the Google
|
||||
// Sign-In OAuth flow (the upstream callback page asks the user to
|
||||
// paste the auth code back into agy, but `-p` has no input field), so
|
||||
// the user has to run `agy` interactively at least once to populate
|
||||
// the system keyring. Spawning a terminal from inside OD makes that
|
||||
// a one-click action instead of a "go open Terminal yourself" task.
|
||||
//
|
||||
// Each platform branch uses primitives that are safe against shell
|
||||
// injection BECAUSE we never accept user input here — the `command`
|
||||
// argument is always a hard-coded binary name like `agy`. Adding
|
||||
// caller-supplied flags or env vars to this helper would invalidate
|
||||
// that guarantee, so the signature is intentionally narrow.
|
||||
|
||||
export type TerminalLaunchResult =
|
||||
| { ok: true; platform: NodeJS.Platform; via: string }
|
||||
| { ok: false; platform: NodeJS.Platform; reason: string };
|
||||
|
||||
// macOS: AppleScript via osascript. Bringing Terminal.app to the
|
||||
// foreground and creating a new shell that immediately runs the
|
||||
// command is the canonical macOS pattern (same one VS Code uses for
|
||||
// "Open in External Terminal").
|
||||
async function launchOnDarwin(command: string): Promise<TerminalLaunchResult> {
|
||||
// `do script "<cmd>"` opens a new Terminal window and runs <cmd>
|
||||
// in it; activate brings Terminal.app to the foreground so the
|
||||
// user actually sees the new window. Strict double-quote escaping
|
||||
// protects us if `command` ever grows special characters (today
|
||||
// it's just `agy`, so this is belt-and-suspenders).
|
||||
const safe = command.replace(/"/g, '\\"');
|
||||
const script = `tell application "Terminal" to do script "${safe}"\ntell application "Terminal" to activate`;
|
||||
try {
|
||||
await execFileAsync('osascript', ['-e', script], { timeout: 5_000 });
|
||||
return { ok: true, platform: 'darwin', via: 'osascript' };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
platform: 'darwin',
|
||||
reason: `osascript failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Linux: try the Debian/Ubuntu meta-emulator first, then the common
|
||||
// concrete terminals. Each attempt spawns detached so the terminal
|
||||
// window's lifetime is independent from the daemon's process group.
|
||||
// We resolve as soon as the child process starts (not when it exits),
|
||||
// because terminals like xterm and x-terminal-emulator stay alive for
|
||||
// the duration of the interactive session — waiting for exit would time
|
||||
// out and kill the window mid-OAuth-flow.
|
||||
async function launchOnLinux(command: string): Promise<TerminalLaunchResult> {
|
||||
// Order matters: x-terminal-emulator is the Debian alternative that
|
||||
// resolves to whichever terminal the distro chose. Otherwise try the
|
||||
// common ones. Each requires a slightly different invocation syntax
|
||||
// (`-e` vs `--` vs `-x`), captured in this table.
|
||||
const attempts: Array<{ bin: string; args: string[] }> = [
|
||||
{ bin: 'x-terminal-emulator', args: ['-e', command] },
|
||||
{ bin: 'gnome-terminal', args: ['--', 'sh', '-c', `${command}; exec $SHELL`] },
|
||||
{ bin: 'konsole', args: ['-e', command] },
|
||||
{ bin: 'xfce4-terminal', args: ['-e', command] },
|
||||
{ bin: 'xterm', args: ['-e', command] },
|
||||
];
|
||||
const errors: string[] = [];
|
||||
for (const { bin, args } of attempts) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(bin, args, { detached: true, stdio: 'ignore' });
|
||||
child.unref();
|
||||
child.once('spawn', resolve);
|
||||
child.once('error', reject);
|
||||
});
|
||||
return { ok: true, platform: 'linux', via: bin };
|
||||
} catch (err) {
|
||||
errors.push(`${bin}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
platform: 'linux',
|
||||
reason: `no system terminal worked (${errors.join('; ')})`,
|
||||
};
|
||||
}
|
||||
|
||||
// Windows: `cmd /c start "<title>" cmd /k "<command>"` — the outer
|
||||
// `start` opens a new console window (the first quoted "Open Design"
|
||||
// is the window title, required by `start`'s positional-arg parser
|
||||
// when the next token is also quoted), and the inner `cmd /k` keeps
|
||||
// the window open after the command finishes so the user can see
|
||||
// OAuth output and finish the flow before the window closes.
|
||||
async function launchOnWindows(command: string): Promise<TerminalLaunchResult> {
|
||||
try {
|
||||
await execFileAsync(
|
||||
'cmd.exe',
|
||||
['/c', 'start', 'Open Design', 'cmd.exe', '/k', command],
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
return { ok: true, platform: 'win32', via: 'cmd /c start' };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
platform: 'win32',
|
||||
reason: `cmd /c start failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function launchAgentInSystemTerminal(
|
||||
command: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<TerminalLaunchResult> {
|
||||
switch (platform) {
|
||||
case 'darwin':
|
||||
return launchOnDarwin(command);
|
||||
case 'linux':
|
||||
return launchOnLinux(command);
|
||||
case 'win32':
|
||||
return launchOnWindows(command);
|
||||
default:
|
||||
return {
|
||||
ok: false,
|
||||
platform,
|
||||
reason: `system-terminal launch is not supported on ${platform}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -18,8 +18,45 @@ export type RuntimeBuildOptions = {
|
|||
|
||||
export type RuntimeContext = {
|
||||
cwd?: string;
|
||||
// True when the current chat run has at least one prior persisted
|
||||
// assistant message in the same conversation — i.e. this isn't the
|
||||
// first user turn. Plain-streaming adapters that support a "continue
|
||||
// the most recent conversation" CLI flag (e.g. `agy -c`) read this to
|
||||
// decide whether to resume the upstream agent's own session state
|
||||
// instead of spawning a fresh, context-free turn. Adapters that
|
||||
// either have no resume flag or recompose history into the prompt
|
||||
// themselves ignore this field.
|
||||
hasPriorAssistantTurn?: boolean;
|
||||
// Daemon-owned path to a temp file where the adapter should write
|
||||
// its diagnostic log. Today only antigravity consumes this: agy in
|
||||
// print mode is silent on stdout/stderr for both missing-auth AND
|
||||
// quota-exhausted failures (verified via `agy --log-file` capture
|
||||
// during PR #3157), so post-exit log inspection is the only way to
|
||||
// tell them apart. Adapters that don't have a `--log-file` flag
|
||||
// ignore this field; the daemon cleans the file up after reading.
|
||||
agentLogFilePath?: string;
|
||||
// Override for the antigravity model-selection settings file path.
|
||||
// Production code leaves this undefined (falls back to the default
|
||||
// ~/.gemini/antigravity-cli/settings.json). Tests pass a temp path
|
||||
// so unit assertions against buildArgs do not touch the real home dir.
|
||||
antigravitySettingsPath?: string;
|
||||
};
|
||||
|
||||
// Marker on a RuntimeAgentDef declaring that the adapter's CLI maintains
|
||||
// its own multi-turn conversation memory and the daemon should NOT also
|
||||
// pack the rendered web transcript (the `## user` / `## assistant` blocks
|
||||
// `buildDaemonTranscript` produces) into the user request. Today only
|
||||
// `agy -c` qualifies; other plain-stream adapters have no upstream
|
||||
// session storage and still rely on the daemon-side transcript injection
|
||||
// for multi-turn coherence.
|
||||
//
|
||||
// Without this opt-out, agy with `-c` receives the same prior turn
|
||||
// twice — once from its own conversation memory, once embedded in the
|
||||
// composed user request — and the embedded copy includes the literal
|
||||
// `<question-form>` markup it emitted on turn 1. The model then
|
||||
// pattern-matches that and re-emits the form on turn 2, looking like
|
||||
// the discovery loop never breaks.
|
||||
|
||||
export type RuntimeCapabilityMap = Record<string, boolean>;
|
||||
|
||||
export type RuntimeListModels = {
|
||||
|
|
@ -101,6 +138,21 @@ export type RuntimeAgentDef = {
|
|||
| 'opencode-env-content';
|
||||
installUrl?: string;
|
||||
docsUrl?: string;
|
||||
// When `false`, the Settings model picker hides the "Custom (fill below)"
|
||||
// option and the associated free-text input. Use this for agents whose
|
||||
// CLI does not actually accept a model id (e.g. `agy` v1.0.3 has no
|
||||
// `--model` flag yet — upstream issue #35 — and the model is chosen
|
||||
// server-side; AMR routes model selection through ACP's
|
||||
// `session/set_model` and rejects free-form ids). Defaults to allowing
|
||||
// custom input (undefined === true) so most adapters keep today's UX.
|
||||
supportsCustomModel?: boolean;
|
||||
// When `true`, the daemon trusts this adapter's CLI to carry its own
|
||||
// multi-turn conversation memory across spawn invocations (today only
|
||||
// `agy -c`). The chat composer skips the rendered web transcript on
|
||||
// follow-up turns and sends just the latest user message — see the
|
||||
// RuntimeContext.hasPriorAssistantTurn comment for why double-context
|
||||
// is the discovery-form loop's root cause.
|
||||
resumesSessionViaCli?: boolean;
|
||||
// Optional name of a daemon-process environment variable that overrides
|
||||
// the default model id when the chat run reaches the spawn layer with
|
||||
// null or the synthetic 'default'. Used by adapters whose CLI rejects
|
||||
|
|
|
|||
|
|
@ -214,6 +214,8 @@ import { narrowProjectCritiqueOverride } from './critique/spawn-inputs.js';
|
|||
import { createCopilotStreamHandler } from './copilot-stream.js';
|
||||
import { createJsonEventStreamHandler } from './json-event-stream.js';
|
||||
import {
|
||||
antigravityAuthGuidance,
|
||||
antigravityQuotaGuidance,
|
||||
classifyAgentAuthFailure,
|
||||
classifyAgentServiceFailure,
|
||||
cursorAuthGuidance,
|
||||
|
|
@ -2408,6 +2410,52 @@ export function telemetryPromptFromRunRequest(message, currentPrompt) {
|
|||
|
||||
const FORM_ANSWERS_HEADER_RE = /^\s*\[form answers\s+(?:\u2014|-)\s*([^\]\r\n]+)\]/i;
|
||||
|
||||
// Aggressive OVERRIDE for weak / medium-strength plain agents (e.g.
|
||||
// GPT-OSS-120B Medium, Gemini 3.5 Flash) that otherwise echo RULE 1's
|
||||
// fenced form example back at the user on follow-up turns even when
|
||||
// they correctly understand the form is answered. Strong models
|
||||
// (Claude Sonnet 4.6, Gemini 3.1 Pro) already handle a shorter
|
||||
// OVERRIDE; enumerating the anti-patterns is a no-op for them and a
|
||||
// strong suppressor for the weaker ones. RULE 1 itself stays in the
|
||||
// system prompt so turn 1 can still emit a valid form.
|
||||
//
|
||||
// Exported so tests pin both the trigger condition and the literal
|
||||
// anti-patterns we ask the model to skip \u2014 silently weakening the
|
||||
// list (e.g. dropping the markdown-fence ban) would reintroduce the
|
||||
// form-echo regression on GPT-OSS / Gemini Flash.
|
||||
export const FORM_ANSWERED_SYSTEM_OVERRIDE = `## OVERRIDE \u2014 form already answered (this is turn 2 or later)
|
||||
|
||||
The user already submitted their form answers (see # User request below).
|
||||
RULE 1 documents the turn-1 ask flow; that flow is finished. Treat RULE 1
|
||||
as read-only documentation for this turn \u2014 do not execute any of it.
|
||||
|
||||
Forbidden output for this turn:
|
||||
- A \`<question-form>\` tag of any id, including \`discovery\` or \`task-type\`.
|
||||
- A markdown \`\`\`json fenced block echoing the form schema or example.
|
||||
- Form-asking prose such as "Got it \u2014 tell me the following" or
|
||||
"\u8bf7\u544a\u8bc9\u6211\u4ee5\u4e0b\u4fe1\u606f".
|
||||
- Narrating fake system events such as "subagents stopped" or
|
||||
"server restart".
|
||||
|
||||
Required output for this turn:
|
||||
- Open with a brief prose confirmation of what the brief is.
|
||||
- Then proceed to RULE 2 (branch on the submitted \`brand\` value) and
|
||||
RULE 3 (emit the \`<artifact>\` block with the full HTML document).
|
||||
|
||||
`;
|
||||
|
||||
// Smaller override for non-discovery / non-task-type form ids. These
|
||||
// forms are not artifact-build transitions, so we only need to suppress
|
||||
// the form re-ask without directing the model toward RULE 2 / RULE 3.
|
||||
// Exported so tests can pin the literal content independently.
|
||||
export const FORM_ANSWERED_GENERIC_OVERRIDE = `## OVERRIDE \u2014 form already answered (this is turn 2 or later)
|
||||
|
||||
The user already submitted their form answers (see # User request below).
|
||||
Do not ask the same form again. Treat the submitted answers as the active
|
||||
user instruction and respond accordingly.
|
||||
|
||||
`;
|
||||
|
||||
function formAnswerTransitionForCurrentPrompt(currentPrompt) {
|
||||
if (typeof currentPrompt !== 'string') return null;
|
||||
const trimmed = currentPrompt.trim();
|
||||
|
|
@ -2420,9 +2468,16 @@ function formAnswerTransitionForCurrentPrompt(currentPrompt) {
|
|||
'## Latest user turn - form answers submitted',
|
||||
trimmed,
|
||||
'',
|
||||
// Keep the wording in lock-step with main — the stronger "do not
|
||||
// emit any `<question-form>`" suppression now lives in the
|
||||
// system-prompt `FORM_ANSWERED_SYSTEM_OVERRIDE` block, which
|
||||
// every plain / stream-json adapter sees. Diverging the
|
||||
// user-request transition string here breaks `chat-route.test
|
||||
// marks submitted discovery form answers ...` which asserts on
|
||||
// the exact main wording.
|
||||
`The user has answered the ${formId} form. Do not emit another ${formId} form.`,
|
||||
];
|
||||
if (formId.toLowerCase() === 'discovery') {
|
||||
if (formId.toLowerCase() === 'discovery' || formId.toLowerCase() === 'task-type') {
|
||||
lines.push(
|
||||
'Continue with RULE 2 / RULE 3 now. For Branch B answers, build now instead of asking another brief.',
|
||||
);
|
||||
|
|
@ -2434,13 +2489,30 @@ function formAnswerTransitionForCurrentPrompt(currentPrompt) {
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function composeChatUserRequestForAgent(message, currentPrompt) {
|
||||
export function composeChatUserRequestForAgent(
|
||||
message,
|
||||
currentPrompt,
|
||||
options: { skipTranscript?: boolean } = {},
|
||||
) {
|
||||
// When the adapter resumes its own session (today: `agy -c`), the
|
||||
// daemon-rendered `## user` / `## assistant` transcript is a duplicate
|
||||
// of what the upstream CLI already has in memory — and the embedded
|
||||
// copy carries the literal `<question-form>` markup the agent emitted
|
||||
// on turn 1, which the model then re-emits on turn 2. Send only the
|
||||
// latest user turn (`currentPrompt`) in that case; the upstream
|
||||
// session memory provides the rest. See
|
||||
// `RuntimeAgentDef.resumesSessionViaCli`.
|
||||
const skip = options.skipTranscript === true;
|
||||
const bodySource = skip ? currentPrompt : message;
|
||||
const body =
|
||||
typeof message === 'string' && message.trim()
|
||||
? message
|
||||
typeof bodySource === 'string' && bodySource.trim()
|
||||
? bodySource
|
||||
: '(No extra typed instruction.)';
|
||||
const transition = formAnswerTransitionForCurrentPrompt(currentPrompt);
|
||||
if (!transition) return body;
|
||||
if (skip) {
|
||||
return [transition, body].join('\n\n');
|
||||
}
|
||||
return [
|
||||
transition,
|
||||
'## Full conversation transcript',
|
||||
|
|
@ -4291,6 +4363,42 @@ export async function startServer({
|
|||
// with shell access to the daemon machine should be the only
|
||||
// one allowed to invoke. Returns the pre-purge stats so the
|
||||
// caller can confirm what they discarded.
|
||||
// PR #3157: surface the Antigravity OAuth flow as a one-click action
|
||||
// in the chat's AGENT_AUTH_REQUIRED banner. agy's `-p` print mode
|
||||
// can't complete the Google Sign-In flow on its own (no input field
|
||||
// for the auth code), so OD opens a system Terminal running `agy`
|
||||
// for the user; they finish OAuth there, then retry the chat. The
|
||||
// endpoint is loopback-gated and only supports antigravity because
|
||||
// (a) we hardcode `agy` as the command, and (b) opening a new
|
||||
// Terminal window is a visible side effect we don't want anyone
|
||||
// hand-rolling for every agent that ships a CLI.
|
||||
app.post('/api/agents/:agentId/oauth-launch', requireLocalDaemonRequest, async (req, res) => {
|
||||
const agentId = req.params.agentId;
|
||||
if (agentId !== 'antigravity') {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: `oauth-launch is only supported for antigravity, got ${agentId}`,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const { launchAgentInSystemTerminal } = await import('./runtimes/terminal-launch.js');
|
||||
const result = await launchAgentInSystemTerminal('agy');
|
||||
if (result.ok) {
|
||||
return res.json({ ok: true, platform: result.platform, via: result.via });
|
||||
}
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
platform: result.platform,
|
||||
error: result.reason,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/plugins/events/purge', requireLocalDaemonRequest, async (_req, res) => {
|
||||
try {
|
||||
const { purgePluginEventBuffer } = await import('./plugins/events.js');
|
||||
|
|
@ -10798,6 +10906,7 @@ export async function startServer({
|
|||
const userRequestPrompt = composeChatUserRequestForAgent(
|
||||
message,
|
||||
currentPrompt,
|
||||
{ skipTranscript: def.resumesSessionViaCli === true },
|
||||
);
|
||||
const clientInstructionPrompt = [researchCommandContract, runContextPrompt, systemPrompt]
|
||||
.map((part) => (typeof part === 'string' ? part.trim() : ''))
|
||||
|
|
@ -10818,6 +10927,18 @@ export async function startServer({
|
|||
// instructions and request) — see server.ts:9920 composer notes.
|
||||
const ECHO_GUARD =
|
||||
'\n\n(Do not quote, restate, or echo the # Instructions block above in your reply. Begin your response with the answer to the # User request below.)';
|
||||
const formAnswerMatch = FORM_ANSWERS_HEADER_RE.exec(
|
||||
typeof currentPrompt === 'string' ? currentPrompt : '',
|
||||
);
|
||||
const formIdForOverride = formAnswerMatch
|
||||
? ((formAnswerMatch[1] || 'form').trim().replace(/[^\w.-]/g, '') || 'form').toLowerCase()
|
||||
: null;
|
||||
const formOverride =
|
||||
formIdForOverride === 'discovery' || formIdForOverride === 'task-type'
|
||||
? FORM_ANSWERED_SYSTEM_OVERRIDE
|
||||
: formIdForOverride !== null
|
||||
? FORM_ANSWERED_GENERIC_OVERRIDE
|
||||
: '';
|
||||
const promptImagePaths = selectPromptImagePaths(
|
||||
def.id,
|
||||
safeImages,
|
||||
|
|
@ -10825,12 +10946,14 @@ export async function startServer({
|
|||
);
|
||||
const composed = [
|
||||
instructionPrompt
|
||||
? `# Instructions (read first)\n\n${instructionPrompt}${cwdHint}${linkedDirsHint}${ECHO_GUARD}\n\n---\n`
|
||||
? `# Instructions (read first)\n\n${formOverride}${instructionPrompt}${cwdHint}${linkedDirsHint}${ECHO_GUARD}\n\n---\n`
|
||||
: cwdHint
|
||||
? `# Instructions${cwdHint}${linkedDirsHint}${ECHO_GUARD}\n\n---\n`
|
||||
? `# Instructions\n\n${formOverride}${cwdHint}${linkedDirsHint}${ECHO_GUARD}\n\n---\n`
|
||||
: linkedDirsHint
|
||||
? `# Instructions${linkedDirsHint}${ECHO_GUARD}\n\n---\n`
|
||||
: '',
|
||||
? `# Instructions\n\n${formOverride}${linkedDirsHint}${ECHO_GUARD}\n\n---\n`
|
||||
: formOverride
|
||||
? `# Instructions\n\n${formOverride}${ECHO_GUARD}\n\n---\n`
|
||||
: '',
|
||||
`# User request\n\n${userRequestPrompt}${attachmentHint}${commentHint}`,
|
||||
promptImagePaths.length
|
||||
? `\n\n${promptImagePaths.map((p) => `@${p}`).join(' ')}`
|
||||
|
|
@ -11109,12 +11232,69 @@ export async function startServer({
|
|||
}
|
||||
}
|
||||
|
||||
// Plain-streaming adapters that own a "continue most recent
|
||||
// conversation" CLI flag (today: only `agy -c`) read this signal
|
||||
// to resume upstream session state on follow-up turns. The query
|
||||
// matches any persisted assistant message in the same conversation
|
||||
// EXCEPT the placeholder row this run just inserted (it's still
|
||||
// `pending` and has no body — counting it as prior would always
|
||||
// force `-c` on the very first turn). Adapters that don't consume
|
||||
// this field ignore it.
|
||||
const hasPriorAssistantTurn = run.conversationId
|
||||
? Boolean(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT 1 FROM messages
|
||||
WHERE conversation_id = ?
|
||||
AND role = 'assistant'
|
||||
AND COALESCE(content, '') <> ''
|
||||
AND id <> COALESCE(?, '')
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(run.conversationId, run.assistantMessageId ?? ''),
|
||||
)
|
||||
: false;
|
||||
|
||||
// Antigravity's `agy` is silent on stdout/stderr in print mode for
|
||||
// both auth-missing and quota-exhausted failures — the actual
|
||||
// RESOURCE_EXHAUSTED / "not logged in" payload only surfaces in
|
||||
// its `--log-file`. We allocate a per-run temp path, pipe agy's
|
||||
// log to it via buildArgs, then read it in the empty-output guard
|
||||
// to disambiguate the silent-failure cause. Other adapters ignore
|
||||
// this field.
|
||||
const agentLogFilePath =
|
||||
def.id === 'antigravity'
|
||||
? path.join(os.tmpdir(), `od-agy-${run.id}.log`)
|
||||
: undefined;
|
||||
|
||||
// Serialize antigravity spawns whose buildArgs writes a concrete
|
||||
// model into settings.json. Two concurrent runs with different
|
||||
// models would otherwise race the file: A writes model A, B writes
|
||||
// model B, then A's agy reads model B. The lock is acquired BEFORE
|
||||
// buildArgs (which performs the write) and released asynchronously
|
||||
// AFTER agy's --log-file confirms the model was propagated. See
|
||||
// `antigravity.ts` for the chain implementation.
|
||||
let antigravityModelLockRelease: (() => void) | null = null;
|
||||
const antigravityConcreteModel =
|
||||
def.id === 'antigravity'
|
||||
&& typeof agentOptions.model === 'string'
|
||||
&& agentOptions.model.length > 0
|
||||
&& agentOptions.model !== 'default'
|
||||
? agentOptions.model
|
||||
: null;
|
||||
if (antigravityConcreteModel) {
|
||||
const { acquireAntigravityModelLock } = await import(
|
||||
'./runtimes/defs/antigravity.js'
|
||||
);
|
||||
antigravityModelLockRelease = await acquireAntigravityModelLock();
|
||||
}
|
||||
|
||||
const args = def.buildArgs(
|
||||
composed,
|
||||
safeImages,
|
||||
extraAllowedDirs,
|
||||
agentOptions,
|
||||
{ cwd: effectiveCwd },
|
||||
{ cwd: effectiveCwd, hasPriorAssistantTurn, agentLogFilePath },
|
||||
);
|
||||
|
||||
// Second-pass budget check that knows about the Windows `.cmd` shim
|
||||
|
|
@ -11429,6 +11609,56 @@ export async function startServer({
|
|||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
});
|
||||
run.child = child;
|
||||
// Schedule release of the antigravity model lock once agy's
|
||||
// --log-file confirms the chosen model was propagated to the
|
||||
// backend (the upstream signal that settings.json was read).
|
||||
// The watcher's `false` return (timeout) deliberately does NOT
|
||||
// release — looper review at 263fd2fe7 flagged that releasing
|
||||
// on timeout reopens the slow-cold-start race: a >15s agy
|
||||
// startup that hadn't yet read settings.json would let run B
|
||||
// rewrite the file and run A would then read run B's model.
|
||||
// The exit handler is the canonical fallback that releases the
|
||||
// lock no matter what (crashed agy, fast exit, etc.) so the
|
||||
// queue can never starve permanently.
|
||||
if (
|
||||
antigravityModelLockRelease
|
||||
&& antigravityConcreteModel
|
||||
&& agentLogFilePath
|
||||
) {
|
||||
const releaseOnce = (() => {
|
||||
let fired = false;
|
||||
return () => {
|
||||
if (fired) return;
|
||||
fired = true;
|
||||
antigravityModelLockRelease?.();
|
||||
};
|
||||
})();
|
||||
const watcherAbort = new AbortController();
|
||||
const { waitForAgyToReadModel } = await import(
|
||||
'./runtimes/defs/antigravity.js'
|
||||
);
|
||||
void waitForAgyToReadModel(
|
||||
agentLogFilePath,
|
||||
antigravityConcreteModel,
|
||||
{ abortSignal: watcherAbort.signal },
|
||||
)
|
||||
.then((found) => {
|
||||
// Only release on TRUE confirmation; a `false` return means
|
||||
// the watcher ran out of its polling window without seeing
|
||||
// the propagation line. We hold the lock until child exit
|
||||
// so a slow-cold-start agy can't be pre-empted by a
|
||||
// concurrent settings.json rewrite from run B.
|
||||
if (found) releaseOnce();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
child.once('exit', () => {
|
||||
// Stop the watcher so its pending readFile / setTimeout
|
||||
// chain does not outlive the run and leak into subsequent
|
||||
// antigravity spawns (or test cases).
|
||||
watcherAbort.abort();
|
||||
releaseOnce();
|
||||
});
|
||||
}
|
||||
if (def.promptViaStdin && child.stdin && def.streamFormat !== 'pi-rpc') {
|
||||
// EPIPE from a fast-exiting CLI (bad auth, missing model, exit on
|
||||
// launch) would otherwise surface as an unhandled stream error and
|
||||
|
|
@ -11678,6 +11908,12 @@ export async function startServer({
|
|||
// plain streams (most other CLIs) we forward raw chunks unchanged so
|
||||
// the browser can append them to the assistant's text buffer.
|
||||
let agentStreamError = null;
|
||||
// Holds buffered plain-text stdout chunks for agents (currently
|
||||
// antigravity) where we need to inspect the full output at close
|
||||
// time before deciding whether to forward it. The auth-prompt guard
|
||||
// in the close handler suppresses the buffer when the output is an
|
||||
// OAuth prompt; otherwise the flush below sends the chunks in order.
|
||||
const plaintextStdoutBuffer: string[] = [];
|
||||
// Tracks whether any stream the run is using actually emitted user-
|
||||
// visible content. Only the streams routed through `sendAgentEvent`
|
||||
// contribute to this flag; ACP sessions and plain stdout streams are
|
||||
|
|
@ -11898,6 +12134,16 @@ export async function startServer({
|
|||
);
|
||||
child.stdout.on('data', (chunk) => handler.feed(chunk));
|
||||
child.on('close', () => handler.flush());
|
||||
} else if (def.id === 'antigravity') {
|
||||
// Buffer stdout until close so the auth-prompt guard can suppress
|
||||
// the OAuth URL before forwarding it to the client as assistant
|
||||
// text. agy exits 0 after printing the auth URL on stdout, so the
|
||||
// chunks would otherwise arrive before the close-time classifier
|
||||
// detects them as an auth prompt.
|
||||
child.stdout.on('data', (chunk) => {
|
||||
noteAgentActivity();
|
||||
plaintextStdoutBuffer.push(String(chunk));
|
||||
});
|
||||
} else {
|
||||
child.stdout.on('data', (chunk) => {
|
||||
noteAgentActivity();
|
||||
|
|
@ -11921,6 +12167,7 @@ export async function startServer({
|
|||
design.runs.finish(run, 'failed', 1, null);
|
||||
});
|
||||
child.on('close', async (code, signal) => {
|
||||
try {
|
||||
clearInactivityWatchdog();
|
||||
revokeToolToken('child_exit');
|
||||
unregisterChatAgentEventSink();
|
||||
|
|
@ -11956,15 +12203,9 @@ export async function startServer({
|
|||
return design.runs.finish(run, 'failed', code ?? 1, signal ?? null);
|
||||
}
|
||||
}
|
||||
// Empty-output guard: a clean `code === 0` exit on a stream we are
|
||||
// tracking, with no error frame and no substantive event, means the
|
||||
// run silently finished without producing anything visible. That used
|
||||
// to be marked `succeeded` and rendered as an empty assistant turn —
|
||||
// see issue #691, where OpenCode runs were ending in ~3s with no
|
||||
// chat content and no error banner. Surface an explicit failure
|
||||
// instead so the chat shows a clear reason. ACP sessions and plain
|
||||
// stdout streams are gated out via `trackingSubstantiveOutput`;
|
||||
// their success/failure determination lives elsewhere.
|
||||
// Empty-output guard: a clean `code === 0` exit with no visible
|
||||
// output means the run silently finished without producing anything.
|
||||
// Surface an explicit failure so the chat shows a clear reason.
|
||||
if (
|
||||
code === 0 &&
|
||||
!run.cancelRequested &&
|
||||
|
|
@ -11991,6 +12232,96 @@ export async function startServer({
|
|||
));
|
||||
return design.runs.finish(run, 'failed', code, signal);
|
||||
}
|
||||
// Plain-stream auth-failure guard: plain adapters (today
|
||||
// antigravity, deepseek's TUI variants) may exit cleanly with
|
||||
// visible stdout that's actually an auth prompt — agy prints
|
||||
// "Authentication required. Please visit the URL to log in:
|
||||
// <URL>" + "Error: authentication timed out." rather than
|
||||
// failing with a non-zero exit. Without this guard the chat
|
||||
// shows that raw prompt as the agent's "reply", and the user
|
||||
// has no way to actually complete OAuth from inside the chat.
|
||||
// Override the apparent success with a proper
|
||||
// AGENT_AUTH_REQUIRED error carrying actionable guidance.
|
||||
if (
|
||||
code === 0 &&
|
||||
!run.cancelRequested &&
|
||||
!trackingSubstantiveOutput &&
|
||||
childStdoutSeen
|
||||
) {
|
||||
const authFailure = classifyAgentAuthFailure(
|
||||
agentId,
|
||||
`${agentStderrTail}\n${agentStdoutTail}`,
|
||||
);
|
||||
if (authFailure?.status === 'missing') {
|
||||
send('error', createSseErrorPayload(
|
||||
'AGENT_AUTH_REQUIRED',
|
||||
authFailure.message ?? `${def.name} authentication required. Please re-authenticate and retry.`,
|
||||
{ retryable: true },
|
||||
));
|
||||
return design.runs.finish(run, 'failed', 0, signal);
|
||||
}
|
||||
}
|
||||
// Plain-stream empty-output guard: plain agents send raw stdout
|
||||
// chunks without structured event tracking. Detect auth failures
|
||||
// and quota / upstream errors when exit 0 but no stdout was
|
||||
// seen. agy in print mode is silent on stdout/stderr for both
|
||||
// missing-auth AND quota-exhausted failures; the daemon piped
|
||||
// agy's `--log-file` to `agentLogFilePath` precisely so this
|
||||
// guard can grep the upstream error code (RESOURCE_EXHAUSTED 429
|
||||
// for quota, "not logged into Antigravity" for auth) and route
|
||||
// to the right user-facing guidance.
|
||||
if (
|
||||
code === 0 &&
|
||||
!run.cancelRequested &&
|
||||
!trackingSubstantiveOutput &&
|
||||
!childStdoutSeen
|
||||
) {
|
||||
let combinedDetail = `${agentStderrTail}\n${agentStdoutTail}`;
|
||||
if (def.id === 'antigravity' && agentLogFilePath) {
|
||||
try {
|
||||
const logContent = await fs.promises.readFile(agentLogFilePath, 'utf8');
|
||||
// Keep the last 8 KB — quota / auth lines all land near the
|
||||
// tail (after the spawn / model-config preamble).
|
||||
combinedDetail = `${combinedDetail}\n${logContent.slice(-8192)}`;
|
||||
} catch {
|
||||
// Missing log file (agy didn't write it, mounted tmpfs is
|
||||
// read-only, etc.) is fine — fall through to the generic
|
||||
// empty-output message.
|
||||
}
|
||||
}
|
||||
const authFailure = classifyAgentAuthFailure(agentId, combinedDetail);
|
||||
const serviceFailure = !authFailure
|
||||
? classifyAgentServiceFailure(combinedDetail)
|
||||
: null;
|
||||
const isAntigravityQuota =
|
||||
def.id === 'antigravity' && serviceFailure === 'RATE_LIMITED';
|
||||
// Antigravity-only fallback: if neither classifier matched but
|
||||
// the run was silent, lean on the empirical observation that
|
||||
// an empty agy print-mode exit almost always means
|
||||
// missing-OAuth (the only other silent path is quota, which
|
||||
// the log-file check above already caught).
|
||||
const useAntigravityAuthFallback =
|
||||
!authFailure && !serviceFailure && def.id === 'antigravity';
|
||||
const errorCode =
|
||||
authFailure || useAntigravityAuthFallback
|
||||
? 'AGENT_AUTH_REQUIRED'
|
||||
: isAntigravityQuota
|
||||
? 'RATE_LIMITED'
|
||||
: 'AGENT_EXECUTION_FAILED';
|
||||
const msg = authFailure
|
||||
? authFailure.message ?? `${def.name} authentication expired. Please re-authenticate and retry.`
|
||||
: isAntigravityQuota
|
||||
? antigravityQuotaGuidance()
|
||||
: useAntigravityAuthFallback
|
||||
? antigravityAuthGuidance()
|
||||
: `${def.name} returned an empty response. This may indicate an expired session — try re-authenticating the agent.`;
|
||||
send('error', createSseErrorPayload(
|
||||
errorCode,
|
||||
msg,
|
||||
{ retryable: true },
|
||||
));
|
||||
return design.runs.finish(run, 'failed', 0, signal);
|
||||
}
|
||||
// ACP agents that don't shut down on stdin.end() (e.g. Devin for
|
||||
// Terminal) are forced to exit via SIGTERM from attachAcpSession after
|
||||
// a clean prompt completion. Without an override, the chat run would
|
||||
|
|
@ -12078,7 +12409,24 @@ export async function startServer({
|
|||
} catch { /* project-level best-effort */ }
|
||||
})();
|
||||
}
|
||||
// Flush buffered plain-text stdout (antigravity) that was not
|
||||
// suppressed by the auth-prompt guard above. Send each chunk in
|
||||
// order before finishing so the assistant text arrives before the
|
||||
// run's `finished` event.
|
||||
for (const chunk of plaintextStdoutBuffer) {
|
||||
send('stdout', { chunk });
|
||||
}
|
||||
design.runs.finish(run, status, code, signal);
|
||||
} finally {
|
||||
// Best-effort cleanup of the per-run agy log file on every close
|
||||
// path — successful, failed, cancelled, or non-zero exit — so
|
||||
// /tmp doesn't accumulate one file per Antigravity run. The log
|
||||
// is read inside the empty-output guard above before this finally
|
||||
// runs, so the read always happens before the unlink.
|
||||
if (agentLogFilePath) {
|
||||
fs.promises.unlink(agentLogFilePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
if (writePromptToChildStdin && child.stdin) {
|
||||
const promptInputFormat = def.promptInputFormat ?? 'text';
|
||||
|
|
|
|||
|
|
@ -1277,6 +1277,50 @@ process.exit(1);
|
|||
);
|
||||
});
|
||||
|
||||
it('suppresses Antigravity auth stdout and emits AGENT_AUTH_REQUIRED without an event: stdout delta', async () => {
|
||||
await withFakeAgent(
|
||||
'agy',
|
||||
`
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === '--version') {
|
||||
console.log('1.107.0-test');
|
||||
process.exit(0);
|
||||
}
|
||||
// Simulate agy chat - printing the OAuth prompt and exiting 0
|
||||
process.stdout.write('Authentication required. Please visit the URL to log in: https://accounts.google.com/o/oauth2/auth?client_id=12345&redirect_uri=antigravity-redirect\\n');
|
||||
process.stdout.write('Waiting for authentication (timeout 30s)...\\n');
|
||||
process.stdout.write('Error: authentication timed out.\\n');
|
||||
process.exit(0);
|
||||
`,
|
||||
async () => {
|
||||
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'antigravity',
|
||||
message: 'hello',
|
||||
}),
|
||||
});
|
||||
expect(createResponse.status).toBe(202);
|
||||
const { runId } = await createResponse.json() as { runId: string };
|
||||
|
||||
const eventsController = new AbortController();
|
||||
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
||||
signal: eventsController.signal,
|
||||
});
|
||||
const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
|
||||
eventsController.abort();
|
||||
const statusBody = await waitForRunStatus(baseUrl, runId);
|
||||
|
||||
expect(eventsBody).toContain('event: error');
|
||||
expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
|
||||
expect(eventsBody).not.toContain('event: stdout');
|
||||
expect(eventsBody).not.toContain('accounts.google.com');
|
||||
expect(statusBody.status).toBe('failed');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces Qoder assistant error records through the SSE error channel', async () => {
|
||||
const qoderErrorLine = JSON.stringify({
|
||||
type: 'assistant',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { test } from 'vitest';
|
||||
import {
|
||||
aider, assert, claude, codex, copilot, cursorAgent, deepseek, devin, detectAgents, gemini, join, kilo, kiro, mkdtempSync, opencode, pi, qoder, qwen, rmSync, spawnEnvForAgent, tmpdir, vibe, writeFileSync, chmodSync,
|
||||
AGENT_DEFS, aider, antigravity, assert, claude, codex, copilot, cursorAgent, deepseek, devin, detectAgents, gemini, join, kilo, kiro, mkdtempSync, opencode, pi, qoder, qwen, rmSync, spawnEnvForAgent, tmpdir, vibe, writeFileSync, chmodSync,
|
||||
} from './helpers/test-helpers.js';
|
||||
import { writeAntigravityModelSelection } from '../../src/runtimes/defs/antigravity.js';
|
||||
import type { TestAgentDef } from './helpers/test-helpers.js';
|
||||
|
||||
test('cursor-agent args deliver prompts via stdin without passing a literal dash prompt', () => {
|
||||
|
|
@ -450,6 +452,159 @@ test('qwen args check promptViaStdin, base args, model args and exclude `-` sent
|
|||
assert.equal(withModel.includes('-'), false);
|
||||
});
|
||||
|
||||
// `agy` exposes `-p` (print mode, alias for `--print`) plus `-` as
|
||||
// the stdin sentinel — confirmed against `agy --help` on v1.0.3, where
|
||||
// `Available subcommands` is `changelog / help / install / plugin /
|
||||
// update` (no `chat`). Earlier review iterations pinned `['chat', '-']`
|
||||
// based on a different agy build the looper reviewer environment uses;
|
||||
// the installed CLI does not recognise it, exits 0 with no stdout, and
|
||||
// the daemon would render the resulting empty reply as a "successful"
|
||||
// agent response — exactly the failure mode the auth/quota guard at
|
||||
// server.ts ~12090 is meant to catch but for the wrong reason.
|
||||
test('antigravity pipes prompt via stdin via -p flag (print mode)', () => {
|
||||
assert.equal(antigravity.bin, 'agy');
|
||||
assert.equal(antigravity.streamFormat, 'plain');
|
||||
assert.equal(antigravity.promptViaStdin, true);
|
||||
|
||||
const args = antigravity.buildArgs('write hello world', [], [], {}, {});
|
||||
assert.deepEqual(args, ['-p', '-']);
|
||||
|
||||
// No `--model` flag exists upstream, so buildArgs argv must stay the
|
||||
// same regardless of which label the user picks.
|
||||
// Pass a temp antigravitySettingsPath so buildArgs does not touch the
|
||||
// real ~/.gemini/antigravity-cli/settings.json during a unit test run.
|
||||
const settingsDir = mkdtempSync(join(tmpdir(), 'od-agy-argv-'));
|
||||
try {
|
||||
const withModel = antigravity.buildArgs('hi', [], [], {
|
||||
model: 'Gemini 3.1 Pro (High)',
|
||||
}, { antigravitySettingsPath: join(settingsDir, 'settings.json') });
|
||||
assert.equal(withModel.includes('--model'), false);
|
||||
assert.deepEqual(withModel, ['-p', '-']);
|
||||
} finally {
|
||||
rmSync(settingsDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Argv must NOT carry `-c` even on follow-up turns. We tested resume
|
||||
// mode and found agy's `-c` activates an internal agentic loop (tool
|
||||
// calls, retries, fallback-to-cached-response) that overrides OD's
|
||||
// system-prompt OVERRIDE — producing byte-identical form re-emissions
|
||||
// on turn 2. The stateless path + sanitized transcript injection is
|
||||
// what actually breaks the discovery loop. Pin both shapes so a
|
||||
// future contributor doesn't silently reintroduce `-c` and hit the
|
||||
// same regression.
|
||||
const followUp = antigravity.buildArgs('next message', [], [], {}, {
|
||||
hasPriorAssistantTurn: true,
|
||||
});
|
||||
assert.deepEqual(followUp, ['-p', '-']);
|
||||
assert.equal(followUp.includes('-c'), false);
|
||||
|
||||
const firstTurn = antigravity.buildArgs('first', [], [], {}, {
|
||||
hasPriorAssistantTurn: false,
|
||||
});
|
||||
assert.deepEqual(firstTurn, ['-p', '-']);
|
||||
assert.equal(antigravity.resumesSessionViaCli, undefined);
|
||||
|
||||
assert.equal(antigravity.maxPromptArgBytes, undefined);
|
||||
|
||||
// Picker exposes the synthetic Default + the 8 labels agy's TUI
|
||||
// Switch-Model surfaces for consumer-tier accounts. The set is small
|
||||
// enough to ship statically; revisit when upstream adds an `agy
|
||||
// models` subcommand (also tracked under issue #35).
|
||||
assert.deepEqual(
|
||||
antigravity.fallbackModels.map((m) => m.id),
|
||||
[
|
||||
'default',
|
||||
'Gemini 3.1 Pro (High)',
|
||||
'Gemini 3.1 Pro (Low)',
|
||||
'Gemini 3.5 Flash (High)',
|
||||
'Gemini 3.5 Flash (Medium)',
|
||||
'Gemini 3.5 Flash (Low)',
|
||||
'Claude Sonnet 4.6 (Thinking)',
|
||||
'Claude Opus 4.6 (Thinking)',
|
||||
'GPT-OSS 120B (Medium)',
|
||||
],
|
||||
);
|
||||
|
||||
// `agy` v1.0.3 has no `--model` flag (upstream #35), no `models`
|
||||
// subcommand, and no `/model` slash command — a user-typed model id
|
||||
// would be silently ignored at spawn, looking like an OD bug. The
|
||||
// settings UI hides the "Custom (fill below)" option when this is
|
||||
// `false`. Remove this opt-out once upstream wires #35.
|
||||
assert.equal(antigravity.supportsCustomModel, false);
|
||||
});
|
||||
|
||||
// `agy` reads `~/.gemini/antigravity-cli/settings.json` on every CLI
|
||||
// startup — verified by capturing the `--log-file` line `Propagating
|
||||
// selected model override to backend: label=…`. Routing OD's model
|
||||
// picker through that file lets the user choose a model from Settings
|
||||
// even though agy has no `--model` flag (upstream issue #35).
|
||||
//
|
||||
// Two behaviors must hold and are pinned here:
|
||||
//
|
||||
// 1. Picking "default" must NOT touch settings.json — respect the
|
||||
// label the user previously set inside agy's own TUI.
|
||||
// 2. Picking a concrete label must write that exact string into the
|
||||
// `model` field while preserving every other key (e.g.
|
||||
// `trustedWorkspaces` that agy populates on first-run consent).
|
||||
test('antigravity persists model selection to agy settings.json', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'od-antigravity-settings-'));
|
||||
try {
|
||||
const settingsPath = join(dir, 'settings.json');
|
||||
|
||||
// 1. Pre-seed the file as agy would after onboarding: a model label
|
||||
// plus a trustedWorkspaces array the user has already consented to.
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
model: 'GPT-OSS 120B (Medium)',
|
||||
trustedWorkspaces: ['/tmp/od-project'],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
// 2. Write a new label and assert the model swap + trusted list intact.
|
||||
writeAntigravityModelSelection('Gemini 3.1 Pro (High)', settingsPath);
|
||||
const after = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
||||
assert.equal(after.model, 'Gemini 3.1 Pro (High)');
|
||||
assert.deepEqual(after.trustedWorkspaces, ['/tmp/od-project']);
|
||||
|
||||
// 3. When the file doesn't exist (fresh install before onboarding),
|
||||
// we must create it rather than crash the spawn pipeline.
|
||||
const freshPath = join(dir, 'fresh', 'settings.json');
|
||||
writeAntigravityModelSelection('Claude Sonnet 4.6 (Thinking)', freshPath);
|
||||
assert.ok(existsSync(freshPath));
|
||||
assert.equal(
|
||||
JSON.parse(readFileSync(freshPath, 'utf8')).model,
|
||||
'Claude Sonnet 4.6 (Thinking)',
|
||||
);
|
||||
|
||||
// 4. When the existing file is corrupt JSON, we must rewrite it from
|
||||
// scratch instead of leaving agy with an unparseable settings file.
|
||||
const corruptPath = join(dir, 'corrupt-settings.json');
|
||||
writeFileSync(corruptPath, '{not valid json');
|
||||
writeAntigravityModelSelection('Gemini 3.5 Flash (Low)', corruptPath);
|
||||
const recovered = JSON.parse(readFileSync(corruptPath, 'utf8'));
|
||||
assert.equal(recovered.model, 'Gemini 3.5 Flash (Low)');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// AMR routes model selection through ACP `session/set_model` and only
|
||||
// accepts ids that survive the live `vela models` preflight, so a free
|
||||
// text id silently fails at spawn. Same custom-model opt-out shape as
|
||||
// antigravity — the declarative `supportsCustomModel: false` on the
|
||||
// def is the single source of truth the settings UI consults, and the
|
||||
// fallback "Custom" item should not appear in the model picker.
|
||||
test('amr opts out of the Custom-model picker option', () => {
|
||||
const amr = AGENT_DEFS.find((a) => a.id === 'amr');
|
||||
assert.ok(amr, 'amr def must remain registered');
|
||||
assert.equal(amr.supportsCustomModel, false);
|
||||
});
|
||||
|
||||
test('kiro fetchModels falls back to fallbackModels when detection fails', async () => {
|
||||
// fetchModels rejects when the binary doesn't exist; the daemon's
|
||||
// probe() catches this and uses fallbackModels instead.
|
||||
|
|
|
|||
263
apps/daemon/tests/runtimes/antigravity-model-lock.test.ts
Normal file
263
apps/daemon/tests/runtimes/antigravity-model-lock.test.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
_resetAntigravityModelLockForTests,
|
||||
acquireAntigravityModelLock,
|
||||
waitForAgyToReadModel,
|
||||
} from '../../src/runtimes/defs/antigravity.js';
|
||||
|
||||
afterEach(() => {
|
||||
_resetAntigravityModelLockForTests();
|
||||
});
|
||||
|
||||
describe('acquireAntigravityModelLock', () => {
|
||||
// The lock chain is the per-process serialization that protects
|
||||
// `~/.gemini/antigravity-cli/settings.json` from concurrent
|
||||
// non-default model writes. Two concurrent spawns must not both
|
||||
// write the file before the first one's agy has actually read it —
|
||||
// otherwise the first run executes on the second run's model.
|
||||
// Pin both the ordering (B does not enter until A releases) AND
|
||||
// the no-deadlock contract (releasing A unblocks B without manual
|
||||
// intervention).
|
||||
it('serializes concurrent acquirers — second waits for first release', async () => {
|
||||
const events: string[] = [];
|
||||
|
||||
const releaseA = await acquireAntigravityModelLock();
|
||||
events.push('A-acquired');
|
||||
|
||||
// Kick off B in parallel — it should NOT acquire until A releases.
|
||||
const bPromise = acquireAntigravityModelLock().then((release) => {
|
||||
events.push('B-acquired');
|
||||
return release;
|
||||
});
|
||||
|
||||
// Yield to the event loop several times so B has every chance to
|
||||
// resolve early if the serialization were broken.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
expect(events).toEqual(['A-acquired']);
|
||||
|
||||
releaseA();
|
||||
const releaseB = await bPromise;
|
||||
expect(events).toEqual(['A-acquired', 'B-acquired']);
|
||||
|
||||
releaseB();
|
||||
});
|
||||
|
||||
// Three+ concurrent acquirers should FIFO through the chain. A
|
||||
// future refactor that drops the awaited `previous` reference would
|
||||
// let later acquirers leapfrog earlier ones, which is exactly the
|
||||
// race we're guarding against.
|
||||
it('FIFOs three concurrent acquirers', async () => {
|
||||
const events: string[] = [];
|
||||
const releaseA = await acquireAntigravityModelLock();
|
||||
events.push('A-acquired');
|
||||
|
||||
const bPromise = acquireAntigravityModelLock().then((rel) => {
|
||||
events.push('B-acquired');
|
||||
return rel;
|
||||
});
|
||||
const cPromise = acquireAntigravityModelLock().then((rel) => {
|
||||
events.push('C-acquired');
|
||||
return rel;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(events).toEqual(['A-acquired']);
|
||||
|
||||
releaseA();
|
||||
const releaseB = await bPromise;
|
||||
expect(events).toEqual(['A-acquired', 'B-acquired']);
|
||||
|
||||
releaseB();
|
||||
const releaseC = await cPromise;
|
||||
expect(events).toEqual(['A-acquired', 'B-acquired', 'C-acquired']);
|
||||
|
||||
releaseC();
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForAgyToReadModel', () => {
|
||||
// The polling helper resolves true when agy's --log-file matches the
|
||||
// upstream `Propagating selected model override to backend:
|
||||
// label="<X>"` line, which is the signal that settings.json was
|
||||
// read. This is the lock-release trigger in the spawn pipeline —
|
||||
// breaking the pattern match would either release the lock too
|
||||
// early (concurrent races re-emerge) or never release it (queue
|
||||
// starvation).
|
||||
it('resolves true when the expected propagation line appears', async () => {
|
||||
let now = 0;
|
||||
const reads: string[] = [];
|
||||
let calls = 0;
|
||||
const result = await waitForAgyToReadModel(
|
||||
'/fake/log/path',
|
||||
'Gemini 3.1 Pro (High)',
|
||||
{
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 10,
|
||||
now: () => now,
|
||||
readFile: async (path) => {
|
||||
reads.push(path);
|
||||
calls++;
|
||||
if (calls < 3) {
|
||||
return 'I0529 boot ...\nE0529 still loading ...\n';
|
||||
}
|
||||
return (
|
||||
'I0529 model_config_manager.go:157] Propagating selected model '
|
||||
+ 'override to backend: label="Gemini 3.1 Pro (High)"\n'
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(reads.every((p) => p === '/fake/log/path')).toBe(true);
|
||||
expect(calls).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
// Model labels carry parentheses and slashes ("Gemini 3.5 Flash
|
||||
// (Medium)", "GPT-OSS 120B (Medium)") — the regex must escape regex
|
||||
// metacharacters so the literal label matches. A naive
|
||||
// `new RegExp(label)` would interpret the parens as a capture group
|
||||
// and silently match the wrong model.
|
||||
it('escapes regex metacharacters in the expected model label', async () => {
|
||||
const log =
|
||||
'I0529 model_config_manager.go] Propagating selected model '
|
||||
+ 'override to backend: label="GPT-OSS 120B (Medium)"';
|
||||
const result = await waitForAgyToReadModel(
|
||||
'/fake/log',
|
||||
'GPT-OSS 120B (Medium)',
|
||||
{
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 5,
|
||||
readFile: async () => log,
|
||||
},
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
// Must not match a DIFFERENT model just because the prefix overlaps.
|
||||
// Concurrent runs A (Gemini Pro) and B (Gemini Pro Low) could
|
||||
// otherwise have B's lock released by A's propagation line.
|
||||
it('does not match a different model label that shares a prefix', async () => {
|
||||
const log =
|
||||
'I0529 model_config_manager.go] Propagating selected model '
|
||||
+ 'override to backend: label="Gemini 3.1 Pro (Low)"';
|
||||
const result = await waitForAgyToReadModel(
|
||||
'/fake/log',
|
||||
'Gemini 3.1 Pro (High)',
|
||||
{
|
||||
timeoutMs: 30,
|
||||
pollIntervalMs: 5,
|
||||
readFile: async () => log,
|
||||
},
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
// Missing / unreadable log file (agy hasn't created it yet, or a
|
||||
// restricted tmpfs) must not throw — the polling loop swallows the
|
||||
// error and keeps retrying. Without this, a transient read failure
|
||||
// would propagate up and crash the spawn pipeline.
|
||||
it('swallows read errors and returns false on timeout', async () => {
|
||||
const result = await waitForAgyToReadModel(
|
||||
'/nonexistent/log',
|
||||
'Gemini 3.1 Pro (High)',
|
||||
{
|
||||
timeoutMs: 30,
|
||||
pollIntervalMs: 5,
|
||||
readFile: async () => {
|
||||
throw new Error('ENOENT: file not found');
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
// The `false` return must NOT be conflated with "agy definitely did
|
||||
// not read the model" — looper review at 263fd2fe7 caught a release-
|
||||
// on-timeout regression that re-opened the model-stealing race.
|
||||
// server.ts now only releases the lock on a TRUE return; this test
|
||||
// pins the helper's contract: "give up polling after timeoutMs and
|
||||
// return false" without any side effect that would imply
|
||||
// confirmation.
|
||||
it('returns false when the propagation line never appears within timeout', async () => {
|
||||
// Time-travelling clock: each `now()` call advances by 10ms so
|
||||
// the polling loop's deadline check passes naturally without
|
||||
// wall-clock sleeps. The simulated log NEVER matches.
|
||||
let now = 0;
|
||||
const result = await waitForAgyToReadModel(
|
||||
'/fake/log',
|
||||
'Gemini 3.1 Pro (High)',
|
||||
{
|
||||
timeoutMs: 50,
|
||||
pollIntervalMs: 1,
|
||||
now: () => {
|
||||
now += 10;
|
||||
return now;
|
||||
},
|
||||
readFile: async () =>
|
||||
'I0529 boot ...\nI0529 still waiting on backend ...\n',
|
||||
},
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
// The abort signal lets the caller (server.ts spawn pipeline) stop
|
||||
// polling when the child process exits — without it, a still-
|
||||
// polling watcher would leak past the run's lifetime and could be
|
||||
// matched by a later concurrent agy run's log content, releasing
|
||||
// the wrong lock.
|
||||
it('returns false immediately when the abort signal is already aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
let calls = 0;
|
||||
const result = await waitForAgyToReadModel(
|
||||
'/fake/log',
|
||||
'Gemini 3.1 Pro (High)',
|
||||
{
|
||||
timeoutMs: 10_000,
|
||||
pollIntervalMs: 1,
|
||||
abortSignal: controller.signal,
|
||||
readFile: async () => {
|
||||
calls++;
|
||||
return '';
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
// Never even entered the poll body because the helper short-
|
||||
// circuited on the already-aborted signal.
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
// Aborting MID-POLL must wake the helper from its setTimeout so
|
||||
// the caller is not blocked waiting out the rest of pollIntervalMs.
|
||||
it('wakes from setTimeout when abort signal fires during polling', async () => {
|
||||
const controller = new AbortController();
|
||||
// Fire the abort after the first read returns no match.
|
||||
let calls = 0;
|
||||
const startedAt = Date.now();
|
||||
const result = await waitForAgyToReadModel(
|
||||
'/fake/log',
|
||||
'Gemini 3.1 Pro (High)',
|
||||
{
|
||||
timeoutMs: 10_000,
|
||||
// Long poll interval — if the helper waited it out we'd see
|
||||
// ~500ms elapsed in test. Abort should cut that short.
|
||||
pollIntervalMs: 500,
|
||||
abortSignal: controller.signal,
|
||||
readFile: async () => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
setTimeout(() => controller.abort(), 10);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
);
|
||||
const elapsed = Date.now() - startedAt;
|
||||
expect(result).toBe(false);
|
||||
expect(elapsed).toBeLessThan(450);
|
||||
});
|
||||
});
|
||||
|
|
@ -765,6 +765,70 @@ test('Cursor auth matcher covers current unauthenticated Cursor error records',
|
|||
assert.equal(isCursorAuthFailureText('Error: [unauthenticated] Error'), true);
|
||||
});
|
||||
|
||||
// agy's print mode (`-p -`) exits with code 0 but emits one of these
|
||||
// shapes when the keyring entry is missing or expired. Without the
|
||||
// matcher, the daemon treats this as a successful turn and shows the
|
||||
// raw OAuth URL as the agent's "reply" — but the user has no way to
|
||||
// complete OAuth from inside chat (agy `-p` has no input field to
|
||||
// paste the auth code into). The matcher converts each shape into
|
||||
// AGENT_AUTH_REQUIRED with actionable guidance.
|
||||
test('antigravity auth matcher covers agy print-mode + log-file auth signals', async () => {
|
||||
const { isAntigravityAuthFailureText, antigravityAuthGuidance, classifyAgentAuthFailure } =
|
||||
await import('../../src/runtimes/auth.js');
|
||||
|
||||
// print-mode stdout shape — user-visible
|
||||
assert.equal(
|
||||
isAntigravityAuthFailureText(
|
||||
'Authentication required. Please visit the URL to log in: https://accounts.google.com/o/oauth2/auth?…',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isAntigravityAuthFailureText('Waiting for authentication (timeout 30s)...\nError: authentication timed out.'),
|
||||
true,
|
||||
);
|
||||
|
||||
// `agy --log-file` shape — surfaces in stderr / log-file probes
|
||||
assert.equal(
|
||||
isAntigravityAuthFailureText(
|
||||
'E log.go:398] Failed to poll ListExperiments: error getting token source: You are not logged into Antigravity.',
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
// Negative: prose mentioning "authentication" must not false-fire
|
||||
assert.equal(
|
||||
isAntigravityAuthFailureText('I added two-factor authentication to the login flow.'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isAntigravityAuthFailureText(''), false);
|
||||
|
||||
// Classifier wires the agy detector to the user-actionable guidance
|
||||
// text so the chat surfaces a re-auth message rather than the raw
|
||||
// OAuth URL the user can't act on from inside OD.
|
||||
const cls = classifyAgentAuthFailure(
|
||||
'antigravity',
|
||||
'Authentication required. Please visit the URL to log in: https://example',
|
||||
);
|
||||
assert.ok(cls);
|
||||
assert.equal(cls.status, 'missing');
|
||||
assert.equal(cls.message, antigravityAuthGuidance());
|
||||
assert.ok(
|
||||
antigravityAuthGuidance().includes('open a terminal and run `agy` once'),
|
||||
'guidance must tell the user exactly what one-time command to run',
|
||||
);
|
||||
assert.ok(
|
||||
antigravityAuthGuidance().includes('keyring'),
|
||||
'guidance must mention the keyring so users understand it persists',
|
||||
);
|
||||
|
||||
// Non-matching text → null (don't claim auth failure on unrelated errors)
|
||||
assert.equal(
|
||||
classifyAgentAuthFailure('antigravity', 'rate limit exceeded'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
// Windows env-var names are case-insensitive at the kernel level, but
|
||||
// spreading process.env into a plain object loses Node's case-insensitive
|
||||
// accessor — a `Anthropic_Api_Key` key would survive a literal
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export const qoder = requireAgent('qoder');
|
|||
export const qwen = requireAgent('qwen');
|
||||
export const opencode = requireAgent('opencode');
|
||||
export const aider = requireAgent('aider');
|
||||
export const antigravity = requireAgent('antigravity');
|
||||
export const deepseekMaxPromptArgBytes = (() => {
|
||||
assert.ok(
|
||||
deepseek.maxPromptArgBytes !== undefined,
|
||||
|
|
|
|||
21
apps/daemon/tests/runtimes/terminal-launch.test.ts
Normal file
21
apps/daemon/tests/runtimes/terminal-launch.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { launchAgentInSystemTerminal } from '../../src/runtimes/terminal-launch.js';
|
||||
|
||||
describe('launchAgentInSystemTerminal', () => {
|
||||
// Surfaces a `system-terminal launch is not supported on ${platform}`
|
||||
// reason on unsupported platforms so the chat's auth banner can fall
|
||||
// back to the text-only guidance instead of throwing. Pins the
|
||||
// shape the web side asserts on (`{ ok: false, reason: string }`).
|
||||
it('rejects unsupported platforms with a structured failure', async () => {
|
||||
// `aix` is one of Node's `process.platform` values but not one any
|
||||
// OD user would actually run on. A typo'd / future platform should
|
||||
// surface the same shape.
|
||||
const result = await launchAgentInSystemTerminal('agy', 'aix');
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.platform).toBe('aix');
|
||||
expect(result.reason).toContain('not supported');
|
||||
expect(result.reason).toContain('aix');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
FORM_ANSWERED_GENERIC_OVERRIDE,
|
||||
composeChatUserRequestForAgent,
|
||||
createFinalizedMessageTelemetryReporter,
|
||||
shouldReportRunCompletedFromMessage,
|
||||
|
|
@ -78,17 +79,150 @@ describe('Langfuse message finalization gate', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('keeps non-discovery form answers active without forcing the build transition', () => {
|
||||
it('task-type form answers trigger the build transition just like discovery', () => {
|
||||
const prompt = composeChatUserRequestForAgent(
|
||||
'## user\ninitial brief',
|
||||
'[form answers - task-type]\n- taskType: Slide deck',
|
||||
);
|
||||
|
||||
expect(prompt).toContain('The user has answered the task-type form.');
|
||||
expect(prompt).toContain('build now instead of asking another brief');
|
||||
expect(prompt).not.toContain('Treat these form answers as the active user turn');
|
||||
});
|
||||
|
||||
it('unknown form ids get the generic transition without forcing the build', () => {
|
||||
const prompt = composeChatUserRequestForAgent(
|
||||
'## user\ninitial brief',
|
||||
'[form answers - preferences]\n- theme: dark',
|
||||
);
|
||||
|
||||
expect(prompt).toContain('The user has answered the preferences form.');
|
||||
expect(prompt).toContain('Treat these form answers as the active user turn');
|
||||
expect(prompt).not.toContain('build now instead of asking another brief');
|
||||
});
|
||||
|
||||
// `agy -c` carries its own conversation memory, so packing the
|
||||
// rendered web transcript (the `## user` / `## assistant` blocks)
|
||||
// into the user request duplicates context the upstream CLI already
|
||||
// has — AND the embedded copy includes the literal `<question-form>`
|
||||
// markup the agent emitted on turn 1, which the model then re-emits
|
||||
// on turn 2, looking like the discovery form loop never breaks.
|
||||
// With `skipTranscript: true`, only the latest user turn ships and
|
||||
// the misleading "## Full conversation transcript" header is dropped.
|
||||
it('drops the transcript and transcript header when skipTranscript is true', () => {
|
||||
const currentPrompt = [
|
||||
'[form answers — discovery]',
|
||||
'- output: Dashboard / tool UI',
|
||||
'- brand: Pick a direction for me [value: pick_direction]',
|
||||
].join('\n');
|
||||
const transcript = [
|
||||
'## user',
|
||||
'初始需求',
|
||||
'',
|
||||
'## assistant',
|
||||
'<question-form id="discovery">…</question-form>',
|
||||
'',
|
||||
'## user',
|
||||
currentPrompt,
|
||||
].join('\n');
|
||||
|
||||
const prompt = composeChatUserRequestForAgent(transcript, currentPrompt, {
|
||||
skipTranscript: true,
|
||||
});
|
||||
|
||||
// The form-answer transition still fires — that drives RULE 2 / 3.
|
||||
expect(prompt).toContain('The user has answered the discovery form.');
|
||||
// The latest user turn is preserved verbatim.
|
||||
expect(prompt).toContain(currentPrompt);
|
||||
// The transcript header is dropped — it was misleading because the
|
||||
// body underneath is no longer a transcript.
|
||||
expect(prompt).not.toContain('## Full conversation transcript');
|
||||
// The prior assistant turn's `<question-form>` markup must NOT
|
||||
// leak in — that's the form-loop regression we're guarding.
|
||||
// (The transition block legitimately mentions "<question-form>"
|
||||
// in prose, so the assertion targets the opening tag the prior
|
||||
// turn carried, not the bare substring.)
|
||||
expect(prompt).not.toContain('<question-form id="discovery">');
|
||||
expect(prompt).not.toContain('## assistant');
|
||||
});
|
||||
|
||||
// The aggressive form-answered OVERRIDE block is what tells weak
|
||||
// plain agents (GPT-OSS-120B Medium, Gemini 3.5 Flash) to skip
|
||||
// RULE 1's form example on follow-up turns. We pin the trigger
|
||||
// condition AND the specific anti-patterns the literal carries,
|
||||
// because silently weakening any of them — e.g. dropping the
|
||||
// markdown-fence ban or the "subagents stopped" hallucination ban —
|
||||
// reintroduces the form-echo regression we hit in PR #3157 on GPT-OSS.
|
||||
it('FORM_ANSWERED_SYSTEM_OVERRIDE pins the anti-patterns weak plain agents need spelled out', async () => {
|
||||
const { FORM_ANSWERED_SYSTEM_OVERRIDE } = await import('../src/server.js');
|
||||
|
||||
// Headline must call out that this is a follow-up turn, not turn 1.
|
||||
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('## OVERRIDE — form already answered');
|
||||
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('turn 2 or later');
|
||||
// RULE 1 stays in the prompt so turn 1 can still emit a valid form;
|
||||
// OVERRIDE just demotes it to documentation for follow-up turns.
|
||||
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('Treat RULE 1\nas read-only documentation');
|
||||
|
||||
// Forbidden anti-patterns observed in real captures:
|
||||
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('`<question-form>` tag of any id');
|
||||
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('```json fenced block');
|
||||
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('Form-asking prose');
|
||||
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('"subagents stopped"');
|
||||
|
||||
// Required path: route to RULE 2 / RULE 3 so the model still
|
||||
// emits the `<artifact>` block on the same turn.
|
||||
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('RULE 2');
|
||||
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('RULE 3');
|
||||
expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('`<artifact>`');
|
||||
});
|
||||
|
||||
it('FORM_ANSWERED_GENERIC_OVERRIDE is used for non-discovery/task-type form ids', () => {
|
||||
// Non-build-transition forms should get a smaller override that only
|
||||
// suppresses re-asking — not the RULE 2 / RULE 3 / artifact directive.
|
||||
expect(FORM_ANSWERED_GENERIC_OVERRIDE).toContain('## OVERRIDE — form already answered');
|
||||
expect(FORM_ANSWERED_GENERIC_OVERRIDE).toContain('turn 2 or later');
|
||||
expect(FORM_ANSWERED_GENERIC_OVERRIDE).toContain('Do not ask the same form again');
|
||||
// Must NOT contain the artifact-build directive that only applies to
|
||||
// discovery / task-type — sending it for an unrelated form id would give
|
||||
// the model contradictory instructions.
|
||||
expect(FORM_ANSWERED_GENERIC_OVERRIDE).not.toContain('RULE 2');
|
||||
expect(FORM_ANSWERED_GENERIC_OVERRIDE).not.toContain('RULE 3');
|
||||
expect(FORM_ANSWERED_GENERIC_OVERRIDE).not.toContain('`<artifact>`');
|
||||
});
|
||||
|
||||
it('FORM_ANSWERED_SYSTEM_OVERRIDE only fires through composeChatUserRequestForAgent\'s transition gate', async () => {
|
||||
// Defense-in-depth check: a turn that is NOT a form-answer follow-up
|
||||
// (no `[form answers — …]` header in `currentPrompt`) must not
|
||||
// surface any of the OVERRIDE language, even when `message` carries
|
||||
// a transcript that mentions question-form. Otherwise we'd suppress
|
||||
// the legitimate turn-1 form ask.
|
||||
const transcript = '## user\n初始需求\n\n## assistant\n<question-form id="discovery">...</question-form>';
|
||||
const currentPrompt = '继续做点修改';
|
||||
|
||||
const prompt = composeChatUserRequestForAgent(transcript, currentPrompt);
|
||||
expect(prompt).not.toContain('OVERRIDE — form already answered');
|
||||
expect(prompt).not.toContain('Treat RULE 1');
|
||||
});
|
||||
|
||||
it('also drops the transcript on a non-form turn when skipTranscript is true', () => {
|
||||
// Without a form-answer transition, the function previously returned
|
||||
// `message` verbatim. With skipTranscript the body must come from
|
||||
// `currentPrompt` instead so a follow-up `agy -c` turn doesn't carry
|
||||
// the duplicate transcript.
|
||||
const transcript = '## user\n第一轮\n\n## assistant\n回答\n\n## user\n第二轮 follow-up';
|
||||
const currentPrompt = '第二轮 follow-up';
|
||||
|
||||
const skipped = composeChatUserRequestForAgent(transcript, currentPrompt, {
|
||||
skipTranscript: true,
|
||||
});
|
||||
expect(skipped).toBe(currentPrompt);
|
||||
|
||||
// Default behavior unchanged (backward compatibility for every
|
||||
// adapter that doesn't set resumesSessionViaCli).
|
||||
const kept = composeChatUserRequestForAgent(transcript, currentPrompt);
|
||||
expect(kept).toBe(transcript);
|
||||
});
|
||||
|
||||
it('invokes Langfuse reporting once when the final message write is marked', () => {
|
||||
const run = {
|
||||
id: 'run-1',
|
||||
|
|
|
|||
1
apps/web/public/agent-icons/antigravity.svg
Normal file
1
apps/web/public/agent-icons/antigravity.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -1 28 28"><path d="M21.751 22.607c1.34 1.005 3.35.335 1.508-1.508C17.73 15.74 18.904 1 12.037 1 5.17 1 6.342 15.74.815 21.1c-2.01 2.009.167 2.511 1.507 1.506 5.192-3.517 4.857-9.714 9.715-9.714 4.857 0 4.522 6.197 9.714 9.715z" fill="url(#ag)"/><defs><linearGradient id="ag" x1="2" y1="12" x2="22" y2="12" gradientUnits="userSpaceOnUse"><stop stop-color="#4285F4"/><stop offset=".33" stop-color="#EA4335"/><stop offset=".66" stop-color="#FBBC04"/><stop offset="1" stop-color="#34A853"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 569 B |
|
|
@ -31,6 +31,7 @@ const ICON_EXT: Record<string, 'svg' | 'png'> = {
|
|||
kiro: 'svg',
|
||||
kilo: 'svg',
|
||||
vibe: 'svg',
|
||||
antigravity: 'svg',
|
||||
aider: 'png',
|
||||
'trae-cli': 'png',
|
||||
devin: 'png',
|
||||
|
|
|
|||
|
|
@ -281,6 +281,12 @@ interface Props {
|
|||
onOpenSettings?: (section?: SettingsSection) => void;
|
||||
onOpenAmrSettings?: () => void;
|
||||
onSwitchToAmrAndRetry?: (failedAssistant: ChatMessage) => void;
|
||||
// PR #3157: Antigravity's `agy -p` can't complete OAuth on its own,
|
||||
// so the auth banner offers a "Sign in via terminal" button that
|
||||
// POSTs to /api/agents/antigravity/oauth-launch. Handler resolves
|
||||
// after the daemon kicks off `osascript`/`x-terminal-emulator`/
|
||||
// `cmd /c start` so the UI can disable the button while in flight.
|
||||
onLaunchAntigravityOauth?: () => Promise<void>;
|
||||
// Same dialog, but landing on the External MCP tab. Forwarded to the
|
||||
// composer's `/mcp` slash and MCP picker button.
|
||||
onOpenMcpSettings?: () => void;
|
||||
|
|
@ -377,6 +383,7 @@ export function ChatPane({
|
|||
onOpenSettings,
|
||||
onOpenAmrSettings,
|
||||
onSwitchToAmrAndRetry,
|
||||
onLaunchAntigravityOauth,
|
||||
onOpenMcpSettings,
|
||||
connectRepoNeeded,
|
||||
githubConnected,
|
||||
|
|
@ -1192,6 +1199,26 @@ export function ChatPane({
|
|||
>
|
||||
{t('chat.amrError.authorizeCta')}
|
||||
</button>
|
||||
) : runFailureUi.primaryAction === 'launch-terminal-auth' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-error-action"
|
||||
onClick={() => {
|
||||
onLaunchAntigravityOauth?.();
|
||||
}}
|
||||
>
|
||||
{t('chat.antigravityError.launchTerminalCta')}
|
||||
</button>
|
||||
) : runFailureUi.primaryAction === 'launch-terminal-switch-model' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-error-action"
|
||||
onClick={() => {
|
||||
onLaunchAntigravityOauth?.();
|
||||
}}
|
||||
>
|
||||
{t('chat.antigravityError.launchSwitchModelCta')}
|
||||
</button>
|
||||
) : runFailureUi.primaryAction === 'recharge' ? (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -2989,6 +2989,29 @@ export function ProjectView({
|
|||
},
|
||||
[currentConversationActionDisabled, onModeChange, onAgentChange, onOpenAmrSettings],
|
||||
);
|
||||
// PR #3157: Antigravity's `agy -p` cannot complete OAuth on its own,
|
||||
// so the auth banner offers a one-click "Sign in via terminal"
|
||||
// button that POSTs to the daemon. The daemon opens a system
|
||||
// Terminal running `agy` (osascript / x-terminal-emulator /
|
||||
// `cmd /c start`); the user finishes Google sign-in there and then
|
||||
// clicks Retry to redo the chat run. We don't auto-retry because
|
||||
// the OAuth completion happens externally with no reliable signal
|
||||
// back to the chat — the secondary Retry button on the same banner
|
||||
// covers the manual case.
|
||||
const handleLaunchAntigravityOauth = useCallback(async () => {
|
||||
try {
|
||||
const { launchAntigravityOauth } = await import('../providers/daemon');
|
||||
const result = await launchAntigravityOauth();
|
||||
if (!result.ok) {
|
||||
// Surface the daemon-side reason so the user knows whether
|
||||
// the spawn failed because of missing osascript / unsupported
|
||||
// platform / etc. instead of silently swallowing it.
|
||||
console.warn('[antigravity] oauth-launch failed:', result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[antigravity] oauth-launch threw:', err);
|
||||
}
|
||||
}, []);
|
||||
// Poll the AMR login status while a retry is armed, rather than only reacting
|
||||
// to the AmrLoginPill's status event — the user may close Settings (which
|
||||
// unmounts the pill and stops its polling) before finishing sign-in in the
|
||||
|
|
@ -4467,6 +4490,7 @@ export function ProjectView({
|
|||
onOpenSettings={onOpenSettings}
|
||||
onOpenAmrSettings={onOpenAmrSettings}
|
||||
onSwitchToAmrAndRetry={handleSwitchToAmrAndRetry}
|
||||
onLaunchAntigravityOauth={handleLaunchAntigravityOauth}
|
||||
onOpenMcpSettings={onOpenMcpSettings}
|
||||
connectRepoNeeded={connectRepoNeeded}
|
||||
githubConnected={githubConnected}
|
||||
|
|
|
|||
|
|
@ -2071,7 +2071,12 @@ export function SettingsDialog({
|
|||
if (!hasModels && !hasReasoning) return null;
|
||||
const choice = cfg.agentModels?.[selected.id] ?? {};
|
||||
const knownModelIds = selected.models?.map((m) => m.id) ?? [];
|
||||
const allowCustomModel = selected.id !== 'amr';
|
||||
// Adapters opt out via `supportsCustomModel: false` on their
|
||||
// RuntimeAgentDef when their CLI has no `--model` flag (Antigravity,
|
||||
// upstream issue #35) or when free-text ids silently fail at spawn
|
||||
// (AMR routes through ACP `session/set_model` and validates against
|
||||
// a live catalog). Undefined === allow, matching today's UX.
|
||||
const allowCustomModel = selected.supportsCustomModel !== false;
|
||||
const configuredModel =
|
||||
typeof choice.model === 'string' && choice.model
|
||||
? choice.model
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const ar: Dict = {
|
|||
'chat.amrError.balanceMessage': 'نفد رصيد AMR الخاص بك. اشحن للاستمرار في هذه المهمة.',
|
||||
'chat.amrError.authorizeCta': 'تفويض وإعادة المحاولة',
|
||||
'chat.amrError.rechargeCta': 'شحن AMR',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'نسخ أمر التثبيت',
|
||||
'plugins.actions.copyPluginId': 'نسخ معرّف الإضافة',
|
||||
'plugins.actions.copyReadmeBadge': 'نسخ شارة README',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const de: Dict = {
|
|||
'chat.amrError.balanceMessage': 'Dein AMR-Guthaben ist aufgebraucht. Lade auf, um diesen Lauf fortzusetzen.',
|
||||
'chat.amrError.authorizeCta': 'Autorisieren und wiederholen',
|
||||
'chat.amrError.rechargeCta': 'AMR aufladen',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'Installationsbefehl kopieren',
|
||||
'plugins.actions.copyPluginId': 'Plugin-ID kopieren',
|
||||
'plugins.actions.copyReadmeBadge': 'README-Badge kopieren',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export const en: Dict = {
|
|||
'chat.amrError.balanceMessage': "Your AMR balance has run out. Top up to keep this run going.",
|
||||
'chat.amrError.authorizeCta': "Authorize & retry",
|
||||
'chat.amrError.rechargeCta': "Top up AMR",
|
||||
'chat.antigravityError.launchTerminalCta': "Sign in via terminal",
|
||||
'chat.antigravityError.launchSwitchModelCta': "Switch model in terminal",
|
||||
'common.cancel': 'Cancel',
|
||||
'common.save': 'Save',
|
||||
'common.close': 'Close',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const esES: Dict = {
|
|||
'chat.amrError.balanceMessage': 'Tu saldo de AMR se ha agotado. Recarga para continuar esta ejecución.',
|
||||
'chat.amrError.authorizeCta': 'Autorizar y reintentar',
|
||||
'chat.amrError.rechargeCta': 'Recargar AMR',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'Copiar comando de instalación',
|
||||
'plugins.actions.copyPluginId': 'Copiar ID del plugin',
|
||||
'plugins.actions.copyReadmeBadge': 'Copiar insignia README',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const fa: Dict = {
|
|||
'chat.amrError.balanceMessage': 'موجودی AMR شما تمام شده است. برای ادامه این اجرا شارژ کنید.',
|
||||
'chat.amrError.authorizeCta': 'اعطای دسترسی و تلاش مجدد',
|
||||
'chat.amrError.rechargeCta': 'شارژ AMR',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'کپی دستور نصب',
|
||||
'plugins.actions.copyPluginId': 'کپی شناسهٔ افزونه',
|
||||
'plugins.actions.copyReadmeBadge': 'کپی نشان README',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const fr: Dict = {
|
|||
'chat.amrError.balanceMessage': 'Votre solde AMR est épuisé. Rechargez pour poursuivre cette exécution.',
|
||||
'chat.amrError.authorizeCta': 'Autoriser et relancer',
|
||||
'chat.amrError.rechargeCta': 'Recharger AMR',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'Copier la commande d’installation',
|
||||
'plugins.actions.copyPluginId': 'Copier l’ID du plugin',
|
||||
'plugins.actions.copyReadmeBadge': 'Copier le badge README',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const hu: Dict = {
|
|||
'chat.amrError.balanceMessage': 'Az AMR-egyenleged elfogyott. Tölts fel a futtatás folytatásához.',
|
||||
'chat.amrError.authorizeCta': 'Engedélyezés és újrapróbálkozás',
|
||||
'chat.amrError.rechargeCta': 'AMR feltöltése',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'Telepítési parancs másolása',
|
||||
'plugins.actions.copyPluginId': 'Pluginazonosító másolása',
|
||||
'plugins.actions.copyReadmeBadge': 'README jelvény másolása',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const id: Dict = {
|
|||
'chat.amrError.balanceMessage': 'Saldo AMR Anda habis. Isi ulang untuk melanjutkan proses ini.',
|
||||
'chat.amrError.authorizeCta': 'Otorisasi & coba lagi',
|
||||
'chat.amrError.rechargeCta': 'Isi ulang AMR',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'Salin perintah instal',
|
||||
'plugins.actions.copyPluginId': 'Salin ID plugin',
|
||||
'plugins.actions.copyReadmeBadge': 'Salin lencana README',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const it: Dict = {
|
|||
'chat.amrError.balanceMessage': 'Il tuo saldo AMR è esaurito. Ricarica per continuare questa esecuzione.',
|
||||
'chat.amrError.authorizeCta': 'Autorizza e riprova',
|
||||
'chat.amrError.rechargeCta': 'Ricarica AMR',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'Copia comando di installazione',
|
||||
'plugins.actions.copyPluginId': 'Copia ID plugin',
|
||||
'plugins.actions.copyReadmeBadge': 'Copia badge README',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const ja: Dict = {
|
|||
'chat.amrError.balanceMessage': 'AMR の残高が不足しています。チャージしてこのタスクを続行してください。',
|
||||
'chat.amrError.authorizeCta': '認可して再試行',
|
||||
'chat.amrError.rechargeCta': 'AMR にチャージ',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'インストールコマンドをコピー',
|
||||
'plugins.actions.copyPluginId': 'プラグイン ID をコピー',
|
||||
'plugins.actions.copyReadmeBadge': 'README バッジをコピー',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const ko: Dict = {
|
|||
'chat.amrError.balanceMessage': 'AMR 잔액이 부족합니다. 충전하여 이 작업을 계속 진행하세요.',
|
||||
'chat.amrError.authorizeCta': '인증하고 재시도',
|
||||
'chat.amrError.rechargeCta': 'AMR 충전',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': '설치 명령 복사',
|
||||
'plugins.actions.copyPluginId': '플러그인 ID 복사',
|
||||
'plugins.actions.copyReadmeBadge': 'README 배지 복사',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const pl: Dict = {
|
|||
'chat.amrError.balanceMessage': 'Saldo AMR zostało wyczerpane. Doładuj, aby kontynuować zadanie.',
|
||||
'chat.amrError.authorizeCta': 'Autoryzuj i ponów',
|
||||
'chat.amrError.rechargeCta': 'Doładuj AMR',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'Kopiuj polecenie instalacji',
|
||||
'plugins.actions.copyPluginId': 'Kopiuj ID wtyczki',
|
||||
'plugins.actions.copyReadmeBadge': 'Kopiuj odznakę README',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const ptBR: Dict = {
|
|||
'chat.amrError.balanceMessage': 'Seu saldo AMR acabou. Recarregue para continuar esta execução.',
|
||||
'chat.amrError.authorizeCta': 'Autorizar e tentar novamente',
|
||||
'chat.amrError.rechargeCta': 'Recarregar AMR',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'Copiar comando de instalação',
|
||||
'plugins.actions.copyPluginId': 'Copiar ID do plugin',
|
||||
'plugins.actions.copyReadmeBadge': 'Copiar selo do README',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const ru: Dict = {
|
|||
'chat.amrError.balanceMessage': 'Баланс AMR исчерпан. Пополните, чтобы продолжить это выполнение.',
|
||||
'chat.amrError.authorizeCta': 'Авторизовать и повторить',
|
||||
'chat.amrError.rechargeCta': 'Пополнить AMR',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'Скопировать команду установки',
|
||||
'plugins.actions.copyPluginId': 'Скопировать ID плагина',
|
||||
'plugins.actions.copyReadmeBadge': 'Скопировать бейдж README',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const th: Dict = {
|
|||
'chat.amrError.balanceMessage': 'ยอดเงิน AMR ของคุณหมดแล้ว เติมเงินเพื่อดำเนินงานนี้ต่อ',
|
||||
'chat.amrError.authorizeCta': 'ให้สิทธิ์และลองใหม่',
|
||||
'chat.amrError.rechargeCta': 'เติมเงิน AMR',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'คัดลอกคำสั่งติดตั้ง',
|
||||
'plugins.actions.copyPluginId': 'คัดลอก ID ปลั๊กอิน',
|
||||
'plugins.actions.copyReadmeBadge': 'คัดลอกแบดจ์ README',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const tr: Dict = {
|
|||
'chat.amrError.balanceMessage': 'AMR bakiyeniz bitti. Çalıştırmaya devam etmek için bakiye yükleyin.',
|
||||
'chat.amrError.authorizeCta': 'Yetkilendir ve yeniden dene',
|
||||
'chat.amrError.rechargeCta': 'AMR bakiyesi yükle',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'Kurulum komutunu kopyala',
|
||||
'plugins.actions.copyPluginId': 'Eklenti ID’sini kopyala',
|
||||
'plugins.actions.copyReadmeBadge': 'README rozetini kopyala',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const uk: Dict = {
|
|||
'chat.amrError.balanceMessage': 'Баланс AMR вичерпано. Поповніть, щоб продовжити це виконання.',
|
||||
'chat.amrError.authorizeCta': 'Авторизувати та повторити',
|
||||
'chat.amrError.rechargeCta': 'Поповнити AMR',
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': 'Скопіювати команду встановлення',
|
||||
'plugins.actions.copyPluginId': 'Скопіювати ID плагіна',
|
||||
'plugins.actions.copyReadmeBadge': 'Скопіювати бейдж README',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export const zhCN: Dict = {
|
|||
'chat.amrError.balanceMessage': "AMR 账户余额不足。充值后可继续运行当前任务。",
|
||||
'chat.amrError.authorizeCta': "授权并重试",
|
||||
'chat.amrError.rechargeCta': "前往充值",
|
||||
'chat.antigravityError.launchTerminalCta': "在终端中登录",
|
||||
'chat.antigravityError.launchSwitchModelCta': "在终端中切换模型",
|
||||
'common.cancel': '取消',
|
||||
'common.save': '保存',
|
||||
'common.close': '关闭',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const zhTW: Dict = {
|
|||
'chat.amrError.balanceMessage': "AMR 帳戶餘額不足。儲值後即可繼續執行目前任務。",
|
||||
'chat.amrError.authorizeCta': "授權並重試",
|
||||
'chat.amrError.rechargeCta': "前往儲值",
|
||||
'chat.antigravityError.launchTerminalCta': 'Sign in via terminal',
|
||||
'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal',
|
||||
'plugins.actions.copyInstallCommand': '複製安裝命令',
|
||||
'plugins.actions.copyPluginId': '複製外掛 ID',
|
||||
'plugins.actions.copyReadmeBadge': '複製 README 徽章',
|
||||
|
|
|
|||
|
|
@ -1695,6 +1695,8 @@ export interface Dict {
|
|||
'chat.amrError.balanceMessage': string;
|
||||
'chat.amrError.authorizeCta': string;
|
||||
'chat.amrError.rechargeCta': string;
|
||||
'chat.antigravityError.launchTerminalCta': string;
|
||||
'chat.antigravityError.launchSwitchModelCta': string;
|
||||
'chat.tabComments': string;
|
||||
'chat.commentsSoon': string;
|
||||
'chat.comments.attached': string;
|
||||
|
|
|
|||
|
|
@ -135,10 +135,50 @@ function scopeHistoryToAgent(history: ChatMessage[], targetAgentId?: string): Ch
|
|||
return history;
|
||||
}
|
||||
|
||||
// Strip OD-specific markup that the agent emitted on a prior turn but
|
||||
// that the model would otherwise pattern-match as a template to echo.
|
||||
// Today this is `<question-form>` blocks and the ```json fenced schemas
|
||||
// some models (GPT-OSS-120B Medium, Gemini 3.5 Flash) emit alongside
|
||||
// them — leaving those literal in the transcript causes weak/medium
|
||||
// plain-stream models to re-emit an identical form on the user's
|
||||
// follow-up turn, looking like the discovery form loop never breaks
|
||||
// (see PR #3157 form-loop investigation).
|
||||
//
|
||||
// User content is preserved verbatim — a user message that legitimately
|
||||
// quotes `<question-form>` (e.g. discussing the markup with the agent)
|
||||
// must not be mangled.
|
||||
export function sanitizePriorAssistantTurnForTranscript(content: string): string {
|
||||
let sanitized = content.replace(
|
||||
/<question-form\b[^>]*>[\s\S]*?<\/question-form>/g,
|
||||
'[question-form was emitted here on a prior turn; the user already answered, see their reply below.]',
|
||||
);
|
||||
// Strip ```json (or plain ```) fenced blocks whose body matches the
|
||||
// form schema shape — `"questions": [` is the strongest tell. A
|
||||
// generic JSON snippet without that key (e.g. an API response the
|
||||
// agent shared) is left intact.
|
||||
sanitized = sanitized.replace(
|
||||
/```(?:json)?\s*\n([\s\S]*?)\n```/g,
|
||||
(match, body: string) => {
|
||||
if (/"questions"\s*:\s*\[/.test(body)) {
|
||||
return '[form schema was echoed here on a prior turn; stripped to avoid a loop.]';
|
||||
}
|
||||
return match;
|
||||
},
|
||||
);
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function buildDaemonTranscript(history: ChatMessage[], targetAgentId?: string): string {
|
||||
const scopedHistory = scopeHistoryToAgent(history, targetAgentId);
|
||||
const transcript = scopedHistory
|
||||
.map((m) => `## ${m.role}\n${escapeTranscriptRoleDelimiters(truncateForTranscript(m.content.trim()))}`)
|
||||
.map((m) => {
|
||||
const trimmed = m.content.trim();
|
||||
const sanitized =
|
||||
m.role === 'assistant'
|
||||
? sanitizePriorAssistantTurnForTranscript(trimmed)
|
||||
: trimmed;
|
||||
return `## ${m.role}\n${escapeTranscriptRoleDelimiters(truncateForTranscript(sanitized))}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
const warning = buildPriorRunContextWarning(scopedHistory);
|
||||
return warning ? `${warning}\n\n${transcript}` : transcript;
|
||||
|
|
@ -402,6 +442,46 @@ export async function submitChatRunToolResult(
|
|||
}
|
||||
}
|
||||
|
||||
// PR #3157: Antigravity's auth banner can offer a one-click "open
|
||||
// system terminal with agy" button. The daemon endpoint spawns
|
||||
// osascript / x-terminal-emulator / `cmd /c start` for the user; on
|
||||
// success the new Terminal window pops up with agy running and the
|
||||
// browser opens for OAuth. The Promise resolves once the daemon kicks
|
||||
// off the spawn (not when OAuth completes), so the UI can disable the
|
||||
// button momentarily and then re-enable for a retry click after the
|
||||
// user finishes in the terminal.
|
||||
export interface LaunchAntigravityOauthResult {
|
||||
ok: boolean;
|
||||
platform?: string;
|
||||
via?: string;
|
||||
error?: string;
|
||||
}
|
||||
export async function launchAntigravityOauth(): Promise<LaunchAntigravityOauthResult> {
|
||||
try {
|
||||
const resp = await fetch('/api/agents/antigravity/oauth-launch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
const body = (await resp.json().catch(() => null)) as
|
||||
| LaunchAntigravityOauthResult
|
||||
| null;
|
||||
if (!resp.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
body?.error ?? `daemon returned ${resp.status} ${resp.statusText}`,
|
||||
};
|
||||
}
|
||||
return body ?? { ok: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface VelaUser {
|
||||
id: string;
|
||||
email: string;
|
||||
|
|
|
|||
|
|
@ -20,10 +20,33 @@ const PROMOTE_AMR_CODES = new Set<string>([
|
|||
]);
|
||||
|
||||
// Primary action offered in the gray error card.
|
||||
// - retry: re-run with the current agent.
|
||||
// - authorize: AMR sign-in/authorize flow, then auto-retry on success.
|
||||
// - recharge: open the AMR wallet (manual retry afterwards).
|
||||
export type RunFailurePrimaryAction = 'retry' | 'authorize' | 'recharge';
|
||||
// - retry: re-run with the current agent.
|
||||
// - authorize: AMR sign-in/authorize flow, then auto-retry on success.
|
||||
// - recharge: open the AMR wallet (manual retry afterwards).
|
||||
// - launch-terminal-auth: Antigravity-specific. agy's `-p`
|
||||
// print mode cannot complete Google
|
||||
// Sign-In on its own (no input field
|
||||
// for the auth code), so OD spawns a
|
||||
// system Terminal running `agy` and
|
||||
// the user finishes OAuth there.
|
||||
// - launch-terminal-switch-model: Antigravity-specific. agy has no
|
||||
// `--model` flag (upstream #35), so
|
||||
// switching to a model with available
|
||||
// quota means opening agy's TUI and
|
||||
// using its Switch Model picker. The
|
||||
// daemon spawns the same terminal as
|
||||
// launch-terminal-auth — the button
|
||||
// label is the only thing that changes.
|
||||
// Both terminal-launch actions pair with `secondaryRetry: true` so the
|
||||
// user has a Retry button after the external step completes (OAuth /
|
||||
// switching models happens out-of-band; we can't auto-retry from the
|
||||
// daemon side).
|
||||
export type RunFailurePrimaryAction =
|
||||
| 'retry'
|
||||
| 'authorize'
|
||||
| 'recharge'
|
||||
| 'launch-terminal-auth'
|
||||
| 'launch-terminal-switch-model';
|
||||
|
||||
// i18n keys for the gray-card text override (null = show the raw error).
|
||||
export type RunFailureMessageKey =
|
||||
|
|
@ -77,6 +100,33 @@ export function resolveRunFailureUi(
|
|||
showSwitchCard: false,
|
||||
};
|
||||
}
|
||||
// Antigravity's auth flow is terminal-only — see the
|
||||
// `launch-terminal-auth` action comment for why. Without this branch
|
||||
// the user sees the daemon-emitted guidance text and would have to
|
||||
// open a terminal themselves; with it they get a one-click button
|
||||
// that opens Terminal.app / x-terminal-emulator / cmd with `agy`
|
||||
// running, and a Retry button to redo the chat after OAuth completes.
|
||||
if (agentId === 'antigravity') {
|
||||
if (code === 'AGENT_AUTH_REQUIRED') {
|
||||
return {
|
||||
primaryAction: 'launch-terminal-auth',
|
||||
messageKey: null,
|
||||
secondaryRetry: true,
|
||||
showSwitchCard: false,
|
||||
};
|
||||
}
|
||||
// Quota: each Antigravity model has its own quota, so the action
|
||||
// is "open agy, switch model" rather than "sign in." Same handler
|
||||
// spawns the same terminal; only the label changes.
|
||||
if (code === 'RATE_LIMITED') {
|
||||
return {
|
||||
primaryAction: 'launch-terminal-switch-model',
|
||||
messageKey: null,
|
||||
secondaryRetry: true,
|
||||
showSwitchCard: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
const promote = typeof code === 'string' && PROMOTE_AMR_CODES.has(code);
|
||||
return {
|
||||
primaryAction: 'retry',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const AGENT_LABELS: Record<string, string> = {
|
|||
qoder: 'Qoder',
|
||||
copilot: 'Copilot',
|
||||
deepseek: 'DeepSeek',
|
||||
antigravity: 'Antigravity',
|
||||
'anthropic-api': 'Anthropic API',
|
||||
'openai-api': 'OpenAI API',
|
||||
'azure-openai-api': 'Azure OpenAI',
|
||||
|
|
@ -31,6 +32,7 @@ const AGENT_ALIASES: Record<string, string> = {
|
|||
'deepseek-tui': 'deepseek',
|
||||
'aider cli': 'aider',
|
||||
'aider chat': 'aider',
|
||||
agy: 'antigravity',
|
||||
};
|
||||
|
||||
export function agentDisplayName(
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ const amrAgent: AgentInfo = {
|
|||
available: true,
|
||||
version: '1.0.0',
|
||||
models: [{ id: 'default', label: 'Default' }],
|
||||
supportsCustomModel: false,
|
||||
};
|
||||
|
||||
type OnRefreshAgents = (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
buildDaemonTranscript,
|
||||
latestUserPromptFromHistory,
|
||||
reattachDaemonRun,
|
||||
sanitizePriorAssistantTurnForTranscript,
|
||||
streamViaDaemon,
|
||||
} from '../../src/providers/daemon';
|
||||
import { streamMessageOpenAI } from '../../src/providers/openai-compatible';
|
||||
|
|
@ -145,6 +146,115 @@ describe('streamViaDaemon', () => {
|
|||
expect(transcript).toContain('small answer');
|
||||
});
|
||||
|
||||
// PR #3157 form-loop investigation: weak / medium plain-stream models
|
||||
// (GPT-OSS-120B Medium, Gemini 3.5 Flash through Antigravity's `agy`)
|
||||
// pattern-match on the literal `<question-form>` markup the agent
|
||||
// emitted on turn 1 and re-emit an identical form on turn 2 even when
|
||||
// the OD-side OVERRIDE block explicitly forbids it. Stripping the
|
||||
// markup from prior assistant turns at the transcript layer kills the
|
||||
// echo source entirely.
|
||||
it('strips question-form markup from prior assistant turns to kill the form-echo loop', () => {
|
||||
const sanitized = sanitizePriorAssistantTurnForTranscript(
|
||||
[
|
||||
'Got it — let me ask a few questions:',
|
||||
'',
|
||||
'<question-form id="discovery" title="Quick brief — 30 seconds">',
|
||||
'{',
|
||||
' "description": "I will lock the brief.",',
|
||||
' "questions": [{ "id": "output", "label": "What are we making?" }]',
|
||||
'}',
|
||||
'</question-form>',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
expect(sanitized).not.toContain('<question-form');
|
||||
expect(sanitized).not.toContain('</question-form>');
|
||||
expect(sanitized).not.toContain('"questions": [');
|
||||
expect(sanitized).toContain('question-form was emitted here on a prior turn');
|
||||
});
|
||||
|
||||
it('also strips ```json fenced form-schema echoes that some models add alongside the form tag', () => {
|
||||
const sanitized = sanitizePriorAssistantTurnForTranscript(
|
||||
[
|
||||
'Got it — 请告诉我以下信息:',
|
||||
'',
|
||||
'```json',
|
||||
'{',
|
||||
' "title": "快速简报 — 30 秒",',
|
||||
' "questions": [',
|
||||
' { "id": "output", "label": "我们要做什么?" }',
|
||||
' ]',
|
||||
'}',
|
||||
'```',
|
||||
'',
|
||||
'<question-form id="discovery" title="快速简报 — 30 秒">',
|
||||
'{ "questions": [] }',
|
||||
'</question-form>',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
expect(sanitized).not.toContain('```json');
|
||||
expect(sanitized).not.toContain('<question-form');
|
||||
expect(sanitized).toContain('form schema was echoed here on a prior turn');
|
||||
});
|
||||
|
||||
it('preserves unrelated ```json fences (regular JSON snippets without "questions") so model output stays intact', () => {
|
||||
const original = [
|
||||
'Here is the config you asked about:',
|
||||
'',
|
||||
'```json',
|
||||
'{ "endpoint": "https://api.example.com", "version": 2 }',
|
||||
'```',
|
||||
].join('\n');
|
||||
const sanitized = sanitizePriorAssistantTurnForTranscript(original);
|
||||
|
||||
// No `"questions"` key → fence is NOT stripped.
|
||||
expect(sanitized).toBe(original);
|
||||
});
|
||||
|
||||
it('preserves <artifact> blocks — only question-form is stripped, the deliverable stays intact', () => {
|
||||
const original = [
|
||||
'Build summary below.',
|
||||
'',
|
||||
'<artifact identifier="deck" type="text/html" title="Pitch deck">',
|
||||
'<!doctype html>',
|
||||
'<html><body>slide content</body></html>',
|
||||
'</artifact>',
|
||||
].join('\n');
|
||||
const sanitized = sanitizePriorAssistantTurnForTranscript(original);
|
||||
|
||||
expect(sanitized).toBe(original);
|
||||
expect(sanitized).toContain('<artifact');
|
||||
expect(sanitized).toContain('<!doctype html>');
|
||||
});
|
||||
|
||||
it('sanitizes ONLY assistant content inside buildDaemonTranscript — user messages quoting <question-form> stay verbatim', () => {
|
||||
const transcript = buildDaemonTranscript([
|
||||
{
|
||||
id: '1',
|
||||
role: 'user',
|
||||
// User legitimately quotes the markup in chat. Must not be mangled —
|
||||
// they might be discussing the markup itself with the agent.
|
||||
content: 'Why does <question-form id="discovery"> render as a card?',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
'<question-form id="discovery" title="Brief">',
|
||||
'{ "questions": [] }',
|
||||
'</question-form>',
|
||||
].join('\n'),
|
||||
},
|
||||
]);
|
||||
|
||||
// User's <question-form> mention survives.
|
||||
expect(transcript).toContain('Why does <question-form id="discovery"> render');
|
||||
// Assistant's emission is replaced with the placeholder.
|
||||
expect(transcript).toContain('question-form was emitted here on a prior turn');
|
||||
expect(transcript).not.toContain('<question-form id="discovery" title="Brief">');
|
||||
});
|
||||
|
||||
it('escapes role delimiter lines in prior message content', () => {
|
||||
const transcript = buildDaemonTranscript([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -47,4 +47,62 @@ describe('resolveRunFailureUi', () => {
|
|||
const ui = resolveRunFailureUi('AGENT_EXECUTION_FAILED', 'amr');
|
||||
expect(ui).toMatchObject({ primaryAction: 'retry', showSwitchCard: false });
|
||||
});
|
||||
|
||||
// PR #3157: Antigravity's `agy -p` cannot complete Google Sign-In on
|
||||
// its own — the OAuth callback page asks the user to paste an auth
|
||||
// code back into agy, but print mode has no input field. The auth
|
||||
// banner offers a one-click "Sign in via terminal" button that
|
||||
// spawns a system Terminal running `agy`. Pin both the action type
|
||||
// AND `secondaryRetry: true` because OAuth completes externally and
|
||||
// we can't auto-retry from the daemon side — the manual Retry
|
||||
// button next to the launcher is the only way back to the chat run.
|
||||
it('offers launch-terminal-auth + manual retry for antigravity AGENT_AUTH_REQUIRED', () => {
|
||||
const ui = resolveRunFailureUi('AGENT_AUTH_REQUIRED', 'antigravity');
|
||||
expect(ui).toMatchObject({
|
||||
primaryAction: 'launch-terminal-auth',
|
||||
messageKey: null,
|
||||
secondaryRetry: true,
|
||||
showSwitchCard: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Antigravity's per-model quota: each model (Gemini 3 Pro / Flash,
|
||||
// Claude 4.6, GPT-OSS) has its own quota and the user has to switch
|
||||
// models in agy's TUI because there's no `--model` flag (upstream
|
||||
// #35). RATE_LIMITED gets the same terminal-launch handler as
|
||||
// AGENT_AUTH_REQUIRED — only the button label changes ("Switch
|
||||
// model in terminal" vs "Sign in via terminal"). Pin both action
|
||||
// type AND `secondaryRetry: true` since model switching happens
|
||||
// out-of-band and we can't auto-retry from the daemon side.
|
||||
it('offers launch-terminal-switch-model + manual retry for antigravity RATE_LIMITED', () => {
|
||||
const ui = resolveRunFailureUi('RATE_LIMITED', 'antigravity');
|
||||
expect(ui).toMatchObject({
|
||||
primaryAction: 'launch-terminal-switch-model',
|
||||
messageKey: null,
|
||||
secondaryRetry: true,
|
||||
showSwitchCard: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Other antigravity failure codes must NOT promote the terminal
|
||||
// launcher — it's specific to the OAuth-missing and quota-reached
|
||||
// cases. A generic `AGENT_EXECUTION_FAILED` should fall back to
|
||||
// plain retry.
|
||||
it('does NOT promote launch-terminal-auth for non-auth/quota antigravity failures', () => {
|
||||
const ui = resolveRunFailureUi('AGENT_EXECUTION_FAILED', 'antigravity');
|
||||
expect(ui.primaryAction).toBe('retry');
|
||||
expect(ui.primaryAction).not.toBe('launch-terminal-auth');
|
||||
expect(ui.primaryAction).not.toBe('launch-terminal-switch-model');
|
||||
});
|
||||
|
||||
// Other agents hitting AGENT_AUTH_REQUIRED must NOT see the
|
||||
// terminal launcher — agy's specific OAuth quirk is what motivates
|
||||
// it; cursor-agent / deepseek / claude have different sign-in
|
||||
// shapes (own CLI subcommand / API key env var / OAuth on first run).
|
||||
it('does NOT promote launch-terminal-auth for non-antigravity auth failures', () => {
|
||||
for (const agent of ['claude', 'cursor-agent', 'deepseek', 'codex']) {
|
||||
const ui = resolveRunFailureUi('AGENT_AUTH_REQUIRED', agent);
|
||||
expect(ui.primaryAction).not.toBe('launch-terminal-auth');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ export interface AgentInfo {
|
|||
| 'claude-mcp-json'
|
||||
| 'acp-merge'
|
||||
| 'opencode-env-content';
|
||||
/**
|
||||
* When `false`, the Settings model picker hides the "Custom (fill below)"
|
||||
* option and the free-text input. Use this for agents whose CLI doesn't
|
||||
* accept a model id (e.g. Antigravity `agy` has no `--model` flag yet —
|
||||
* upstream issue #35) or rejects free-form ids (AMR validates against the
|
||||
* live Vela catalog). Undefined === allow, matching the historical UX.
|
||||
*/
|
||||
supportsCustomModel?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentsResponse {
|
||||
|
|
|
|||
Loading…
Reference in a new issue