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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,14 @@ HookBudget budget
// subtracts HookBudget.Safety — do NOT subtract it again. Written even when the watcher-spawn
// gate below returns early — a withheld watcher must not suppress injection.
var fragment = await SessionStartMemoryHookSupport.AwaitBounded(memoryTask, budget);
var workItemsNudge = HarnessNudgeEmitter.Combine(
WorkItemsNudgeEmitter.Resolve(SessionStartHarness.Antigravity, sessionId, activeProfile?.DisableWorkItemsNudge is true),
HarnessNudgeEmitter.ResolveFragmentForHook(activeProfile?.DisableHarnessNudge is true, config));
// Nudges are unleased pure functions of the session id, so on this repeating callback they
// must be gated by the vendor's own per-conversation counter: without the gate every turn
// re-injects them as another persistent userMessage step.
var workItemsNudge = IsFirstInvocation(payload)
? HarnessNudgeEmitter.Combine(
WorkItemsNudgeEmitter.Resolve(SessionStartHarness.Antigravity, sessionId, activeProfile?.DisableWorkItemsNudge is true),
HarnessNudgeEmitter.ResolveFragmentForHook(activeProfile?.DisableHarnessNudge is true, config))
Comment on lines +212 to +215

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the throttled harness nudge outside the turn gate

In a long-running Antigravity conversation, this gate prevents HarnessNudgeEmitter.ResolveFragmentForHook from ever checking again after turn 1, so an agent installed later—or one missed because the first check fell within the shared six-hour throttle—will not be offered until the user starts another conversation or invokes another surface. Unlike the work-items nudge, the harness nudge is already stateful: TryClaimCheck throttles evaluation for six hours and StampOffered enforces the per-vendor reoffer floor, so it cannot be injected on every turn and should remain outside this first-invocation gate.

Useful? React with 👍 / 👎.

: null;
WritePreInvocationOutput(stdout, fragment, workItemsNudge);
await stdout.FlushAsync();

Expand Down Expand Up @@ -309,6 +314,17 @@ internal static SessionMemoryLifecycle LifecycleFor(string sessionId) =>
}
}

/// <summary>
/// Whether this PreInvocation is the conversation's first, read from the vendor's own
/// <c>invocationNum</c> counter — the one payload field that varies between callbacks. An absent
/// or non-numeric value reads as first: a payload without the counter cannot distinguish
/// callbacks, and emitting once too often beats never emitting.
Comment on lines +318 to +321
/// </summary>
internal static bool IsFirstInvocation(JsonObject payload) =>
payload["invocationNum"] is not JsonValue value
|| !value.TryGetValue<long>(out var invocation)
|| invocation <= 1;

/// <summary>The event name — the first positional token after <c>--antigravity</c>.</summary>
internal static string? EventArg(string[] args) {
var idx = Array.IndexOf(args, "--antigravity");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,4 +242,26 @@ public async Task HandleCore_consults_the_fallback_for_an_empty_workspacePaths_p
await Assert.That(sw.ToString()).IsEqualTo("");
await Assert.That(consulted).IsTrue();
}

// The nudges carry no lease, so on this repeating callback only the invocation counter keeps
// them from re-injecting a persistent userMessage step every turn.

[Test]
public async Task The_first_invocation_emits_nudges() {
await Assert.That(AntigravityHookCommand.IsFirstInvocation(Payload("""{"invocationNum":1}"""))).IsTrue();
}

[Test]
public async Task A_later_invocation_suppresses_nudges() {
await Assert.That(AntigravityHookCommand.IsFirstInvocation(Payload("""{"invocationNum":2}"""))).IsFalse();
await Assert.That(AntigravityHookCommand.IsFirstInvocation(Payload("""{"invocationNum":97}"""))).IsFalse();
}

// Fail-open: a payload that cannot distinguish callbacks must still emit.
[Test]
public async Task A_missing_or_non_numeric_counter_reads_as_the_first_invocation() {
await Assert.That(AntigravityHookCommand.IsFirstInvocation(Payload("{}"))).IsTrue();
await Assert.That(AntigravityHookCommand.IsFirstInvocation(Payload("""{"invocationNum":"2"}"""))).IsTrue();
await Assert.That(AntigravityHookCommand.IsFirstInvocation(Payload("""{"invocationNum":0}"""))).IsTrue();
Comment on lines +260 to +265
}
}