Skip to content
Merged
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
12 changes: 11 additions & 1 deletion assistant/src/cli/commands/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ Examples:
const result = await cliIpcCall<{
invoked: boolean;
producedToolCalls: boolean;
reason?: "not_found" | "timeout" | "no_resolver";
}>("wake_conversation", {
conversationId,
hint: opts.hint,
Expand All @@ -451,12 +452,21 @@ Examples:
const wake = result.result!;
if (opts.json) {
log.info(JSON.stringify({ ok: true, ...wake }));
} else if (wake.invoked) {
return;
}
if (wake.invoked) {
log.info(
wake.producedToolCalls
? `Wake produced output on conversation ${conversationId}`
: `Wake invoked on ${conversationId} (no output produced)`,
);
} else if (wake.reason === "timeout") {
// Conversation exists but stayed busy past the wait-until-idle
// window. This is a transient condition, not an error — the
// caller can retry later. Exit 0.
log.info(
`Conversation ${conversationId} is busy — wake skipped (retry later)`,
);
} else {
log.error(
`Could not wake conversation ${conversationId} — conversation not found`,
Expand Down
2 changes: 1 addition & 1 deletion assistant/src/prompts/update-bulletin-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export async function runUpdateBulletinJobIfNeeded(): Promise<void> {

if (!wakeResult.invoked) {
log.warn(
{ conversationId: conv.id },
{ conversationId: conv.id, reason: wakeResult.reason },
"Update bulletin wake silently no-op'd (invoked=false); cleaning up orphan background conversation and leaving checkpoint unchanged so next startup retries",
);
// Belt-and-suspenders cleanup: even though `runUpdateBulletinJobIfNeeded`
Expand Down
45 changes: 43 additions & 2 deletions assistant/src/runtime/__tests__/agent-wake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,12 +490,53 @@ describe("wakeAgentForOpportunity", () => {
expect(result.producedToolCalls).toBe(false);
});

test("returns invoked: false when the conversation cannot be resolved", async () => {
test("returns invoked: false with reason 'not_found' when the conversation cannot be resolved", async () => {
const result = await wakeAgentForOpportunity(
{ conversationId: "missing", hint: "x", source: "y" },
{ resolveTarget: async () => null },
);
expect(result).toEqual({ invoked: false, producedToolCalls: false });
expect(result).toEqual({
invoked: false,
producedToolCalls: false,
reason: "not_found",
});
});

test("returns invoked: false with reason 'timeout' when the target stays busy past the wait-until-idle window", async () => {
// Resolver returns a target that is permanently `processing`. Fast-
// forward the injected `now` past the 30s deadline so waitUntilIdle
// returns false. Without the distinct `timeout` reason, callers
// cannot tell this case apart from "not_found".
const history: Message[] = [];
const target: WakeTarget = {
conversationId: "conv-busy",
agentLoop: { run: async () => history },
getMessages: () => history,
pushMessage: () => {},
emitAgentEvent: () => {},
isProcessing: () => true,
markProcessing: () => {},
persistTailMessage: async () => {},
};
let t = 0;
const now = () => {
// First call establishes the deadline at +30_000. Every subsequent
// call jumps past the deadline so the polling loop exits after one
// 50ms tick.
const v = t;
t += 31_000;
return v;
};

const result = await wakeAgentForOpportunity(
{ conversationId: "conv-busy", hint: "x", source: "y" },
{ resolveTarget: async () => target, now },
);
expect(result).toEqual({
invoked: false,
producedToolCalls: false,
reason: "timeout",
});
});

test("agent loop error is treated as a no-op", async () => {
Expand Down
16 changes: 13 additions & 3 deletions assistant/src/runtime/agent-wake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,19 @@ export interface WakeOptions {
source: string;
}

/**
* Reason a wake returned `invoked: false`. Callers (CLI, update-bulletin
* job) need to distinguish "conversation doesn't exist" from "conversation
* exists but stayed busy past the wait-until-idle timeout" — the former is
* a user-visible error, the latter is an expected transient condition.
*/
export type WakeSkipReason = "not_found" | "timeout" | "no_resolver";

export interface WakeResult {
invoked: boolean;
producedToolCalls: boolean;
/** Present only when `invoked: false`; identifies why the wake was skipped. */
reason?: WakeSkipReason;
}

/**
Expand Down Expand Up @@ -299,7 +309,7 @@ export async function wakeAgentForOpportunity(
{ conversationId, source },
"agent-wake: no resolver available (default resolver not registered and no deps passed); skipping",
);
return { invoked: false, producedToolCalls: false };
return { invoked: false, producedToolCalls: false, reason: "no_resolver" };
}
const nowFn = deps?.now ?? Date.now;
const startedAt = nowFn();
Expand All @@ -311,7 +321,7 @@ export async function wakeAgentForOpportunity(
{ conversationId, source },
"agent-wake: conversation not found; skipping",
);
return { invoked: false, producedToolCalls: false };
return { invoked: false, producedToolCalls: false, reason: "not_found" };
}

const idle = await waitUntilIdle(target, nowFn);
Expand All @@ -320,7 +330,7 @@ export async function wakeAgentForOpportunity(
{ conversationId, source },
"agent-wake: conversation still processing after timeout; skipping",
);
return { invoked: false, producedToolCalls: false };
return { invoked: false, producedToolCalls: false, reason: "timeout" };
}

const baseline = target.getMessages();
Expand Down
7 changes: 5 additions & 2 deletions skills/meet-join/daemon/__tests__/proactive-chat-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,8 +549,11 @@ describe("proactive-chat E2E — Tier 1 hit → Tier 2 confirms → agent wake
expect(blocks[0]!.type).toBe("tool_use");
expect(blocks[0]!.name).toBe("meet_send_chat");

// Performance envelope — generous headroom for CI runners.
expect(elapsedMs).toBeLessThan(2000);
// Performance envelope — tight enough to catch real regressions but
// loose enough to tolerate slow CI runners. The underlying fake-LLM
// path completes well under 100ms on developer hardware; 500ms is a
// 5x headroom that flags genuine perf drift without flaking.
expect(elapsedMs).toBeLessThan(500);

detector.dispose();
} finally {
Expand Down
Loading