refactor: align agent lifecycle
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
- TUI: migrate key handling to the updated pi-tui Key matcher API.
|
- TUI: migrate key handling to the updated pi-tui Key matcher API.
|
||||||
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
|
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
|
||||||
- macOS: Settings now use a sidebar layout to avoid toolbar overflow in Connections.
|
- macOS: Settings now use a sidebar layout to avoid toolbar overflow in Connections.
|
||||||
|
- macOS: drop deprecated `afterMs` from agent wait params to match gateway schema.
|
||||||
|
|
||||||
### Maintenance
|
### Maintenance
|
||||||
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
|
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
|
||||||
|
|||||||
@@ -424,21 +424,17 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
|
|
||||||
public struct AgentWaitParams: Codable, Sendable {
|
public struct AgentWaitParams: Codable, Sendable {
|
||||||
public let runid: String
|
public let runid: String
|
||||||
public let afterms: Int?
|
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
runid: String,
|
runid: String,
|
||||||
afterms: Int?,
|
|
||||||
timeoutms: Int?
|
timeoutms: Int?
|
||||||
) {
|
) {
|
||||||
self.runid = runid
|
self.runid = runid
|
||||||
self.afterms = afterms
|
|
||||||
self.timeoutms = timeoutms
|
self.timeoutms = timeoutms
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case runid = "runId"
|
case runid = "runId"
|
||||||
case afterms = "afterMs"
|
|
||||||
case timeoutms = "timeoutMs"
|
case timeoutms = "timeoutMs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
docs/agent-loop.md
Normal file
61
docs/agent-loop.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
summary: "Agent loop lifecycle, streams, and wait semantics"
|
||||||
|
read_when:
|
||||||
|
- You need an exact walkthrough of the agent loop or lifecycle events
|
||||||
|
---
|
||||||
|
# Agent Loop (Clawdis)
|
||||||
|
|
||||||
|
Short, exact flow of one agent run. Source of truth: current code in `src/`.
|
||||||
|
|
||||||
|
## Entry points
|
||||||
|
- Gateway RPC: `agent` and `agent.wait` in `src/gateway/server-methods/agent.ts`.
|
||||||
|
- CLI: `agentCommand` in `src/commands/agent.ts`.
|
||||||
|
|
||||||
|
## High-level flow
|
||||||
|
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
|
||||||
|
2) `agentCommand` runs the agent:
|
||||||
|
- resolves model + thinking/verbose defaults
|
||||||
|
- loads skills snapshot
|
||||||
|
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
||||||
|
- emits **lifecycle end/error** if the embedded loop does not emit one
|
||||||
|
3) `runEmbeddedPiAgent`:
|
||||||
|
- builds `AgentSession` and subscribes to pi events
|
||||||
|
- streams assistant deltas + tool events
|
||||||
|
- enforces timeout -> aborts run if exceeded
|
||||||
|
- returns payloads + usage metadata
|
||||||
|
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdis `agent` stream:
|
||||||
|
- tool events => `stream: "tool"`
|
||||||
|
- assistant deltas => `stream: "assistant"`
|
||||||
|
- lifecycle events => `stream: "lifecycle"` (`phase: "start" | "end" | "error"`)
|
||||||
|
5) `agent.wait` uses `waitForAgentJob`:
|
||||||
|
- waits for **lifecycle end/error** for `runId`
|
||||||
|
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
|
||||||
|
|
||||||
|
## Event streams (today)
|
||||||
|
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
|
||||||
|
- `assistant`: streamed deltas from pi-agent-core
|
||||||
|
- `tool`: streamed tool events from pi-agent-core
|
||||||
|
|
||||||
|
## Chat surface handling
|
||||||
|
- `createAgentEventHandler` in `src/gateway/server-chat.ts`:
|
||||||
|
- buffers assistant deltas
|
||||||
|
- emits chat `delta` messages
|
||||||
|
- emits chat `final` when **lifecycle end/error** arrives
|
||||||
|
|
||||||
|
## Timeouts
|
||||||
|
- `agent.wait` default: 30s (just the wait). `timeoutMs` param overrides.
|
||||||
|
- Agent runtime: `agent.timeoutSeconds` default 600s; enforced in `runEmbeddedPiAgent` abort timer.
|
||||||
|
|
||||||
|
## Where things can end early
|
||||||
|
- Agent timeout (abort)
|
||||||
|
- AbortSignal (cancel)
|
||||||
|
- Gateway disconnect or RPC timeout
|
||||||
|
- `agent.wait` timeout (wait-only, does not stop agent)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `src/gateway/server-methods/agent.ts`
|
||||||
|
- `src/gateway/server-methods/agent-job.ts`
|
||||||
|
- `src/commands/agent.ts`
|
||||||
|
- `src/agents/pi-embedded-runner.ts`
|
||||||
|
- `src/agents/pi-embedded-subscribe.ts`
|
||||||
|
- `src/gateway/server-chat.ts`
|
||||||
65
docs/refactor/agent-loop.md
Normal file
65
docs/refactor/agent-loop.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
summary: "Refactor plan: unify agent lifecycle events and wait semantics"
|
||||||
|
read_when:
|
||||||
|
- Refactoring agent lifecycle events or wait behavior
|
||||||
|
---
|
||||||
|
# Refactor: Agent Loop
|
||||||
|
|
||||||
|
Goal: align Clawdis run lifecycle with pi/mom semantics, remove ambiguity between "job" and "agent_end".
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
- Two lifecycles today:
|
||||||
|
- `job` (gateway wrapper) => used by `agent.wait` + chat final
|
||||||
|
- pi-agent `agent_end` (inner loop) => only logged
|
||||||
|
- This can finalize early (job done) while late assistant deltas still arrive.
|
||||||
|
- `afterMs` and timeouts can cause false timeouts in `agent.wait`.
|
||||||
|
|
||||||
|
## Reference (mom)
|
||||||
|
- Single lifecycle: `agent_start`/`agent_end` from pi-agent-core event stream.
|
||||||
|
- `waitForIdle()` resolves on `agent_end`.
|
||||||
|
- No separate job state exposed to clients.
|
||||||
|
|
||||||
|
## Proposed refactor (breaking allowed)
|
||||||
|
1) Replace public `job` stream with `lifecycle` stream
|
||||||
|
- `stream: "lifecycle"`
|
||||||
|
- `data: { phase: "start" | "end" | "error", startedAt, endedAt, error? }`
|
||||||
|
2) `agent.wait` waits on lifecycle end/error only
|
||||||
|
- remove `afterMs`
|
||||||
|
- return `{ runId, status, startedAt, endedAt, error? }`
|
||||||
|
3) Chat final emitted on lifecycle end only
|
||||||
|
- deltas still from `assistant` stream
|
||||||
|
4) Centralize run registry
|
||||||
|
- one map keyed by runId: sessionKey, startedAt, lastSeq, bufferedText
|
||||||
|
- clear on lifecycle end
|
||||||
|
|
||||||
|
## Implementation outline
|
||||||
|
- `src/agents/pi-embedded-subscribe.ts`
|
||||||
|
- emit lifecycle start/end events (translate pi `agent_start`/`agent_end`)
|
||||||
|
- `src/infra/agent-events.ts`
|
||||||
|
- add `"lifecycle"` to stream type
|
||||||
|
- `src/gateway/protocol/schema.ts`
|
||||||
|
- update AgentEvent schema; update AgentWait params (remove afterMs, add status)
|
||||||
|
- `src/gateway/server-methods/agent-job.ts`
|
||||||
|
- rename to `agent-wait.ts` or similar; wait on lifecycle end/error
|
||||||
|
- `src/gateway/server-chat.ts`
|
||||||
|
- finalize on lifecycle end (not job)
|
||||||
|
- `src/commands/agent.ts`
|
||||||
|
- stop emitting `job` externally (keep internal log if needed)
|
||||||
|
|
||||||
|
## Migration notes (breaking)
|
||||||
|
- Update all callers of `agent.wait` to new response shape.
|
||||||
|
- Update tests that expect `timeout` based on job events.
|
||||||
|
- If any UI relies on job state, map lifecycle instead.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- If lifecycle events are dropped, wait/chat could hang; add timeout in `agent.wait` to fail fast.
|
||||||
|
- Late deltas after lifecycle end should be ignored; keep seq tracking + drop.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
- One lifecycle visible to clients.
|
||||||
|
- `agent.wait` resolves when agent loop ends, not wrapper completion.
|
||||||
|
- Chat final never emits before last assistant delta.
|
||||||
|
|
||||||
|
## Rollout (if we wanted safety)
|
||||||
|
- Gate with config flag `agent.lifecycleMode = "legacy"|"refactor"`.
|
||||||
|
- Remove legacy after one release.
|
||||||
@@ -261,12 +261,6 @@ describe("sessions tools", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(waitCalls).toHaveLength(8);
|
expect(waitCalls).toHaveLength(8);
|
||||||
expect(historyOnlyCalls).toHaveLength(8);
|
expect(historyOnlyCalls).toHaveLength(8);
|
||||||
expect(
|
|
||||||
waitCalls.some(
|
|
||||||
(call) =>
|
|
||||||
typeof (call.params as { afterMs?: number })?.afterMs === "number",
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(sendCallCount).toBe(0);
|
expect(sendCallCount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -616,6 +616,18 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
|
|
||||||
if (evt.type === "agent_start") {
|
if (evt.type === "agent_start") {
|
||||||
log.debug(`embedded run agent start: runId=${params.runId}`);
|
log.debug(`embedded run agent start: runId=${params.runId}`);
|
||||||
|
emitAgentEvent({
|
||||||
|
runId: params.runId,
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: {
|
||||||
|
phase: "start",
|
||||||
|
startedAt: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
params.onAgentEvent?.({
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: { phase: "start" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.type === "auto_compaction_start") {
|
if (evt.type === "auto_compaction_start") {
|
||||||
@@ -638,6 +650,18 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
|
|
||||||
if (evt.type === "agent_end") {
|
if (evt.type === "agent_end") {
|
||||||
log.debug(`embedded run agent end: runId=${params.runId}`);
|
log.debug(`embedded run agent end: runId=${params.runId}`);
|
||||||
|
emitAgentEvent({
|
||||||
|
runId: params.runId,
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: {
|
||||||
|
phase: "end",
|
||||||
|
endedAt: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
params.onAgentEvent?.({
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: { phase: "end" },
|
||||||
|
});
|
||||||
if (pendingCompactionRetry > 0) {
|
if (pendingCompactionRetry > 0) {
|
||||||
resolveCompactionRetry();
|
resolveCompactionRetry();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -151,16 +151,11 @@ export function createSessionsSendTool(opts?: {
|
|||||||
typeof response?.runId === "string" && response.runId
|
typeof response?.runId === "string" && response.runId
|
||||||
? response.runId
|
? response.runId
|
||||||
: stepIdem;
|
: stepIdem;
|
||||||
const stepAcceptedAt =
|
|
||||||
typeof response?.acceptedAt === "number"
|
|
||||||
? response.acceptedAt
|
|
||||||
: undefined;
|
|
||||||
const stepWaitMs = Math.min(step.timeoutMs, 60_000);
|
const stepWaitMs = Math.min(step.timeoutMs, 60_000);
|
||||||
const wait = (await callGateway({
|
const wait = (await callGateway({
|
||||||
method: "agent.wait",
|
method: "agent.wait",
|
||||||
params: {
|
params: {
|
||||||
runId: stepRunId,
|
runId: stepRunId,
|
||||||
afterMs: stepAcceptedAt,
|
|
||||||
timeoutMs: stepWaitMs,
|
timeoutMs: stepWaitMs,
|
||||||
},
|
},
|
||||||
timeoutMs: stepWaitMs + 2000,
|
timeoutMs: stepWaitMs + 2000,
|
||||||
@@ -171,7 +166,7 @@ export function createSessionsSendTool(opts?: {
|
|||||||
|
|
||||||
const runAgentToAgentFlow = async (
|
const runAgentToAgentFlow = async (
|
||||||
roundOneReply?: string,
|
roundOneReply?: string,
|
||||||
runInfo?: { runId: string; acceptedAt?: number },
|
runInfo?: { runId: string },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
let primaryReply = roundOneReply;
|
let primaryReply = roundOneReply;
|
||||||
@@ -182,7 +177,6 @@ export function createSessionsSendTool(opts?: {
|
|||||||
method: "agent.wait",
|
method: "agent.wait",
|
||||||
params: {
|
params: {
|
||||||
runId: runInfo.runId,
|
runId: runInfo.runId,
|
||||||
afterMs: runInfo.acceptedAt,
|
|
||||||
timeoutMs: waitMs,
|
timeoutMs: waitMs,
|
||||||
},
|
},
|
||||||
timeoutMs: waitMs + 2000,
|
timeoutMs: waitMs + 2000,
|
||||||
@@ -277,14 +271,10 @@ export function createSessionsSendTool(opts?: {
|
|||||||
params: sendParams,
|
params: sendParams,
|
||||||
timeoutMs: 10_000,
|
timeoutMs: 10_000,
|
||||||
})) as { runId?: string; acceptedAt?: number };
|
})) as { runId?: string; acceptedAt?: number };
|
||||||
const acceptedAt =
|
|
||||||
typeof response?.acceptedAt === "number"
|
|
||||||
? response.acceptedAt
|
|
||||||
: undefined;
|
|
||||||
if (typeof response?.runId === "string" && response.runId) {
|
if (typeof response?.runId === "string" && response.runId) {
|
||||||
runId = response.runId;
|
runId = response.runId;
|
||||||
}
|
}
|
||||||
void runAgentToAgentFlow(undefined, { runId, acceptedAt });
|
void runAgentToAgentFlow(undefined, { runId });
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
runId,
|
runId,
|
||||||
status: "accepted",
|
status: "accepted",
|
||||||
@@ -306,7 +296,6 @@ export function createSessionsSendTool(opts?: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let acceptedAt: number | undefined;
|
|
||||||
try {
|
try {
|
||||||
const response = (await callGateway({
|
const response = (await callGateway({
|
||||||
method: "agent",
|
method: "agent",
|
||||||
@@ -316,9 +305,6 @@ export function createSessionsSendTool(opts?: {
|
|||||||
if (typeof response?.runId === "string" && response.runId) {
|
if (typeof response?.runId === "string" && response.runId) {
|
||||||
runId = response.runId;
|
runId = response.runId;
|
||||||
}
|
}
|
||||||
if (typeof response?.acceptedAt === "number") {
|
|
||||||
acceptedAt = response.acceptedAt;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const messageText =
|
const messageText =
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
@@ -341,7 +327,6 @@ export function createSessionsSendTool(opts?: {
|
|||||||
method: "agent.wait",
|
method: "agent.wait",
|
||||||
params: {
|
params: {
|
||||||
runId,
|
runId,
|
||||||
afterMs: acceptedAt,
|
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
},
|
},
|
||||||
timeoutMs: timeoutMs + 2000,
|
timeoutMs: timeoutMs + 2000,
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
68f18193053997f3dee16de6b0be0bcd97dc70ff8200c77f687479e8b19b78e1
|
971128267a38be786e60f2e900770da48ea305c8cebe6c53ab56e6ea86d924df
|
||||||
||||||| Stash base
|
|
||||||
7daf1cbf58ef395b74c2690c439ac7b3cb536e8eb124baf72ad41da4f542204d
|
|
||||||
|
|||||||
@@ -352,17 +352,7 @@ export async function agentCommand(
|
|||||||
const sessionFile = resolveSessionTranscriptPath(sessionId);
|
const sessionFile = resolveSessionTranscriptPath(sessionId);
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
emitAgentEvent({
|
let lifecycleEnded = false;
|
||||||
runId,
|
|
||||||
stream: "job",
|
|
||||||
data: {
|
|
||||||
state: "started",
|
|
||||||
startedAt,
|
|
||||||
to: opts.to ?? null,
|
|
||||||
sessionId,
|
|
||||||
isNewSession,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let result: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
let result: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||||
let fallbackProvider = provider;
|
let fallbackProvider = provider;
|
||||||
@@ -399,6 +389,13 @@ export async function agentCommand(
|
|||||||
abortSignal: opts.abortSignal,
|
abortSignal: opts.abortSignal,
|
||||||
extraSystemPrompt: opts.extraSystemPrompt,
|
extraSystemPrompt: opts.extraSystemPrompt,
|
||||||
onAgentEvent: (evt) => {
|
onAgentEvent: (evt) => {
|
||||||
|
if (
|
||||||
|
evt.stream === "lifecycle" &&
|
||||||
|
typeof evt.data?.phase === "string" &&
|
||||||
|
(evt.data.phase === "end" || evt.data.phase === "error")
|
||||||
|
) {
|
||||||
|
lifecycleEnded = true;
|
||||||
|
}
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId,
|
runId,
|
||||||
stream: evt.stream,
|
stream: evt.stream,
|
||||||
@@ -410,33 +407,31 @@ export async function agentCommand(
|
|||||||
result = fallbackResult.result;
|
result = fallbackResult.result;
|
||||||
fallbackProvider = fallbackResult.provider;
|
fallbackProvider = fallbackResult.provider;
|
||||||
fallbackModel = fallbackResult.model;
|
fallbackModel = fallbackResult.model;
|
||||||
|
if (!lifecycleEnded) {
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId,
|
runId,
|
||||||
stream: "job",
|
stream: "lifecycle",
|
||||||
data: {
|
data: {
|
||||||
state: "done",
|
phase: "end",
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt: Date.now(),
|
endedAt: Date.now(),
|
||||||
to: opts.to ?? null,
|
|
||||||
sessionId,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
aborted: result.meta.aborted ?? false,
|
aborted: result.meta.aborted ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!lifecycleEnded) {
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId,
|
runId,
|
||||||
stream: "job",
|
stream: "lifecycle",
|
||||||
data: {
|
data: {
|
||||||
state: "error",
|
phase: "error",
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt: Date.now(),
|
endedAt: Date.now(),
|
||||||
to: opts.to ?? null,
|
|
||||||
sessionId,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
error: String(err),
|
error: String(err),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -218,7 +218,6 @@ export const AgentParamsSchema = Type.Object(
|
|||||||
export const AgentWaitParamsSchema = Type.Object(
|
export const AgentWaitParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
runId: NonEmptyString,
|
runId: NonEmptyString,
|
||||||
afterMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
||||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
|
|||||||
@@ -208,9 +208,9 @@ export function createAgentEventHandler({
|
|||||||
agentRunSeq.set(evt.runId, evt.seq);
|
agentRunSeq.set(evt.runId, evt.seq);
|
||||||
broadcast("agent", agentPayload);
|
broadcast("agent", agentPayload);
|
||||||
|
|
||||||
const jobState =
|
const lifecyclePhase =
|
||||||
evt.stream === "job" && typeof evt.data?.state === "string"
|
evt.stream === "lifecycle" && typeof evt.data?.phase === "string"
|
||||||
? evt.data.state
|
? evt.data.phase
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (sessionKey) {
|
if (sessionKey) {
|
||||||
@@ -218,7 +218,7 @@ export function createAgentEventHandler({
|
|||||||
if (evt.stream === "assistant" && typeof evt.data?.text === "string") {
|
if (evt.stream === "assistant" && typeof evt.data?.text === "string") {
|
||||||
const clientRunId = chatLink?.clientRunId ?? evt.runId;
|
const clientRunId = chatLink?.clientRunId ?? evt.runId;
|
||||||
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
|
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
|
||||||
} else if (jobState === "done" || jobState === "error") {
|
} else if (lifecyclePhase === "end" || lifecyclePhase === "error") {
|
||||||
if (chatLink) {
|
if (chatLink) {
|
||||||
const finished = chatRunState.registry.shift(evt.runId);
|
const finished = chatRunState.registry.shift(evt.runId);
|
||||||
if (!finished) {
|
if (!finished) {
|
||||||
@@ -229,7 +229,7 @@ export function createAgentEventHandler({
|
|||||||
finished.sessionKey,
|
finished.sessionKey,
|
||||||
finished.clientRunId,
|
finished.clientRunId,
|
||||||
evt.seq,
|
evt.seq,
|
||||||
jobState,
|
lifecyclePhase === "error" ? "error" : "done",
|
||||||
evt.data?.error,
|
evt.data?.error,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -237,14 +237,14 @@ export function createAgentEventHandler({
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
evt.runId,
|
evt.runId,
|
||||||
evt.seq,
|
evt.seq,
|
||||||
jobState,
|
lifecyclePhase === "error" ? "error" : "done",
|
||||||
evt.data?.error,
|
evt.data?.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jobState === "done" || jobState === "error") {
|
if (lifecyclePhase === "end" || lifecyclePhase === "error") {
|
||||||
clearAgentRunContext(evt.runId);
|
clearAgentRunContext(evt.runId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,50 +1,48 @@
|
|||||||
import { onAgentEvent } from "../../infra/agent-events.js";
|
import { onAgentEvent } from "../../infra/agent-events.js";
|
||||||
|
|
||||||
const AGENT_JOB_CACHE_TTL_MS = 10 * 60_000;
|
const AGENT_RUN_CACHE_TTL_MS = 10 * 60_000;
|
||||||
const agentJobCache = new Map<string, AgentJobSnapshot>();
|
const agentRunCache = new Map<string, AgentRunSnapshot>();
|
||||||
const agentRunStarts = new Map<string, number>();
|
const agentRunStarts = new Map<string, number>();
|
||||||
let agentJobListenerStarted = false;
|
let agentRunListenerStarted = false;
|
||||||
|
|
||||||
type AgentJobSnapshot = {
|
type AgentRunSnapshot = {
|
||||||
runId: string;
|
runId: string;
|
||||||
state: "done" | "error";
|
status: "ok" | "error";
|
||||||
startedAt?: number;
|
startedAt?: number;
|
||||||
endedAt?: number;
|
endedAt?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
ts: number;
|
ts: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function pruneAgentJobCache(now = Date.now()) {
|
function pruneAgentRunCache(now = Date.now()) {
|
||||||
for (const [runId, entry] of agentJobCache) {
|
for (const [runId, entry] of agentRunCache) {
|
||||||
if (now - entry.ts > AGENT_JOB_CACHE_TTL_MS) {
|
if (now - entry.ts > AGENT_RUN_CACHE_TTL_MS) {
|
||||||
agentJobCache.delete(runId);
|
agentRunCache.delete(runId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordAgentJobSnapshot(entry: AgentJobSnapshot) {
|
function recordAgentRunSnapshot(entry: AgentRunSnapshot) {
|
||||||
pruneAgentJobCache(entry.ts);
|
pruneAgentRunCache(entry.ts);
|
||||||
agentJobCache.set(entry.runId, entry);
|
agentRunCache.set(entry.runId, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureAgentJobListener() {
|
function ensureAgentRunListener() {
|
||||||
if (agentJobListenerStarted) return;
|
if (agentRunListenerStarted) return;
|
||||||
agentJobListenerStarted = true;
|
agentRunListenerStarted = true;
|
||||||
onAgentEvent((evt) => {
|
onAgentEvent((evt) => {
|
||||||
if (!evt) return;
|
if (!evt) return;
|
||||||
if (evt.stream !== "job") return;
|
if (evt.stream !== "lifecycle") return;
|
||||||
const state = evt.data?.state;
|
const phase = evt.data?.phase;
|
||||||
if (state === "started") {
|
if (phase === "start") {
|
||||||
const startedAt =
|
const startedAt =
|
||||||
typeof evt.data?.startedAt === "number"
|
typeof evt.data?.startedAt === "number"
|
||||||
? (evt.data.startedAt as number)
|
? (evt.data.startedAt as number)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (startedAt !== undefined) {
|
agentRunStarts.set(evt.runId, startedAt ?? Date.now());
|
||||||
agentRunStarts.set(evt.runId, startedAt);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state !== "done" && state !== "error") return;
|
if (phase !== "end" && phase !== "error") return;
|
||||||
const startedAt =
|
const startedAt =
|
||||||
typeof evt.data?.startedAt === "number"
|
typeof evt.data?.startedAt === "number"
|
||||||
? (evt.data.startedAt as number)
|
? (evt.data.startedAt as number)
|
||||||
@@ -58,9 +56,9 @@ function ensureAgentJobListener() {
|
|||||||
? (evt.data.error as string)
|
? (evt.data.error as string)
|
||||||
: undefined;
|
: undefined;
|
||||||
agentRunStarts.delete(evt.runId);
|
agentRunStarts.delete(evt.runId);
|
||||||
recordAgentJobSnapshot({
|
recordAgentRunSnapshot({
|
||||||
runId: evt.runId,
|
runId: evt.runId,
|
||||||
state: state === "error" ? "error" : "done",
|
status: phase === "error" ? "error" : "ok",
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt,
|
endedAt,
|
||||||
error,
|
error,
|
||||||
@@ -69,34 +67,24 @@ function ensureAgentJobListener() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesAfterMs(entry: AgentJobSnapshot, afterMs?: number) {
|
function getCachedAgentRun(runId: string) {
|
||||||
if (afterMs === undefined) return true;
|
pruneAgentRunCache();
|
||||||
if (typeof entry.startedAt === "number") return entry.startedAt >= afterMs;
|
return agentRunCache.get(runId);
|
||||||
if (typeof entry.endedAt === "number") return entry.endedAt >= afterMs;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCachedAgentJob(runId: string, afterMs?: number) {
|
|
||||||
pruneAgentJobCache();
|
|
||||||
const cached = agentJobCache.get(runId);
|
|
||||||
if (!cached) return undefined;
|
|
||||||
return matchesAfterMs(cached, afterMs) ? cached : undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForAgentJob(params: {
|
export async function waitForAgentJob(params: {
|
||||||
runId: string;
|
runId: string;
|
||||||
afterMs?: number;
|
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
}): Promise<AgentJobSnapshot | null> {
|
}): Promise<AgentRunSnapshot | null> {
|
||||||
const { runId, afterMs, timeoutMs } = params;
|
const { runId, timeoutMs } = params;
|
||||||
ensureAgentJobListener();
|
ensureAgentRunListener();
|
||||||
const cached = getCachedAgentJob(runId, afterMs);
|
const cached = getCachedAgentRun(runId);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
if (timeoutMs <= 0) return null;
|
if (timeoutMs <= 0) return null;
|
||||||
|
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const finish = (entry: AgentJobSnapshot | null) => {
|
const finish = (entry: AgentRunSnapshot | null) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
@@ -104,10 +92,15 @@ export async function waitForAgentJob(params: {
|
|||||||
resolve(entry);
|
resolve(entry);
|
||||||
};
|
};
|
||||||
const unsubscribe = onAgentEvent((evt) => {
|
const unsubscribe = onAgentEvent((evt) => {
|
||||||
if (!evt || evt.stream !== "job") return;
|
if (!evt || evt.stream !== "lifecycle") return;
|
||||||
if (evt.runId !== runId) return;
|
if (evt.runId !== runId) return;
|
||||||
const state = evt.data?.state;
|
const phase = evt.data?.phase;
|
||||||
if (state !== "done" && state !== "error") return;
|
if (phase !== "end" && phase !== "error") return;
|
||||||
|
const cached = getCachedAgentRun(runId);
|
||||||
|
if (cached) {
|
||||||
|
finish(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const startedAt =
|
const startedAt =
|
||||||
typeof evt.data?.startedAt === "number"
|
typeof evt.data?.startedAt === "number"
|
||||||
? (evt.data.startedAt as number)
|
? (evt.data.startedAt as number)
|
||||||
@@ -120,20 +113,19 @@ export async function waitForAgentJob(params: {
|
|||||||
typeof evt.data?.error === "string"
|
typeof evt.data?.error === "string"
|
||||||
? (evt.data.error as string)
|
? (evt.data.error as string)
|
||||||
: undefined;
|
: undefined;
|
||||||
const snapshot: AgentJobSnapshot = {
|
const snapshot: AgentRunSnapshot = {
|
||||||
runId: evt.runId,
|
runId: evt.runId,
|
||||||
state: state === "error" ? "error" : "done",
|
status: phase === "error" ? "error" : "ok",
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt,
|
endedAt,
|
||||||
error,
|
error,
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
};
|
};
|
||||||
recordAgentJobSnapshot(snapshot);
|
recordAgentRunSnapshot(snapshot);
|
||||||
if (!matchesAfterMs(snapshot, afterMs)) return;
|
|
||||||
finish(snapshot);
|
finish(snapshot);
|
||||||
});
|
});
|
||||||
const timer = setTimeout(() => finish(null), Math.max(1, timeoutMs));
|
const timer = setTimeout(() => finish(null), Math.max(1, timeoutMs));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureAgentJobListener();
|
ensureAgentRunListener();
|
||||||
|
|||||||
@@ -288,10 +288,6 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
const p = params as AgentWaitParams;
|
const p = params as AgentWaitParams;
|
||||||
const runId = p.runId.trim();
|
const runId = p.runId.trim();
|
||||||
const afterMs =
|
|
||||||
typeof p.afterMs === "number" && Number.isFinite(p.afterMs)
|
|
||||||
? Math.max(0, Math.floor(p.afterMs))
|
|
||||||
: undefined;
|
|
||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
typeof p.timeoutMs === "number" && Number.isFinite(p.timeoutMs)
|
typeof p.timeoutMs === "number" && Number.isFinite(p.timeoutMs)
|
||||||
? Math.max(0, Math.floor(p.timeoutMs))
|
? Math.max(0, Math.floor(p.timeoutMs))
|
||||||
@@ -299,7 +295,6 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
|
|
||||||
const snapshot = await waitForAgentJob({
|
const snapshot = await waitForAgentJob({
|
||||||
runId,
|
runId,
|
||||||
afterMs,
|
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
});
|
});
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
@@ -311,7 +306,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
respond(true, {
|
respond(true, {
|
||||||
runId,
|
runId,
|
||||||
status: snapshot.state === "done" ? "ok" : "error",
|
status: snapshot.status,
|
||||||
startedAt: snapshot.startedAt,
|
startedAt: snapshot.startedAt,
|
||||||
endedAt: snapshot.endedAt,
|
endedAt: snapshot.endedAt,
|
||||||
error: snapshot.error,
|
error: snapshot.error,
|
||||||
|
|||||||
@@ -463,8 +463,8 @@ describe("gateway server agent", () => {
|
|||||||
});
|
});
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId: "run-auto-1",
|
runId: "run-auto-1",
|
||||||
stream: "job",
|
stream: "lifecycle",
|
||||||
data: { state: "done" },
|
data: { phase: "end" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const evt = await finalChatP;
|
const evt = await finalChatP;
|
||||||
@@ -518,21 +518,20 @@ describe("gateway server agent", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("agent.wait resolves after job completes", async () => {
|
test("agent.wait resolves after lifecycle end", async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
|
|
||||||
const waitP = rpcReq(ws, "agent.wait", {
|
const waitP = rpcReq(ws, "agent.wait", {
|
||||||
runId: "run-wait-1",
|
runId: "run-wait-1",
|
||||||
afterMs: 100,
|
|
||||||
timeoutMs: 1000,
|
timeoutMs: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId: "run-wait-1",
|
runId: "run-wait-1",
|
||||||
stream: "job",
|
stream: "lifecycle",
|
||||||
data: { state: "done", startedAt: 200, endedAt: 210 },
|
data: { phase: "end", startedAt: 200, endedAt: 210 },
|
||||||
});
|
});
|
||||||
}, 10);
|
}, 10);
|
||||||
|
|
||||||
@@ -545,14 +544,14 @@ describe("gateway server agent", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("agent.wait resolves when job completed before wait call", async () => {
|
test("agent.wait resolves when lifecycle ended before wait call", async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
|
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId: "run-wait-early",
|
runId: "run-wait-early",
|
||||||
stream: "job",
|
stream: "lifecycle",
|
||||||
data: { state: "done", startedAt: 50, endedAt: 55 },
|
data: { phase: "end", startedAt: 50, endedAt: 55 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await rpcReq(ws, "agent.wait", {
|
const res = await rpcReq(ws, "agent.wait", {
|
||||||
@@ -567,41 +566,7 @@ describe("gateway server agent", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("agent.wait ignores jobs before afterMs", async () => {
|
test("agent.wait times out when no lifecycle ends", async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
|
||||||
await connectOk(ws);
|
|
||||||
|
|
||||||
const waitP = rpcReq(ws, "agent.wait", {
|
|
||||||
runId: "run-wait-2",
|
|
||||||
afterMs: 500,
|
|
||||||
timeoutMs: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
emitAgentEvent({
|
|
||||||
runId: "run-wait-2",
|
|
||||||
stream: "job",
|
|
||||||
data: { state: "done", startedAt: 200, endedAt: 220 },
|
|
||||||
});
|
|
||||||
}, 10);
|
|
||||||
setTimeout(() => {
|
|
||||||
emitAgentEvent({
|
|
||||||
runId: "run-wait-2",
|
|
||||||
stream: "job",
|
|
||||||
data: { state: "done", startedAt: 700, endedAt: 710 },
|
|
||||||
});
|
|
||||||
}, 20);
|
|
||||||
|
|
||||||
const res = await waitP;
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
expect(res.payload.status).toBe("ok");
|
|
||||||
expect(res.payload.startedAt).toBe(700);
|
|
||||||
|
|
||||||
ws.close();
|
|
||||||
await server.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("agent.wait times out when no job completes", async () => {
|
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
|
|
||||||
@@ -615,4 +580,63 @@ describe("gateway server agent", () => {
|
|||||||
ws.close();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("agent.wait returns error on lifecycle error", async () => {
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const waitP = rpcReq(ws, "agent.wait", {
|
||||||
|
runId: "run-wait-err",
|
||||||
|
timeoutMs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
emitAgentEvent({
|
||||||
|
runId: "run-wait-err",
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: { phase: "error", error: "boom" },
|
||||||
|
});
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
const res = await waitP;
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.payload.status).toBe("error");
|
||||||
|
expect(res.payload.error).toBe("boom");
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agent.wait uses lifecycle start timestamp when end omits it", async () => {
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const waitP = rpcReq(ws, "agent.wait", {
|
||||||
|
runId: "run-wait-start",
|
||||||
|
timeoutMs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
emitAgentEvent({
|
||||||
|
runId: "run-wait-start",
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: { phase: "start", startedAt: 123 },
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
emitAgentEvent({
|
||||||
|
runId: "run-wait-start",
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: { phase: "end", endedAt: 456 },
|
||||||
|
});
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
const res = await waitP;
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.payload.status).toBe("ok");
|
||||||
|
expect(res.payload.startedAt).toBe(123);
|
||||||
|
expect(res.payload.endedAt).toBe(456);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -831,8 +831,8 @@ describe("gateway server chat", () => {
|
|||||||
|
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId: "sess-main",
|
runId: "sess-main",
|
||||||
stream: "job",
|
stream: "lifecycle",
|
||||||
data: { state: "done" },
|
data: { phase: "end" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const final1 = await final1P;
|
const final1 = await final1P;
|
||||||
@@ -853,8 +853,8 @@ describe("gateway server chat", () => {
|
|||||||
|
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId: "sess-main",
|
runId: "sess-main",
|
||||||
stream: "job",
|
stream: "lifecycle",
|
||||||
data: { state: "done" },
|
data: { phase: "end" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const final2 = await final2P;
|
const final2 = await final2P;
|
||||||
|
|||||||
@@ -173,9 +173,9 @@ describe("gateway server health/presence", () => {
|
|||||||
o.type === "event" &&
|
o.type === "event" &&
|
||||||
o.event === "agent" &&
|
o.event === "agent" &&
|
||||||
o.payload?.runId === runId &&
|
o.payload?.runId === runId &&
|
||||||
o.payload?.stream === "job",
|
o.payload?.stream === "lifecycle",
|
||||||
);
|
);
|
||||||
emitAgentEvent({ runId, stream: "job", data: { msg: "hi" } });
|
emitAgentEvent({ runId, stream: "lifecycle", data: { msg: "hi" } });
|
||||||
const evt = await evtPromise;
|
const evt = await evtPromise;
|
||||||
expect(evt.payload.runId).toBe(runId);
|
expect(evt.payload.runId).toBe(runId);
|
||||||
expect(typeof evt.seq).toBe("number");
|
expect(typeof evt.seq).toBe("number");
|
||||||
|
|||||||
@@ -699,8 +699,8 @@ describe("gateway server node/bridge", () => {
|
|||||||
runId: "sess-main",
|
runId: "sess-main",
|
||||||
seq: 2,
|
seq: 2,
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
stream: "job",
|
stream: "lifecycle",
|
||||||
data: { state: "done" },
|
data: { phase: "end" },
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 25));
|
await new Promise((r) => setTimeout(r, 25));
|
||||||
@@ -841,8 +841,8 @@ describe("gateway server node/bridge", () => {
|
|||||||
runId: "sess-main",
|
runId: "sess-main",
|
||||||
seq: 2,
|
seq: 2,
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
stream: "job",
|
stream: "lifecycle",
|
||||||
data: { state: "done" },
|
data: { phase: "end" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const evt = await finalChatP;
|
const evt = await finalChatP;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
installGatewayTestHooks();
|
installGatewayTestHooks();
|
||||||
|
|
||||||
describe("sessions_send gateway loopback", () => {
|
describe("sessions_send gateway loopback", () => {
|
||||||
it("returns reply when job finishes before agent.wait", async () => {
|
it("returns reply when lifecycle ends before agent.wait", async () => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
|
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
|
||||||
@@ -35,8 +35,8 @@ describe("sessions_send gateway loopback", () => {
|
|||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId,
|
runId,
|
||||||
stream: "job",
|
stream: "lifecycle",
|
||||||
data: { state: "started", startedAt, sessionId },
|
data: { phase: "start", startedAt },
|
||||||
});
|
});
|
||||||
|
|
||||||
let text = "pong";
|
let text = "pong";
|
||||||
@@ -60,12 +60,11 @@ describe("sessions_send gateway loopback", () => {
|
|||||||
|
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId,
|
runId,
|
||||||
stream: "job",
|
stream: "lifecycle",
|
||||||
data: {
|
data: {
|
||||||
state: "done",
|
phase: "end",
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt: Date.now(),
|
endedAt: Date.now(),
|
||||||
sessionId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -121,13 +121,9 @@ export function summarizeAgentEventForWsLog(
|
|||||||
return extra;
|
return extra;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stream === "job") {
|
if (stream === "lifecycle") {
|
||||||
const state = typeof data.state === "string" ? data.state : undefined;
|
const phase = typeof data.phase === "string" ? data.phase : undefined;
|
||||||
if (state) extra.state = state;
|
if (phase) extra.phase = phase;
|
||||||
if (data.to === null) extra.to = null;
|
|
||||||
else if (typeof data.to === "string") extra.to = data.to;
|
|
||||||
if (typeof data.durationMs === "number")
|
|
||||||
extra.ms = Math.round(data.durationMs);
|
|
||||||
if (typeof data.aborted === "boolean") extra.aborted = data.aborted;
|
if (typeof data.aborted === "boolean") extra.aborted = data.aborted;
|
||||||
const error = typeof data.error === "string" ? data.error : undefined;
|
const error = typeof data.error === "string" ? data.error : undefined;
|
||||||
if (error?.trim()) extra.error = compactPreview(error, 120);
|
if (error?.trim()) extra.error = compactPreview(error, 120);
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ describe("agent-events sequencing", () => {
|
|||||||
list.push(evt.seq);
|
list.push(evt.seq);
|
||||||
});
|
});
|
||||||
|
|
||||||
emitAgentEvent({ runId: "run-1", stream: "job", data: {} });
|
emitAgentEvent({ runId: "run-1", stream: "lifecycle", data: {} });
|
||||||
emitAgentEvent({ runId: "run-1", stream: "job", data: {} });
|
emitAgentEvent({ runId: "run-1", stream: "lifecycle", data: {} });
|
||||||
emitAgentEvent({ runId: "run-2", stream: "job", data: {} });
|
emitAgentEvent({ runId: "run-2", stream: "lifecycle", data: {} });
|
||||||
emitAgentEvent({ runId: "run-1", stream: "job", data: {} });
|
emitAgentEvent({ runId: "run-1", stream: "lifecycle", data: {} });
|
||||||
|
|
||||||
stop();
|
stop();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type AgentEventStream =
|
export type AgentEventStream =
|
||||||
| "job"
|
| "lifecycle"
|
||||||
| "tool"
|
| "tool"
|
||||||
| "assistant"
|
| "assistant"
|
||||||
| "error"
|
| "error"
|
||||||
|
|||||||
@@ -375,11 +375,11 @@ export async function runTui(opts: TuiOptions) {
|
|||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (evt.stream === "job") {
|
if (evt.stream === "lifecycle") {
|
||||||
const state = typeof evt.data?.state === "string" ? evt.data.state : "";
|
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : "";
|
||||||
if (state === "started") setStatus("running");
|
if (phase === "start") setStatus("running");
|
||||||
if (state === "done") setStatus("idle");
|
if (phase === "end") setStatus("idle");
|
||||||
if (state === "error") setStatus("error");
|
if (phase === "error") setStatus("error");
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user