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
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>
263 lines
9 KiB
TypeScript
263 lines
9 KiB
TypeScript
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);
|
|
});
|
|
});
|