Keep agent timelines stable from submission through resume (#2718)
* Reapply: Stop completed turns from appearing stuck (#2484) * Reapply: Keep older chat history and image previews stable (#2490) * Lock timeline layout stability with user-driven specs Two reds reproduce the reported jank: - new agent, first prompt: the row paints at top=100 then jumps to 144 through authoritative hydration, because the draft tab and the agent panel each render their own AgentStreamView for the same logical row. - long loaded timeline, submit: scrollHeight collapses 10793 -> 9111 mid-turn, because splitOrderedTail reclassifies measured rows into the virtualized segment where the virtualizer falls back to estimateStreamItemHeight. Also restores centered timeline rows: the pagination anchor wrapper introduced in #2490 was a plain block div, which broke alignSelf: center. * Measure the turn footer blink from when the footer appears Bounding the window at the prompt row conflated two things: the footer arriving a frame after the row, and the footer leaving the layout and coming back. Bounded from the footer's first frame, the blink still reproduces, so the footer-ownership race is real rather than a sampling artifact. * Rename the shadowed prompt row binding in the frame recorder * fix(desktop): allow concurrent worktree dev instances Desktop terminals can inherit another instance's profile path and debugging flags. Scope the profile to the current dev root and select an available debugging port so instances do not share global runtime state. * fix(desktop): expose development providers in dev builds Desktop-managed daemons inherited no Paseo runtime mode, so dev builds constructed the production provider registry. Mark unpackaged internal launches as development while continuing to force packaged launches to production. * fix(timeline): stabilize optimistic turn layout Keep initial history loading dormant until user intent and preserve active-turn layout across delayed transport updates. Add painted-frame coverage for optimistic timeline transitions. * fix(timeline): preserve optimistic prompts through hydration Canonical replacement relied on a transport submission record that can settle before a provider echoes the user row. Keep presentation continuity owned by the unreconciled row while destructive history replacement remains governed by active transactions. * fix(timeline): make submitted prompts canonical Submission settlement previously depended on status updates that could arrive after the optimistic row. Persist every accepted prompt in the canonical timeline and keep the footer active until RPC and canonical acknowledgement converge. * fix(timeline): stop older history pages repeating entries Backward pagination counted source sequence rows, so page boundaries inside a merged projected item could return that item repeatedly. Count projected entries and advance from their display anchors instead, with server and browser regressions covering the contract. * fix(timeline): resume agents from the latest history Use a bounded tail probe on resume so long background runs appear atomically instead of replaying every missed page. Preserve unchanged views, reconcile local/live rows, and keep skipped history reachable through backward pagination. * fix(timeline): reconcile turn state after missed updates Turn completion events can be missed while an agent timeline is unsubscribed, leaving footer activity stale even after the directory reports idle. Reconcile the single-owned turn lifecycle from authoritative resume snapshots while preserving newer live evidence. * test(timeline): cover running state across refresh Protect the hydration boundary where directory state must restore consistent tab, composer, and footer activity. Expose the running indicator semantically so the regression checks user-visible state instead of spinner implementation details. * fix(timeline): keep history loading geometry stable Reserve a fixed history-start gutter regardless of pagination state so showing or hiding the loader cannot shift timeline content. * fix(timeline): keep turn activity coherent Derive footer, tab, and composer activity from one lifecycle and submission projection. Idle directory transitions now request an authoritative resume so missed terminal events converge without treating directory delivery order as turn authority. * fix(tests): align checks with runtime boundaries * fix(desktop): stop dev app surviving its terminal Electron was detached from the desktop runner, allowing it to outlive a stopped workspace terminal with broken output pipes and retain the single-instance lock. Keep Electron owned by the runner and preserve explicit process-group cleanup for detached child trees. * fix(timeline): preserve activity through turn handoff Correlate lifecycle events and snapshots with daemon-owned turn identity, and keep optimistic submissions active until their canonical row arrives. This prevents stale updates and RPC timing from exposing a false idle state. * refactor(timeline): remove submission timing state * fix(timeline): restore prompt identity on resume * fix(timeline): exclude attachments from canonical prompt text Structured provider prompts can include hidden history attachments before the user's text. Persist only plain prompt blocks so authoritative user messages match what the composer displayed while provider input remains unchanged. * refactor(agent): remove unused pending-run message identity * fix(desktop): let Chromium allocate the debugging port * refactor(agent): remove impossible finalized-turn tracking * fix(timeline): load history after keyboard scrolling Treat observed upward displacement as user intent so keyboard and scrollbar navigation can wake dormant pagination. * test(server): preserve accepted turn stream order The manager-owned accepted turn boundary intentionally precedes coalesced output. Assert that lifecycle event explicitly while retaining the timeline-before-terminal ordering contract. * test(app): scope sidebar fixtures by identity Shared E2E daemon state may contain unrelated projects. Keep stale-route coverage tied to its named missing project and drag only the two rows seeded by the reorder spec. * fix(timeline): gate prompt revisions by client capability * fix(timeline): finalize canonical prompt delivery Treat provider identity as a single stored-row transition so legacy clients receive one deliverable canonical prompt. Release identity-pending rows before later foreground output or the current terminal while preserving stale-turn isolation. * fix(server): preserve legacy prompt pagination * fix(timeline): keep assistant block identity stable Assign canonical block IDs during live promotion so terminal finalization cannot remount image-bearing rows. * fix(timeline): apply covered revisions during resume append Run the exact canonical revision pass before overlapping resume pages append newer rows, while leaving cursor advancement to the incremental path. * fix(server): preserve queued agent transitions Project each queued live snapshot before awaiting stored metadata so later lifecycle state cannot overwrite an earlier transition. * fix(server): snapshot queued agent updates Capture each live agent payload before it enters the per-agent queue so shared nested state cannot change while an earlier storage read is pending. * docs(app): date agent turn compatibility cleanup * refactor(timeline): mint submitted prompt identity at acceptance Canonical prompt rows now keep one immutable wire identity while provider identity remains internal for rewind translation. This removes the unreleased same-sequence revision and legacy projection contract. * refactor(app): remove prompt revision reconciliation Submitted prompt rows are immutable on the wire, so resume and live reducers no longer need to apply already-covered same-sequence updates. * test(app): cover submitted prompt rewind roundtrip * docs(timeline): describe immutable submitted prompt identity Provider echoes now enrich daemon-internal identity only; wire rows keep their acceptance-time identity and rewind performs the provider translation. * test(server): validate mock rewind targets Reject unknown provider message IDs so app rewind coverage exercises wire-to-provider identity translation. * fix(server): validate rewind before interrupting * fix(server): omit rewind identity for daemon prompts * test(app): guard rewind eligibility and rejection * fix(server): preserve prompt identity across hydration * refactor(timeline): remove stale revision remnants * Revert "fix(server): preserve prompt identity across hydration" This reverts commit 6adcf7e5b0bc8601761af59b461a746cb5e395fa. * fix(timeline): settle missed submission acknowledgements
This commit is contained in:
@@ -1248,6 +1248,10 @@ describe("target coalesced behavior", () => {
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
await expect(firstEvent).resolves.toEqual({
|
||||
done: false,
|
||||
value: { type: "turn_started", provider: "codex", turnId: "turn-1" },
|
||||
});
|
||||
await expect(stream.next()).resolves.toEqual({
|
||||
done: false,
|
||||
value: {
|
||||
type: "timeline",
|
||||
@@ -1267,14 +1271,20 @@ describe("target coalesced behavior", () => {
|
||||
expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: "hello world" }]);
|
||||
expect(streamEvents[0]).toMatchObject({
|
||||
type: "agent_stream",
|
||||
event: {
|
||||
type: "timeline",
|
||||
item: { type: "assistant_message", text: "hello world" },
|
||||
},
|
||||
event: { type: "turn_started", provider: "codex", turnId: "turn-1" },
|
||||
});
|
||||
expect(streamEvents[1]).toMatchObject({
|
||||
type: "agent_stream",
|
||||
event: { type: "turn_completed" },
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "turn-1",
|
||||
item: { type: "assistant_message", text: "hello world" },
|
||||
},
|
||||
});
|
||||
expect(streamEvents[2]).toMatchObject({
|
||||
type: "agent_stream",
|
||||
event: { type: "turn_completed", provider: "codex", turnId: "turn-1" },
|
||||
});
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { formatSystemNotificationPrompt } from "./agent-prompt.js";
|
||||
import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js";
|
||||
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
import type {
|
||||
@@ -4633,6 +4633,7 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the
|
||||
const interruptStarted = deferred<void>();
|
||||
const allowInterruptToFinish = deferred<void>();
|
||||
const allowSecondStartToResolve = deferred<void>();
|
||||
const allowSecondTurnToComplete = deferred<void>();
|
||||
let capturedSession: StaleReplacementSession | null = null;
|
||||
|
||||
class StaleReplacementSession extends TestAgentSession {
|
||||
@@ -4651,8 +4652,9 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the
|
||||
|
||||
secondStartEntered.resolve();
|
||||
await allowSecondStartToResolve.promise;
|
||||
setTimeout(() => {
|
||||
setTimeout(async () => {
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
await allowSecondTurnToComplete.promise;
|
||||
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||
}, 0);
|
||||
return { turnId };
|
||||
@@ -4709,7 +4711,9 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the
|
||||
{ agentId: snapshot.id, replayState: false },
|
||||
);
|
||||
|
||||
const firstRun = manager.streamAgent(snapshot.id, "first run");
|
||||
const firstRun = manager.streamAgent(snapshot.id, "first run", {
|
||||
clientMessageId: "first-client-message",
|
||||
});
|
||||
const firstRunDrain = (async () => {
|
||||
for await (const _event of firstRun) {
|
||||
// Drain events so lifecycle updates are applied.
|
||||
@@ -4720,7 +4724,9 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the
|
||||
|
||||
const replaceUpdatesStart = stateUpdates.length;
|
||||
const beforeReplaceUpdatedAt = manager.getAgent(snapshot.id)?.updatedAt.getTime() ?? 0;
|
||||
const secondRunPromise = manager.replaceAgentRun(snapshot.id, "replacement run");
|
||||
const secondRunPromise = manager.replaceAgentRun(snapshot.id, "replacement run", {
|
||||
clientMessageId: "replacement-client-message",
|
||||
});
|
||||
|
||||
await interruptStarted.promise;
|
||||
const replacementUpdates = stateUpdates.slice(replaceUpdatesStart);
|
||||
@@ -4766,6 +4772,18 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the
|
||||
allowSecondStartToResolve.resolve();
|
||||
|
||||
await replacementStart;
|
||||
capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-1" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(
|
||||
manager
|
||||
.fetchTimeline(snapshot.id, { direction: "tail", limit: 0 })
|
||||
.rows.find(
|
||||
(row) =>
|
||||
row.item.type === "user_message" &&
|
||||
row.item.clientMessageId === "replacement-client-message",
|
||||
)?.item,
|
||||
).toMatchObject({ messageId: "replacement-client-message" });
|
||||
allowSecondTurnToComplete.resolve();
|
||||
await firstRunDrain;
|
||||
await secondRunDrain;
|
||||
unsubscribe();
|
||||
@@ -4830,6 +4848,14 @@ test("applies live autonomous events and preserves usage omitted from completion
|
||||
provider: "codex",
|
||||
turnId: autonomousTurnId,
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
const running = manager.getAgent(snapshot.id);
|
||||
expect(running?.lifecycle).toBe("running");
|
||||
expect(running ? toAgentPayload(running).activeTurn : null).toEqual({
|
||||
turnId: autonomousTurnId,
|
||||
startedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
capturedSession!.pushEvent({
|
||||
type: "usage_updated",
|
||||
provider: "codex",
|
||||
@@ -4855,6 +4881,7 @@ test("applies live autonomous events and preserves usage omitted from completion
|
||||
|
||||
const updated = manager.getAgent(snapshot.id);
|
||||
expect(updated?.lifecycle).toBe("idle");
|
||||
expect(updated ? toAgentPayload(updated).activeTurn : null).toBeNull();
|
||||
expect(updated?.lastUsage).toEqual({
|
||||
inputTokens: 10,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
@@ -4868,6 +4895,149 @@ test("applies live autonomous events and preserves usage omitted from completion
|
||||
expect(lifecycleUpdates).toContain("idle");
|
||||
});
|
||||
|
||||
test("ignores stale autonomous terminals without lowering the active turn lifecycle", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-autonomous-terminal-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
let capturedSession: TestAgentSession | null = null;
|
||||
|
||||
class LiveEventClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
capturedSession = new TestAgentSession(config);
|
||||
return capturedSession;
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new LiveEventClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000135",
|
||||
});
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
capturedSession!.pushEvent({
|
||||
type: "turn_completed",
|
||||
provider: "codex",
|
||||
turnId: "prior-untracked-turn",
|
||||
usage: { inputTokens: 7 },
|
||||
});
|
||||
capturedSession!.pushEvent({
|
||||
type: "turn_failed",
|
||||
provider: "codex",
|
||||
turnId: "prior-untracked-turn",
|
||||
error: "turn-b marker",
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
const priorFailure = manager.getAgent(snapshot.id);
|
||||
expect(priorFailure?.lastError).toBe("turn-b marker");
|
||||
expect(priorFailure?.lastUsage).toEqual({ inputTokens: 7 });
|
||||
});
|
||||
|
||||
capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: "turn-b" });
|
||||
await vi.waitFor(() => {
|
||||
const running = manager.getAgent(snapshot.id);
|
||||
expect(running?.lifecycle).toBe("running");
|
||||
expect(running ? toAgentPayload(running).activeTurn?.turnId : null).toBe("turn-b");
|
||||
});
|
||||
|
||||
const active = manager.getAgent(snapshot.id)!;
|
||||
active.pendingPermissions.set("turn-b-permission", {
|
||||
id: "turn-b-permission",
|
||||
provider: "codex",
|
||||
name: "Current turn permission",
|
||||
kind: "tool",
|
||||
});
|
||||
const timelineBeforeStaleTerminals = manager.getTimeline(snapshot.id);
|
||||
|
||||
const staleTerminals: AgentStreamEvent[] = [
|
||||
{
|
||||
type: "turn_completed",
|
||||
provider: "codex",
|
||||
turnId: "turn-a",
|
||||
usage: { inputTokens: 99 },
|
||||
},
|
||||
{ type: "turn_failed", provider: "codex", turnId: "turn-a", error: "late failure" },
|
||||
{ type: "turn_canceled", provider: "codex", turnId: "turn-a", reason: "late cancel" },
|
||||
];
|
||||
for (const terminal of staleTerminals) {
|
||||
const processed = new Promise<void>((resolve) => {
|
||||
const unsubscribe = manager.subscribe(
|
||||
(event) => {
|
||||
if (
|
||||
event.type === "agent_stream" &&
|
||||
event.agentId === snapshot.id &&
|
||||
event.event.type === terminal.type
|
||||
) {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: false },
|
||||
);
|
||||
});
|
||||
capturedSession!.pushEvent(terminal);
|
||||
await processed;
|
||||
const stillRunning = manager.getAgent(snapshot.id);
|
||||
expect(stillRunning?.lifecycle).toBe("running");
|
||||
expect(stillRunning ? toAgentPayload(stillRunning).activeTurn?.turnId : null).toBe("turn-b");
|
||||
expect(stillRunning?.lastError).toBe("turn-b marker");
|
||||
expect(stillRunning?.lastUsage).toEqual({ inputTokens: 7 });
|
||||
expect(stillRunning?.pendingPermissions.has("turn-b-permission")).toBe(true);
|
||||
expect(manager.getTimeline(snapshot.id)).toEqual(timelineBeforeStaleTerminals);
|
||||
}
|
||||
|
||||
capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-b" });
|
||||
await vi.waitFor(() => {
|
||||
const settled = manager.getAgent(snapshot.id);
|
||||
expect(settled?.lifecycle).toBe("idle");
|
||||
expect(settled ? toAgentPayload(settled).activeTurn : null).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves terminal fallback when no active turn identity was observed", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-untracked-terminal-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
let capturedSession: TestAgentSession | null = null;
|
||||
|
||||
class LiveEventClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
capturedSession = new TestAgentSession(config);
|
||||
return capturedSession;
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new LiveEventClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000136",
|
||||
});
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
capturedSession!.pushEvent({
|
||||
type: "turn_failed",
|
||||
provider: "codex",
|
||||
turnId: "untracked-turn",
|
||||
error: "untracked failure",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const failed = manager.getAgent(snapshot.id);
|
||||
expect(failed?.lifecycle).toBe("error");
|
||||
expect(failed?.lastError).toBe("untracked failure");
|
||||
});
|
||||
expect(manager.getTimeline(snapshot.id)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: expect.stringContaining("untracked failure"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("cancelAgentRun waits for an acknowledged autonomous interrupt to settle", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
@@ -5147,6 +5317,11 @@ test("autonomous events arriving during foreground run are processed via subscri
|
||||
provider: "codex",
|
||||
turnId: autonomousTurnId,
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
const running = manager.getAgent(snapshot.id);
|
||||
expect(running?.lifecycle).toBe("running");
|
||||
expect(running ? toAgentPayload(running).activeTurn?.turnId : null).toBe("fg-turn-1");
|
||||
});
|
||||
capturedSession!.pushEvent({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
@@ -5159,6 +5334,18 @@ test("autonomous events arriving during foreground run are processed via subscri
|
||||
turnId: autonomousTurnId,
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(manager.getTimeline(snapshot.id)).toContainEqual({
|
||||
type: "assistant_message",
|
||||
text: "AUTONOMOUS_DURING_FOREGROUND",
|
||||
}),
|
||||
);
|
||||
const stillForeground = manager.getAgent(snapshot.id);
|
||||
expect(stillForeground?.lifecycle).toBe("running");
|
||||
expect(stillForeground ? toAgentPayload(stillForeground).activeTurn?.turnId : null).toBe(
|
||||
"fg-turn-1",
|
||||
);
|
||||
|
||||
releaseForeground.resolve();
|
||||
const foregroundEvents = await foregroundResults;
|
||||
|
||||
@@ -7907,23 +8094,21 @@ test("provider user_message is recorded from the live stream", async () => {
|
||||
class UnexpectedUserMessageSession extends TestAgentSession {
|
||||
override async startTurn(): Promise<{ turnId: string }> {
|
||||
const turnId = "turn-unexpected-1";
|
||||
setTimeout(() => {
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
// Provider yields user_message (e.g., system continuation)
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "user_message", text: "continuation prompt" },
|
||||
turnId,
|
||||
});
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "assistant_message", text: "continuation reply" },
|
||||
turnId,
|
||||
});
|
||||
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||
}, 0);
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
// Provider yields a staged user_message without client identity (e.g., system continuation).
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "user_message", text: "continuation prompt" },
|
||||
turnId,
|
||||
});
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "assistant_message", text: "continuation reply" },
|
||||
turnId,
|
||||
});
|
||||
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||
return { turnId };
|
||||
}
|
||||
}
|
||||
@@ -7963,20 +8148,35 @@ test("provider user_message is recorded from the live stream", async () => {
|
||||
expect(userMessages[0].text).toBe("continuation prompt");
|
||||
});
|
||||
|
||||
test("authoritative timeline includes provider-emitted submitted user prompt", async () => {
|
||||
test("canonical submitted prompt keeps wire identity while rewind resolves provider identity", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-submitted-prompt-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const allowProviderEcho = deferred<void>();
|
||||
|
||||
class SubmittedUserMessageSession extends TestAgentSession {
|
||||
override readonly capabilities = {
|
||||
...TEST_CAPABILITIES,
|
||||
supportsRewindFiles: true,
|
||||
};
|
||||
readonly rewindMessageIds: string[] = [];
|
||||
interruptCount = 0;
|
||||
|
||||
override async startTurn(
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<{ turnId: string }> {
|
||||
const turnId = "turn-submitted-user-message";
|
||||
const text = typeof prompt === "string" ? prompt : "";
|
||||
setTimeout(() => {
|
||||
setTimeout(async () => {
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
item: { type: "assistant_message", text: "output before provider echo" },
|
||||
});
|
||||
await allowProviderEcho.promise;
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
@@ -7992,37 +8192,200 @@ test("authoritative timeline includes provider-emitted submitted user prompt", a
|
||||
}, 0);
|
||||
return { turnId };
|
||||
}
|
||||
}
|
||||
|
||||
class SubmittedUserMessageClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new SubmittedUserMessageSession(config);
|
||||
override async interrupt(): Promise<void> {
|
||||
this.interruptCount += 1;
|
||||
this.pushEvent({
|
||||
type: "turn_canceled",
|
||||
provider: this.provider,
|
||||
turnId: "turn-submitted-user-message",
|
||||
});
|
||||
}
|
||||
|
||||
override async revertFiles({ messageId }: { messageId: string }): Promise<void> {
|
||||
this.rewindMessageIds.push(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
class SubmittedUserMessageClient extends TestAgentClient {
|
||||
session: SubmittedUserMessageSession | null = null;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.session = new SubmittedUserMessageSession(config);
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
|
||||
const client = new SubmittedUserMessageClient();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new SubmittedUserMessageClient() },
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000402",
|
||||
});
|
||||
const events: AgentManagerEvent[] = [];
|
||||
manager.subscribe((event) => events.push(event), { replayState: false });
|
||||
const streamEvents = () =>
|
||||
events.flatMap((event) => {
|
||||
if (event.type !== "agent_stream") return [];
|
||||
if (event.event.type !== "timeline") return [{ type: event.event.type }];
|
||||
const item = event.event.item;
|
||||
return [
|
||||
{
|
||||
type: item.type,
|
||||
seq: event.seq,
|
||||
...(item.type === "user_message"
|
||||
? {
|
||||
text: item.text,
|
||||
clientMessageId: item.clientMessageId,
|
||||
messageId: item.messageId,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
try {
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
await manager.runAgent(snapshot.id, "hello from composer", {
|
||||
const run = manager.runAgent(snapshot.id, "hello from composer", {
|
||||
clientMessageId: "msg-client-1",
|
||||
});
|
||||
await manager.waitForAgentRunStart(snapshot.id);
|
||||
|
||||
await expect(manager.rewind(snapshot.id, "msg-client-1", "files")).rejects.toThrow(
|
||||
"Cannot rewind before the provider acknowledges the submitted prompt",
|
||||
);
|
||||
expect(client.session?.interruptCount).toBe(0);
|
||||
|
||||
allowProviderEcho.resolve();
|
||||
expect(await run).toMatchObject({ canceled: false });
|
||||
|
||||
expect(streamEvents()).toEqual([
|
||||
{ type: "turn_started" },
|
||||
{
|
||||
type: "user_message",
|
||||
seq: 1,
|
||||
text: "hello from composer",
|
||||
clientMessageId: "msg-client-1",
|
||||
messageId: "msg-client-1",
|
||||
},
|
||||
{ type: "assistant_message", seq: 2 },
|
||||
{ type: "turn_completed" },
|
||||
]);
|
||||
|
||||
const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows;
|
||||
expect(timeline.map((row) => row.item)).toContainEqual({
|
||||
type: "user_message",
|
||||
text: "hello from composer",
|
||||
messageId: "provider-message-1",
|
||||
clientMessageId: "msg-client-1",
|
||||
expect(timeline).toEqual([
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: expect.any(String),
|
||||
providerMessageId: "provider-message-1",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "hello from composer",
|
||||
messageId: "msg-client-1",
|
||||
clientMessageId: "msg-client-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: expect.any(String),
|
||||
item: { type: "assistant_message", text: "output before provider echo" },
|
||||
},
|
||||
]);
|
||||
|
||||
await manager.rewind(snapshot.id, "msg-client-1", "files");
|
||||
await manager.rewind(snapshot.id, "provider-native-message", "files");
|
||||
expect(client.session?.rewindMessageIds).toEqual([
|
||||
"provider-message-1",
|
||||
"provider-native-message",
|
||||
]);
|
||||
} finally {
|
||||
await manager.flush().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("authoritative timeline records a daemon-handled submitted prompt before its output", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-daemon-handled-prompt-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const commandCompleted = deferred<void>();
|
||||
|
||||
class DaemonHandledPromptSession extends TestAgentSession {
|
||||
override readonly capabilities = {
|
||||
...TEST_CAPABILITIES,
|
||||
supportsRewindConversation: true,
|
||||
};
|
||||
|
||||
override tryHandleOutOfBand(prompt: AgentPromptInput) {
|
||||
if (prompt !== "/handled") return null;
|
||||
return {
|
||||
run: async ({ emit }: { emit: (event: AgentStreamEvent) => void }) => {
|
||||
emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "assistant_message", text: "Handled by the daemon" },
|
||||
});
|
||||
commandCompleted.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class DaemonHandledPromptClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new DaemonHandledPromptSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new DaemonHandledPromptClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000403",
|
||||
});
|
||||
|
||||
try {
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const events: AgentManagerEvent[] = [];
|
||||
manager.subscribe((event) => events.push(event), {
|
||||
agentId: snapshot.id,
|
||||
replayState: false,
|
||||
});
|
||||
|
||||
await startAgentRun(manager, snapshot.id, "/handled", logger, {
|
||||
runOptions: { clientMessageId: "msg-client-daemon-handled" },
|
||||
});
|
||||
await commandCompleted.promise;
|
||||
|
||||
expect(
|
||||
events.flatMap((event) =>
|
||||
event.type === "agent_stream" && event.event.type === "timeline" ? [event.event.item] : [],
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: "/handled",
|
||||
clientMessageId: "msg-client-daemon-handled",
|
||||
},
|
||||
{ type: "assistant_message", text: "Handled by the daemon" },
|
||||
]);
|
||||
|
||||
const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows;
|
||||
expect(timeline.map((row) => row.item)).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: "/handled",
|
||||
clientMessageId: "msg-client-daemon-handled",
|
||||
},
|
||||
{ type: "assistant_message", text: "Handled by the daemon" },
|
||||
]);
|
||||
} finally {
|
||||
await manager.flush().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
|
||||
@@ -90,6 +90,16 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
|
||||
type TimeoutResult = "completed" | "timed_out";
|
||||
|
||||
function submittedPromptText(prompt: AgentPromptInput): string {
|
||||
if (typeof prompt === "string") {
|
||||
return prompt;
|
||||
}
|
||||
return prompt
|
||||
.flatMap((block) => (block.type === "text" && !("mimeType" in block) ? [block.text] : []))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export class AgentManagerShuttingDownError extends Error {
|
||||
constructor() {
|
||||
super("Agent manager is shutting down");
|
||||
@@ -296,6 +306,8 @@ interface StreamEventFlags {
|
||||
shouldNotifyWaiters: boolean;
|
||||
}
|
||||
|
||||
type ActiveTurnTerminalDisposition = "closed_current" | "stale" | "untracked";
|
||||
|
||||
interface HandleStreamEventOptions {
|
||||
fromHistory?: boolean;
|
||||
}
|
||||
@@ -329,6 +341,8 @@ interface ManagedAgentBase {
|
||||
persistence: AgentPersistenceHandle | null;
|
||||
historyPrimed: boolean;
|
||||
lastUserMessageAt: Date | null;
|
||||
activeTurnId: string | null;
|
||||
activeTurnStartedAt: Date | null;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string;
|
||||
attention: AttentionState;
|
||||
@@ -403,6 +417,38 @@ type ActiveManagedAgent =
|
||||
type LiveManagedAgent = ActiveManagedAgent;
|
||||
type AgentLabelPatch = Record<string, string | null>;
|
||||
|
||||
function attachManagedTurnIdentity(
|
||||
agent: ActiveManagedAgent,
|
||||
event: AgentStreamEvent,
|
||||
fromHistory: boolean,
|
||||
): { event: AgentStreamEvent; turnId: string | undefined } {
|
||||
const existingTurnId = getAgentStreamEventTurnId(event);
|
||||
if (fromHistory || existingTurnId !== undefined) {
|
||||
return { event, turnId: existingTurnId };
|
||||
}
|
||||
switch (event.type) {
|
||||
case "turn_started": {
|
||||
const turnId =
|
||||
agent.activeForegroundTurnId ?? agent.activeTurnId ?? `autonomous-${randomUUID()}`;
|
||||
return { event: { ...event, turnId }, turnId };
|
||||
}
|
||||
case "turn_completed":
|
||||
case "turn_failed":
|
||||
case "turn_canceled": {
|
||||
const turnId = agent.activeForegroundTurnId ?? agent.activeTurnId ?? undefined;
|
||||
return turnId ? { event: { ...event, turnId }, turnId } : { event, turnId };
|
||||
}
|
||||
default:
|
||||
return { event, turnId: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
function limitAgentStreamEventContent(event: AgentStreamEvent): AgentStreamEvent {
|
||||
return event.type === "timeline"
|
||||
? { ...event, item: limitAgentTimelineItemContent(event.item) }
|
||||
: event;
|
||||
}
|
||||
|
||||
interface WriteLabelsResult {
|
||||
record: StoredAgentRecord | null;
|
||||
live: boolean;
|
||||
@@ -1523,6 +1569,8 @@ export class AgentManager {
|
||||
inFlightPermissionResponses: new Set(),
|
||||
pendingReplacement: false,
|
||||
activeForegroundTurnId: null,
|
||||
activeTurnId: null,
|
||||
activeTurnStartedAt: null,
|
||||
foregroundTurnWaiters: new Set(),
|
||||
finalizedForegroundTurnIds: new Set(),
|
||||
unsubscribeSession: null,
|
||||
@@ -1873,12 +1921,16 @@ export class AgentManager {
|
||||
* emitted by the handler flow through dispatchStream so they persist and
|
||||
* broadcast like normal timeline events.
|
||||
*/
|
||||
tryRunOutOfBand(agentId: string, prompt: AgentPromptInput): boolean {
|
||||
tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const handler = agent.session.tryHandleOutOfBand?.(prompt);
|
||||
if (!handler) {
|
||||
return false;
|
||||
}
|
||||
if (options?.clientMessageId) {
|
||||
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId);
|
||||
this.emitState(agent);
|
||||
}
|
||||
const dispatch = (event: AgentStreamEvent): void => {
|
||||
// Persist timeline items so they show up in fetchAgentTimeline; broadcast
|
||||
// for live subscribers. Other event types are broadcast only.
|
||||
@@ -2000,14 +2052,49 @@ export class AgentManager {
|
||||
throw error;
|
||||
}
|
||||
|
||||
pendingRun.started = true;
|
||||
pendingRun.turnId = turnId;
|
||||
if (isReplacement) {
|
||||
agent.pendingReplacement = false;
|
||||
}
|
||||
const turnStartedAt = new Date();
|
||||
pendingRun.started = true;
|
||||
pendingRun.turnId = turnId;
|
||||
agent.activeForegroundTurnId = turnId;
|
||||
this.openActiveTurn(agent, turnId, turnStartedAt);
|
||||
agent.lifecycle = "running";
|
||||
this.touchUpdatedAt(agent);
|
||||
// AgentManager owns the accepted-turn boundary. Publish liveness before the canonical
|
||||
// prompt so clients can retire optimistic activity without painting an idle frame.
|
||||
// The provider's duplicate start for this turn is suppressed at the ingestion boundary.
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
{ type: "turn_started", provider: agent.provider, turnId },
|
||||
{ timestamp: turnStartedAt.toISOString() },
|
||||
);
|
||||
const stagedSubmittedPromptEcho = options?.clientMessageId
|
||||
? pendingRun.stagedEvents.find(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "user_message" &&
|
||||
event.item.clientMessageId === options.clientMessageId,
|
||||
)
|
||||
: undefined;
|
||||
if (options?.clientMessageId) {
|
||||
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, {
|
||||
messageId: options.clientMessageId,
|
||||
providerMessageId:
|
||||
stagedSubmittedPromptEcho?.item.type === "user_message"
|
||||
? stagedSubmittedPromptEcho.item.messageId
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
for (const stagedEvent of pendingRun.stagedEvents.splice(0)) {
|
||||
const isAcceptedTurnStart =
|
||||
stagedEvent.type === "turn_started" && getAgentStreamEventTurnId(stagedEvent) === turnId;
|
||||
if (isAcceptedTurnStart || stagedEvent === stagedSubmittedPromptEcho) {
|
||||
continue;
|
||||
}
|
||||
this.enqueueSessionEvent(agent.id, stagedEvent);
|
||||
}
|
||||
this.emitState(agent);
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -2025,6 +2112,12 @@ export class AgentManager {
|
||||
this.runs.addWaiter(agent, turnStream.waiter);
|
||||
|
||||
try {
|
||||
const acceptedTurnStartedEvent: AgentStreamEvent = {
|
||||
type: "turn_started",
|
||||
provider: agent.provider,
|
||||
turnId,
|
||||
};
|
||||
yield acceptedTurnStartedEvent;
|
||||
for await (const event of turnStream.events(isTurnTerminalEvent)) {
|
||||
yield event;
|
||||
}
|
||||
@@ -2048,6 +2141,7 @@ export class AgentManager {
|
||||
this.runs.rememberFinalizedTurn(mutableAgent, turnId);
|
||||
}
|
||||
mutableAgent.activeForegroundTurnId = null;
|
||||
this.applyActiveTurnTerminal(mutableAgent, turnId);
|
||||
const terminalError = mutableAgent.lastError;
|
||||
const shouldHoldBusyForReplacement = mutableAgent.pendingReplacement && !terminalError;
|
||||
let nextLifecycle: "running" | "error" | "idle";
|
||||
@@ -2085,6 +2179,24 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
private openActiveTurn(agent: ActiveManagedAgent, turnId: string, startedAt: Date): void {
|
||||
agent.activeTurnId = turnId;
|
||||
agent.activeTurnStartedAt = startedAt;
|
||||
}
|
||||
|
||||
private applyActiveTurnTerminal(
|
||||
agent: ActiveManagedAgent,
|
||||
turnId?: string,
|
||||
fromHistory = false,
|
||||
): ActiveTurnTerminalDisposition {
|
||||
if (fromHistory) return "stale";
|
||||
if (!agent.activeTurnId) return "untracked";
|
||||
if (turnId && agent.activeTurnId !== turnId) return "stale";
|
||||
agent.activeTurnId = null;
|
||||
agent.activeTurnStartedAt = null;
|
||||
return "closed_current";
|
||||
}
|
||||
|
||||
async replaceAgentRun(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
@@ -2373,6 +2485,19 @@ export class AgentManager {
|
||||
|
||||
async rewind(agentId: string, messageId: string, mode: RewindMode): Promise<void> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const submittedRow = this.timelineStore
|
||||
.getRows(agentId)
|
||||
.find(
|
||||
(row) =>
|
||||
row.item.type === "user_message" &&
|
||||
row.item.messageId === messageId &&
|
||||
row.item.clientMessageId === messageId,
|
||||
);
|
||||
if (submittedRow && !submittedRow.providerMessageId) {
|
||||
throw new Error("Cannot rewind before the provider acknowledges the submitted prompt");
|
||||
}
|
||||
const providerMessageId = submittedRow?.providerMessageId ?? messageId;
|
||||
|
||||
if (this.hasInFlightRun(agentId)) {
|
||||
await this.cancelAgentRunBefore(agentId, "rewind");
|
||||
}
|
||||
@@ -2383,7 +2508,7 @@ export class AgentManager {
|
||||
{ agentId, provider: agent.provider, messageId, mode },
|
||||
"agent.rewind.start",
|
||||
);
|
||||
await invokeRewindCapability(agent.session, { messageId, mode });
|
||||
await invokeRewindCapability(agent.session, { messageId: providerMessageId, mode });
|
||||
if (mode !== "files") {
|
||||
await this.hydrateTimelineFromProvider(agentId, { force: true, broadcast: true });
|
||||
}
|
||||
@@ -2839,6 +2964,8 @@ export class AgentManager {
|
||||
inFlightPermissionResponses: new Set(),
|
||||
pendingReplacement: false,
|
||||
activeForegroundTurnId: null,
|
||||
activeTurnId: null,
|
||||
activeTurnStartedAt: null,
|
||||
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
|
||||
finalizedForegroundTurnIds: new Set<string>(),
|
||||
unsubscribeSession: null,
|
||||
@@ -2893,6 +3020,8 @@ export class AgentManager {
|
||||
lifecycle: "closed",
|
||||
session: null,
|
||||
activeForegroundTurnId: null,
|
||||
activeTurnId: null,
|
||||
activeTurnStartedAt: null,
|
||||
pendingPermissions: new Map(),
|
||||
bufferedPermissionResolutions: new Map(),
|
||||
inFlightPermissionResponses: new Set(),
|
||||
@@ -2935,6 +3064,11 @@ export class AgentManager {
|
||||
},
|
||||
"agent.manager.enqueue",
|
||||
);
|
||||
const pendingRun = this.runs.getPendingRun(agentId);
|
||||
if (pendingRun && !pendingRun.started) {
|
||||
pendingRun.stagedEvents.push(event);
|
||||
return;
|
||||
}
|
||||
const previous = this.sessionEventTails.get(agentId) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
@@ -3296,14 +3430,11 @@ export class AgentManager {
|
||||
event: AgentStreamEvent,
|
||||
options?: HandleStreamEventOptions,
|
||||
): Promise<boolean> {
|
||||
if (event.type === "timeline") {
|
||||
event = {
|
||||
...event,
|
||||
item: limitAgentTimelineItemContent(event.item),
|
||||
};
|
||||
}
|
||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
||||
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
|
||||
event = limitAgentStreamEventContent(event);
|
||||
const identified = attachManagedTurnIdentity(agent, event, options?.fromHistory === true);
|
||||
event = identified.event;
|
||||
const eventTurnId = identified.turnId;
|
||||
const isForegroundEvent = agent.activeForegroundTurnId === eventTurnId;
|
||||
this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent);
|
||||
if (
|
||||
eventTurnId &&
|
||||
@@ -3323,6 +3454,15 @@ export class AgentManager {
|
||||
this.agentStreamCoalescer.flushFor(agent.id);
|
||||
}
|
||||
|
||||
let terminalDisposition: ActiveTurnTerminalDisposition = "untracked";
|
||||
if (isTurnTerminalEvent(event)) {
|
||||
terminalDisposition = this.applyActiveTurnTerminal(
|
||||
agent,
|
||||
eventTurnId,
|
||||
options?.fromHistory === true,
|
||||
);
|
||||
}
|
||||
|
||||
const flags: StreamEventFlags = { shouldDispatchEvent: true, shouldNotifyWaiters: true };
|
||||
|
||||
const dispatchPromise = this.dispatchStreamEventByType({
|
||||
@@ -3331,21 +3471,24 @@ export class AgentManager {
|
||||
options,
|
||||
isForegroundEvent,
|
||||
eventTurnId,
|
||||
terminalDisposition,
|
||||
flags,
|
||||
});
|
||||
if (dispatchPromise) {
|
||||
await dispatchPromise;
|
||||
}
|
||||
|
||||
if (!options?.fromHistory && isTurnTerminalEvent(event)) {
|
||||
this.runs.settleTerminalRun(agent.id, eventTurnId);
|
||||
if (isForegroundEvent) {
|
||||
this.finalizeForegroundTurn(agent, eventTurnId);
|
||||
if (!options?.fromHistory) {
|
||||
if (isTurnTerminalEvent(event)) {
|
||||
this.runs.settleTerminalRun(agent.id, eventTurnId);
|
||||
if (isForegroundEvent) {
|
||||
this.finalizeForegroundTurn(agent, eventTurnId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!options?.fromHistory && flags.shouldDispatchEvent) {
|
||||
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
|
||||
if (flags.shouldDispatchEvent) {
|
||||
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
|
||||
}
|
||||
}
|
||||
|
||||
this.traceHandleStreamEventEnd(agent, event, eventTurnId, flags);
|
||||
@@ -3419,9 +3562,11 @@ export class AgentManager {
|
||||
options: HandleStreamEventOptions | undefined;
|
||||
isForegroundEvent: boolean;
|
||||
eventTurnId: string | undefined;
|
||||
terminalDisposition: ActiveTurnTerminalDisposition;
|
||||
flags: StreamEventFlags;
|
||||
}): Promise<void> | undefined {
|
||||
const { agent, event, options, isForegroundEvent, eventTurnId, flags } = params;
|
||||
const { agent, event, options, isForegroundEvent, eventTurnId, terminalDisposition, flags } =
|
||||
params;
|
||||
switch (event.type) {
|
||||
case "thread_started":
|
||||
this.onStreamThreadStarted(agent);
|
||||
@@ -3463,9 +3608,15 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
return undefined;
|
||||
case "timeline":
|
||||
return this.onStreamTimelineEvent({ agent, event, options, isForegroundEvent, flags });
|
||||
return this.onStreamTimelineEvent({ agent, event, options, flags });
|
||||
case "turn_completed":
|
||||
this.onStreamTurnCompleted({ agent, event, eventTurnId, isForegroundEvent });
|
||||
this.onStreamTurnCompleted({
|
||||
agent,
|
||||
event,
|
||||
eventTurnId,
|
||||
isForegroundEvent,
|
||||
terminalDisposition,
|
||||
});
|
||||
return undefined;
|
||||
case "turn_failed":
|
||||
return this.onStreamTurnFailed({
|
||||
@@ -3473,13 +3624,21 @@ export class AgentManager {
|
||||
event,
|
||||
eventTurnId,
|
||||
isForegroundEvent,
|
||||
terminalDisposition,
|
||||
options,
|
||||
});
|
||||
case "turn_canceled":
|
||||
this.onStreamTurnCanceled({ agent, event, eventTurnId, isForegroundEvent, options });
|
||||
this.onStreamTurnCanceled({
|
||||
agent,
|
||||
event,
|
||||
eventTurnId,
|
||||
isForegroundEvent,
|
||||
terminalDisposition,
|
||||
options,
|
||||
});
|
||||
return undefined;
|
||||
case "turn_started":
|
||||
this.onStreamTurnStarted({ agent, eventTurnId, isForegroundEvent });
|
||||
this.onStreamTurnStarted({ agent, eventTurnId, isForegroundEvent, flags });
|
||||
return undefined;
|
||||
case "permission_requested":
|
||||
this.onStreamPermissionRequested(agent, event);
|
||||
@@ -3508,7 +3667,6 @@ export class AgentManager {
|
||||
agent: ActiveManagedAgent;
|
||||
event: Extract<AgentStreamEvent, { type: "timeline" }>;
|
||||
options: { fromHistory?: boolean } | undefined;
|
||||
isForegroundEvent: boolean;
|
||||
flags: StreamEventFlags;
|
||||
}): Promise<void> {
|
||||
const { agent, event, options, flags } = params;
|
||||
@@ -3519,6 +3677,16 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.item.type === "user_message" &&
|
||||
event.item.clientMessageId &&
|
||||
this.reconcileSubmittedPromptEcho(agent, event.item)
|
||||
) {
|
||||
flags.shouldDispatchEvent = false;
|
||||
flags.shouldNotifyWaiters = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.fromHistory) {
|
||||
this.recordTimeline(
|
||||
agent.id,
|
||||
@@ -3544,8 +3712,9 @@ export class AgentManager {
|
||||
event: Extract<AgentStreamEvent, { type: "turn_completed" }>;
|
||||
eventTurnId: string | undefined;
|
||||
isForegroundEvent: boolean;
|
||||
terminalDisposition: ActiveTurnTerminalDisposition;
|
||||
}): void {
|
||||
const { agent, event, eventTurnId, isForegroundEvent } = params;
|
||||
const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition } = params;
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
@@ -3557,6 +3726,7 @@ export class AgentManager {
|
||||
},
|
||||
"agent.manager.turn.completed",
|
||||
);
|
||||
if (terminalDisposition === "stale") return;
|
||||
if (event.usage) {
|
||||
agent.lastUsage = { ...agent.lastUsage, ...event.usage };
|
||||
}
|
||||
@@ -3564,7 +3734,12 @@ export class AgentManager {
|
||||
// data accumulated during streaming isn't lost when the provider omits
|
||||
// it from the completion event.
|
||||
agent.lastError = undefined;
|
||||
if (!isForegroundEvent && agent.lifecycle !== "idle" && !agent.pendingReplacement) {
|
||||
if (
|
||||
!isForegroundEvent &&
|
||||
!agent.activeForegroundTurnId &&
|
||||
agent.lifecycle !== "idle" &&
|
||||
!agent.pendingReplacement
|
||||
) {
|
||||
(agent as ActiveManagedAgent).lifecycle = "idle";
|
||||
this.emitState(agent);
|
||||
}
|
||||
@@ -3576,9 +3751,10 @@ export class AgentManager {
|
||||
event: Extract<AgentStreamEvent, { type: "turn_failed" }>;
|
||||
eventTurnId: string | undefined;
|
||||
isForegroundEvent: boolean;
|
||||
terminalDisposition: ActiveTurnTerminalDisposition;
|
||||
options: { fromHistory?: boolean } | undefined;
|
||||
}): Promise<void> {
|
||||
const { agent, event, eventTurnId, isForegroundEvent, options } = params;
|
||||
const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition, options } = params;
|
||||
this.logger.warn(
|
||||
{
|
||||
agentId: agent.id,
|
||||
@@ -3594,7 +3770,8 @@ export class AgentManager {
|
||||
},
|
||||
"handleStreamEvent: turn_failed",
|
||||
);
|
||||
if (!isForegroundEvent) {
|
||||
if (terminalDisposition === "stale") return;
|
||||
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
|
||||
agent.lifecycle = "error";
|
||||
}
|
||||
agent.lastError = event.error;
|
||||
@@ -3605,7 +3782,7 @@ export class AgentManager {
|
||||
options,
|
||||
);
|
||||
this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Turn failed");
|
||||
if (!isForegroundEvent) {
|
||||
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
|
||||
this.emitState(agent);
|
||||
}
|
||||
}
|
||||
@@ -3615,13 +3792,14 @@ export class AgentManager {
|
||||
event: Extract<AgentStreamEvent, { type: "turn_canceled" }>;
|
||||
eventTurnId: string | undefined;
|
||||
isForegroundEvent: boolean;
|
||||
terminalDisposition: ActiveTurnTerminalDisposition;
|
||||
options:
|
||||
| {
|
||||
fromHistory?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
}): void {
|
||||
const { agent, event, eventTurnId, isForegroundEvent, options } = params;
|
||||
const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition, options } = params;
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
@@ -3634,12 +3812,13 @@ export class AgentManager {
|
||||
},
|
||||
"agent.manager.turn.canceled",
|
||||
);
|
||||
if (!isForegroundEvent && !agent.pendingReplacement) {
|
||||
if (terminalDisposition === "stale") return;
|
||||
if (!isForegroundEvent && !agent.activeForegroundTurnId && !agent.pendingReplacement) {
|
||||
agent.lifecycle = "idle";
|
||||
}
|
||||
agent.lastError = undefined;
|
||||
this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Interrupted");
|
||||
if (!isForegroundEvent) {
|
||||
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
|
||||
this.emitState(agent);
|
||||
}
|
||||
}
|
||||
@@ -3648,8 +3827,9 @@ export class AgentManager {
|
||||
agent: ActiveManagedAgent;
|
||||
eventTurnId: string | undefined;
|
||||
isForegroundEvent: boolean;
|
||||
flags: StreamEventFlags;
|
||||
}): void {
|
||||
const { agent, eventTurnId, isForegroundEvent } = params;
|
||||
const { agent, eventTurnId, isForegroundEvent, flags } = params;
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
@@ -3661,11 +3841,22 @@ export class AgentManager {
|
||||
},
|
||||
"agent.manager.turn.started",
|
||||
);
|
||||
if (!isForegroundEvent) {
|
||||
this.runs.trackAutonomousRun(agent.id, eventTurnId ?? null);
|
||||
agent.lifecycle = "running";
|
||||
this.emitState(agent);
|
||||
if (isForegroundEvent) {
|
||||
flags.shouldDispatchEvent = false;
|
||||
flags.shouldNotifyWaiters = false;
|
||||
return;
|
||||
}
|
||||
if (agent.activeForegroundTurnId) {
|
||||
flags.shouldDispatchEvent = false;
|
||||
flags.shouldNotifyWaiters = false;
|
||||
return;
|
||||
}
|
||||
this.runs.trackAutonomousRun(agent.id, eventTurnId ?? null);
|
||||
if (eventTurnId) {
|
||||
this.openActiveTurn(agent, eventTurnId, new Date());
|
||||
}
|
||||
agent.lifecycle = "running";
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
private onStreamPermissionRequested(
|
||||
@@ -3720,8 +3911,9 @@ export class AgentManager {
|
||||
item: AgentTimelineItem,
|
||||
provider: AgentProvider,
|
||||
turnId?: string,
|
||||
options?: { providerMessageId?: string },
|
||||
): AgentStreamEvent {
|
||||
const row = this.recordTimeline(agentId, item);
|
||||
const row = this.recordTimeline(agentId, item, options);
|
||||
const event: AgentStreamEvent = {
|
||||
type: "timeline",
|
||||
item,
|
||||
@@ -3749,6 +3941,45 @@ export class AgentManager {
|
||||
return event;
|
||||
}
|
||||
|
||||
private recordSubmittedPrompt(
|
||||
agent: ActiveManagedAgent,
|
||||
prompt: AgentPromptInput,
|
||||
clientMessageId: string,
|
||||
options?: { messageId?: string; providerMessageId?: string },
|
||||
): void {
|
||||
if (this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId)) {
|
||||
return;
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
agent.lastUserMessageAt = new Date();
|
||||
const item: AgentTimelineItem = {
|
||||
type: "user_message",
|
||||
text: submittedPromptText(prompt),
|
||||
clientMessageId,
|
||||
...(options?.messageId ? { messageId: options.messageId } : {}),
|
||||
};
|
||||
this.recordAndDispatchTimelineItem(agent.id, item, agent.provider, undefined, options);
|
||||
}
|
||||
|
||||
private reconcileSubmittedPromptEcho(
|
||||
agent: ActiveManagedAgent,
|
||||
item: Extract<AgentTimelineItem, { type: "user_message" }>,
|
||||
): AgentTimelineRow | null {
|
||||
const { clientMessageId, messageId } = item;
|
||||
if (!clientMessageId) return null;
|
||||
const existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId);
|
||||
if (!existing || existing.item.type !== "user_message") return null;
|
||||
if (messageId) {
|
||||
const enriched = this.timelineStore.enrichSubmittedUserMessage(
|
||||
agent.id,
|
||||
clientMessageId,
|
||||
messageId,
|
||||
);
|
||||
if (enriched) this.enqueueDurableTimelineUpdate(agent.id, enriched);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
private async appendSystemErrorTimelineMessage(
|
||||
agent: ActiveManagedAgent,
|
||||
provider: AgentProvider,
|
||||
@@ -3806,7 +4037,7 @@ export class AgentManager {
|
||||
private recordTimeline(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem,
|
||||
options?: { timestamp?: string },
|
||||
options?: { timestamp?: string; providerMessageId?: string },
|
||||
): AgentTimelineRow {
|
||||
item = limitAgentTimelineItemContent(item);
|
||||
const row = this.timelineStore.append(agentId, item, options);
|
||||
@@ -3928,6 +4159,17 @@ export class AgentManager {
|
||||
this.trackBackgroundTask(task);
|
||||
}
|
||||
|
||||
private enqueueDurableTimelineUpdate(agentId: string, row: AgentTimelineRow): void {
|
||||
if (!this.durableTimelineStore) return;
|
||||
const task = this.durableTimelineStore.updateCommittedRow(agentId, row).catch((err) => {
|
||||
this.logger.error(
|
||||
{ err, agentId, seq: row.seq, itemType: row.item.type },
|
||||
"Failed to enrich durable timeline row",
|
||||
);
|
||||
});
|
||||
this.trackBackgroundTask(task);
|
||||
}
|
||||
|
||||
private trackBackgroundTask(task: Promise<void>): void {
|
||||
this.backgroundTasks.add(task);
|
||||
void task.finally(() => {
|
||||
@@ -3996,7 +4238,11 @@ export class AgentManager {
|
||||
private dispatchStream(
|
||||
agentId: string,
|
||||
event: AgentStreamEvent,
|
||||
metadata?: { seq?: number; epoch?: string; timestamp?: string },
|
||||
metadata?: {
|
||||
seq?: number;
|
||||
epoch?: string;
|
||||
timestamp?: string;
|
||||
},
|
||||
): void {
|
||||
if (event.type === "timeline") {
|
||||
event = {
|
||||
|
||||
@@ -79,6 +79,8 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
currentModeId: "plan",
|
||||
pendingPermissions: pendingPermissionsOverride ?? new Map<string, AgentPermissionRequest>(),
|
||||
activeForegroundTurnId: activeForegroundTurnIdValue,
|
||||
activeTurnId: activeForegroundTurnIdValue,
|
||||
activeTurnStartedAt: lifecycle === "running" ? new Date("2025-01-01T00:00:01.000Z") : null,
|
||||
foregroundTurnWaiters: new Set(),
|
||||
unsubscribeSession: null,
|
||||
timeline: [],
|
||||
@@ -105,6 +107,13 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
};
|
||||
}
|
||||
|
||||
it("projects the daemon-owned active turn identity", () => {
|
||||
expect(toAgentPayload(createManagedAgent({ lifecycle: "running" })).activeTurn).toEqual({
|
||||
turnId: "test-turn-id",
|
||||
startedAt: "2025-01-01T00:00:01.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
function createPermission(overrides: Partial<AgentPermissionRequest> = {}): AgentPermissionRequest {
|
||||
const base: AgentPermissionRequest = {
|
||||
id: "perm-1",
|
||||
|
||||
@@ -121,6 +121,12 @@ export function toAgentPayload(
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
|
||||
status: agent.lifecycle,
|
||||
activeTurn: agent.activeTurnId
|
||||
? {
|
||||
turnId: agent.activeTurnId,
|
||||
startedAt: agent.activeTurnStartedAt?.toISOString() ?? null,
|
||||
}
|
||||
: null,
|
||||
capabilities: cloneCapabilities(agent.capabilities),
|
||||
currentModeId: agent.currentModeId,
|
||||
availableModes: cloneAvailableModes(agent.availableModes),
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function startAgentRun(
|
||||
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
|
||||
// in-flight turn — replaceAgentRun would interrupt the running turn. The
|
||||
// intercept lives at this layer so it covers every prompt entrypoint.
|
||||
if (agentManager.tryRunOutOfBand(agentId, prompt)) {
|
||||
if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
|
||||
return { outOfBand: true };
|
||||
}
|
||||
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface ForegroundTurnWaiter {
|
||||
export interface PendingForegroundRun {
|
||||
token: string;
|
||||
kind: "foreground";
|
||||
stagedEvents: AgentStreamEvent[];
|
||||
turnId: string | null;
|
||||
started: boolean;
|
||||
settled: boolean;
|
||||
@@ -69,7 +70,12 @@ export class AgentRunState {
|
||||
return current;
|
||||
}
|
||||
|
||||
const run = createTrackedRun({ kind: "autonomous", turnId, started: true });
|
||||
const run: AutonomousAgentRun = {
|
||||
...createTrackedRunState(),
|
||||
kind: "autonomous",
|
||||
turnId,
|
||||
started: true,
|
||||
};
|
||||
this.runs.set(agentId, run);
|
||||
return run;
|
||||
}
|
||||
@@ -259,31 +265,27 @@ export class ForegroundTurnStream {
|
||||
}
|
||||
|
||||
function createPendingForegroundRun(): PendingForegroundRun {
|
||||
return createTrackedRun({ kind: "foreground", turnId: null, started: false });
|
||||
return {
|
||||
...createTrackedRunState(),
|
||||
kind: "foreground",
|
||||
turnId: null,
|
||||
started: false,
|
||||
stagedEvents: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createTrackedRun(input: {
|
||||
kind: "foreground";
|
||||
turnId: null;
|
||||
started: false;
|
||||
}): PendingForegroundRun;
|
||||
function createTrackedRun(input: {
|
||||
kind: "autonomous";
|
||||
turnId: string | null;
|
||||
started: true;
|
||||
}): AutonomousAgentRun;
|
||||
function createTrackedRun(
|
||||
input:
|
||||
| { kind: "foreground"; turnId: null; started: false }
|
||||
| { kind: "autonomous"; turnId: string | null; started: true },
|
||||
): TrackedAgentRun {
|
||||
function createTrackedRunState(): {
|
||||
token: string;
|
||||
settled: boolean;
|
||||
settledPromise: Promise<void>;
|
||||
resolveSettled: () => void;
|
||||
} {
|
||||
let resolveSettled!: () => void;
|
||||
const settledPromise = new Promise<void>((resolvePromise) => {
|
||||
resolveSettled = resolvePromise;
|
||||
});
|
||||
return {
|
||||
token: randomUUID(),
|
||||
...input,
|
||||
settled: false,
|
||||
settledPromise,
|
||||
resolveSettled,
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface AgentTimelineRow {
|
||||
seq: number;
|
||||
timestamp: string;
|
||||
item: AgentTimelineItem;
|
||||
readonly providerMessageId?: string;
|
||||
}
|
||||
|
||||
export interface AgentTimelineCursor {
|
||||
@@ -58,4 +59,5 @@ export interface AgentTimelineStore {
|
||||
getLastAssistantMessage(agentId: string): Promise<string | null>;
|
||||
deleteAgent(agentId: string): Promise<void>;
|
||||
bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void>;
|
||||
updateCommittedRow(agentId: string, row: AgentTimelineRow): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -167,6 +167,35 @@ export class InMemoryAgentTimelineStore {
|
||||
return this.requireState(agentId).rows.map(cloneRow);
|
||||
}
|
||||
|
||||
getSubmittedUserMessage(agentId: string, clientMessageId: string): AgentTimelineRow | null {
|
||||
const row = this.requireState(agentId).rows.find(
|
||||
(candidate) =>
|
||||
candidate.item.type === "user_message" &&
|
||||
candidate.item.clientMessageId === clientMessageId,
|
||||
);
|
||||
return row ? cloneRow(row) : null;
|
||||
}
|
||||
|
||||
enrichSubmittedUserMessage(
|
||||
agentId: string,
|
||||
clientMessageId: string,
|
||||
providerMessageId: string,
|
||||
): AgentTimelineRow | null {
|
||||
const state = this.requireState(agentId);
|
||||
const index = state.rows.findIndex(
|
||||
(candidate) =>
|
||||
candidate.item.type === "user_message" &&
|
||||
candidate.item.clientMessageId === clientMessageId,
|
||||
);
|
||||
const row = state.rows[index];
|
||||
if (!row || row.item.type !== "user_message") {
|
||||
return null;
|
||||
}
|
||||
const enriched: AgentTimelineRow = { ...row, providerMessageId };
|
||||
state.rows[index] = enriched;
|
||||
return cloneRow(enriched);
|
||||
}
|
||||
|
||||
getEpoch(agentId: string): string {
|
||||
return this.requireState(agentId).epoch;
|
||||
}
|
||||
@@ -235,13 +264,14 @@ export class InMemoryAgentTimelineStore {
|
||||
append(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem,
|
||||
options?: { timestamp?: string },
|
||||
options?: { timestamp?: string; providerMessageId?: string },
|
||||
): AgentTimelineRow {
|
||||
const state = this.requireState(agentId);
|
||||
const row: AgentTimelineRow = {
|
||||
seq: state.nextSeq,
|
||||
timestamp: options?.timestamp ?? new Date().toISOString(),
|
||||
item,
|
||||
...(options?.providerMessageId ? { providerMessageId: options.providerMessageId } : {}),
|
||||
};
|
||||
state.nextSeq += 1;
|
||||
state.rows.push(row);
|
||||
|
||||
@@ -30,7 +30,7 @@ interface CreateAgentLifecycleDispatchDependencies {
|
||||
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
emit: (message: SessionOutboundMessage) => void;
|
||||
emitAgentRemove: (agentId: string) => void;
|
||||
emitAgentRemove: (agentId: string) => Promise<void>;
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
|
||||
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
|
||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
|
||||
@@ -222,7 +222,7 @@ export class CreateAgentLifecycleDispatch {
|
||||
);
|
||||
|
||||
if (options.agentId) {
|
||||
this.dependencies.emitAgentRemove(options.agentId);
|
||||
await this.dependencies.emitAgentRemove(options.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2345,6 +2345,15 @@ describe("ACPAgentSession", () => {
|
||||
messageId: "msg-client-1",
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
});
|
||||
expect(
|
||||
events
|
||||
.filter(
|
||||
(event) =>
|
||||
event.type === "turn_started" ||
|
||||
(event.type === "timeline" && event.item.type === "user_message"),
|
||||
)
|
||||
.map((event) => event.type),
|
||||
).toEqual(["turn_started", "timeline"]);
|
||||
expect(
|
||||
events.filter((event) => event.type === "timeline" && event.item.type === "user_message"),
|
||||
).toEqual([
|
||||
|
||||
@@ -1507,6 +1507,22 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
};
|
||||
}
|
||||
|
||||
test("emits turn_started before the submitted user message", async () => {
|
||||
const session = await createSessionForTurns([[]]);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.startTurn("turn", { clientMessageId: "client-message-1" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(events.slice(0, 2).map((event) => event.type)).toEqual(["turn_started", "timeline"]);
|
||||
expect(events[1]).toMatchObject({
|
||||
type: "timeline",
|
||||
item: { type: "user_message", clientMessageId: "client-message-1" },
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("passes persistSession through to the Claude SDK query options", async () => {
|
||||
const createResultTurn = (sessionId: string) => [
|
||||
{
|
||||
|
||||
@@ -1119,7 +1119,13 @@ describe("Codex app-server provider", () => {
|
||||
async () => appServer.child,
|
||||
);
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
await session.startTurn("remember this", { clientMessageId: "client-message" });
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
threadId: "thread-1",
|
||||
turn: { id: "turn-1" },
|
||||
});
|
||||
const userMessage = waitForNextTimelineItem(session, "user_message");
|
||||
emitCodexUserMessage(appServer, { id: "codex-message", text: "remember this" });
|
||||
|
||||
@@ -1130,6 +1136,7 @@ describe("Codex app-server provider", () => {
|
||||
clientMessageId: "client-message",
|
||||
},
|
||||
});
|
||||
expect(events.slice(0, 2).map((event) => event.type)).toEqual(["turn_started", "timeline"]);
|
||||
appServer.completeTurn();
|
||||
await session.close();
|
||||
});
|
||||
@@ -2727,8 +2734,14 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects an interrupt until Codex identifies the accepted turn", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
test("waits for Codex to identify an accepted turn before interrupting it", async () => {
|
||||
const interruptedTurns: unknown[] = [];
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"turn/interrupt": async (params) => {
|
||||
interruptedTurns.push(params);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
@@ -2740,11 +2753,11 @@ describe("Codex app-server provider", () => {
|
||||
const resultPromise = session.run("Start working.");
|
||||
await appServer.waitForTurnStart();
|
||||
|
||||
await expect(session.interrupt()).rejects.toThrow(
|
||||
"Cannot interrupt Codex before turn/started identifies the active turn",
|
||||
);
|
||||
|
||||
const interruptPromise = session.interrupt();
|
||||
appServer.startsTurn({ threadId: "thread-1", turnId: "turn-identified-late" });
|
||||
await interruptPromise;
|
||||
|
||||
expect(interruptedTurns).toEqual([{ threadId: "thread-1", turnId: "turn-identified-late" }]);
|
||||
appServer.completeTurn();
|
||||
await resultPromise;
|
||||
appServer.assertNoErrors();
|
||||
@@ -2753,6 +2766,38 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("does not interrupt after the accepted turn terminates before identification", async () => {
|
||||
const interruptedTurns: unknown[] = [];
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"turn/interrupt": async (params) => {
|
||||
interruptedTurns.push(params);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
|
||||
try {
|
||||
const resultPromise = session.run("Finish before identification.");
|
||||
await appServer.waitForTurnStart();
|
||||
const interruptPromise = session.interrupt();
|
||||
appServer.completeTurn();
|
||||
|
||||
await expect(interruptPromise).rejects.toThrow(
|
||||
"Cannot interrupt Codex before turn/started identifies the active turn",
|
||||
);
|
||||
await resultPromise;
|
||||
expect(interruptedTurns).toEqual([]);
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects an interrupt before Codex initializes the thread", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
|
||||
@@ -1988,6 +1988,7 @@ const TurnCompletedNotificationSchema = z
|
||||
threadId: z.string().optional(),
|
||||
turn: z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
status: z.string(),
|
||||
error: z
|
||||
.object({
|
||||
@@ -3108,6 +3109,11 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private currentMode: string;
|
||||
private currentThreadId: string | null = null;
|
||||
private currentTurnId: string | null = null;
|
||||
private pendingForegroundTurnIdentification: {
|
||||
foregroundTurnId: string;
|
||||
promise: Promise<string | null>;
|
||||
resolve: (turnId: string | null) => void;
|
||||
} | null = null;
|
||||
private client: CodexAppServerClient | null = null;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private nextTurnOrdinal = 0;
|
||||
@@ -3862,6 +3868,16 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.activeClientMessageId = options?.clientMessageId ?? null;
|
||||
this.currentTurnId = null;
|
||||
this.pendingForegroundTurnIdentification?.resolve(null);
|
||||
let resolveTurnIdentification!: (identifiedTurnId: string | null) => void;
|
||||
const turnIdentification = new Promise<string | null>((resolvePromise) => {
|
||||
resolveTurnIdentification = resolvePromise;
|
||||
});
|
||||
this.pendingForegroundTurnIdentification = {
|
||||
foregroundTurnId: turnId,
|
||||
promise: turnIdentification,
|
||||
resolve: resolveTurnIdentification,
|
||||
};
|
||||
|
||||
this.logTurnStartSummary({
|
||||
turnId,
|
||||
@@ -3875,6 +3891,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
|
||||
return { turnId };
|
||||
} catch (error) {
|
||||
this.pendingForegroundTurnIdentification?.resolve(null);
|
||||
this.pendingForegroundTurnIdentification = null;
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
throw error;
|
||||
@@ -4264,14 +4282,26 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (!this.client || !this.currentThreadId) {
|
||||
throw new Error("Cannot interrupt Codex before the active thread is initialized");
|
||||
}
|
||||
if (!this.currentTurnId) {
|
||||
let turnId = this.currentTurnId;
|
||||
const foregroundTurnId = this.activeForegroundTurnId;
|
||||
const pendingIdentification = this.pendingForegroundTurnIdentification;
|
||||
// turn/start is accepted before Codex publishes the native turn id. Keep the
|
||||
// interrupt attached to this foreground turn until that ordered notification arrives.
|
||||
if (
|
||||
!turnId &&
|
||||
foregroundTurnId &&
|
||||
pendingIdentification?.foregroundTurnId === foregroundTurnId
|
||||
) {
|
||||
turnId = await pendingIdentification.promise;
|
||||
}
|
||||
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
|
||||
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
|
||||
}
|
||||
await this.client.request(
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId: this.currentThreadId,
|
||||
turnId: this.currentTurnId,
|
||||
turnId,
|
||||
},
|
||||
INTERRUPT_TIMEOUT_MS,
|
||||
);
|
||||
@@ -4288,6 +4318,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.subscribers.clear();
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
this.pendingForegroundTurnIdentification?.resolve(null);
|
||||
this.pendingForegroundTurnIdentification = null;
|
||||
if (this.client) {
|
||||
await this.client.dispose();
|
||||
}
|
||||
@@ -5300,6 +5332,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
this.currentTurnId = parsed.turnId;
|
||||
const pendingIdentification = this.pendingForegroundTurnIdentification;
|
||||
if (
|
||||
pendingIdentification &&
|
||||
pendingIdentification.foregroundTurnId === this.activeForegroundTurnId
|
||||
) {
|
||||
pendingIdentification.resolve(parsed.turnId);
|
||||
this.pendingForegroundTurnIdentification = null;
|
||||
}
|
||||
this.resetTurnTrackingState();
|
||||
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
|
||||
}
|
||||
@@ -5338,6 +5378,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
this.currentTurnId = null;
|
||||
this.pendingForegroundTurnIdentification?.resolve(null);
|
||||
this.pendingForegroundTurnIdentification = null;
|
||||
this.pendingSubAgentNotificationsByThreadId.clear();
|
||||
this.resetTurnTrackingState();
|
||||
}
|
||||
|
||||
@@ -323,7 +323,10 @@ export function createFakeCodexAppServer(
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
method: "turn/completed",
|
||||
params: { threadId: params.threadId ?? "thread-1", turn: { status: "completed" } },
|
||||
params: {
|
||||
threadId: params.threadId ?? "thread-1",
|
||||
turn: { status: "completed" },
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { MockLoadTestAgentClient } from "./mock-load-test-agent.js";
|
||||
|
||||
async function createIdleSessionWithProviderUserMessage(): Promise<{
|
||||
session: AgentSession;
|
||||
messageId: string;
|
||||
}> {
|
||||
const client = new MockLoadTestAgentClient();
|
||||
const session = await client.createSession({
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.startTurn("Emit synthetic user message before accepting turn.");
|
||||
await session.interrupt();
|
||||
|
||||
const userMessage = events.find(
|
||||
(event) => event.type === "timeline" && event.item.type === "user_message",
|
||||
);
|
||||
if (userMessage?.type !== "timeline" || userMessage.item.type !== "user_message") {
|
||||
throw new Error("Mock provider user message was not emitted");
|
||||
}
|
||||
if (!userMessage.item.messageId) {
|
||||
throw new Error("Mock provider user message ID was not emitted");
|
||||
}
|
||||
return { session, messageId: userMessage.item.messageId };
|
||||
}
|
||||
|
||||
function requireRewindCapabilities(session: AgentSession): {
|
||||
conversation: NonNullable<AgentSession["revertConversation"]>;
|
||||
files: NonNullable<AgentSession["revertFiles"]>;
|
||||
both: NonNullable<AgentSession["revertBoth"]>;
|
||||
} {
|
||||
if (!session.revertConversation || !session.revertFiles || !session.revertBoth) {
|
||||
throw new Error("Mock provider rewind capabilities are unavailable");
|
||||
}
|
||||
return {
|
||||
conversation: session.revertConversation.bind(session),
|
||||
files: session.revertFiles.bind(session),
|
||||
both: session.revertBoth.bind(session),
|
||||
};
|
||||
}
|
||||
|
||||
describe("MockLoadTestAgentSession rewind", () => {
|
||||
test("rejects unknown targets and accepts a provider-owned user message", async () => {
|
||||
const { session, messageId } = await createIdleSessionWithProviderUserMessage();
|
||||
const rewind = requireRewindCapabilities(session);
|
||||
|
||||
await expect(rewind.conversation({ messageId: "unknown-message" })).rejects.toThrow(
|
||||
"Mock rewind target unknown-message was not found in session history",
|
||||
);
|
||||
await expect(rewind.files({ messageId: "unknown-message" })).rejects.toThrow(
|
||||
"Mock rewind target unknown-message was not found in session history",
|
||||
);
|
||||
await expect(rewind.both({ messageId: "unknown-message" })).rejects.toThrow(
|
||||
"Mock rewind target unknown-message was not found in session history",
|
||||
);
|
||||
|
||||
await expect(rewind.files({ messageId })).resolves.toBeUndefined();
|
||||
await expect(rewind.conversation({ messageId })).resolves.toBeUndefined();
|
||||
await expect(rewind.both({ messageId })).resolves.toBeUndefined();
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,98 @@ describe("MockLoadTestAgentClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects the configured number of prompts before starting a retry", async () => {
|
||||
const client = new MockLoadTestAgentClient();
|
||||
const session = await client.createSession({
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
featureValues: { mockPromptRejections: 1 },
|
||||
});
|
||||
|
||||
await expect(session.startTurn("Reject this prompt.")).rejects.toThrow(
|
||||
"Requested mock prompt rejection",
|
||||
);
|
||||
|
||||
await expect(session.startTurn("Accept this retry.")).resolves.toEqual({
|
||||
turnId: expect.any(String),
|
||||
});
|
||||
await session.interrupt();
|
||||
});
|
||||
|
||||
test("can withhold the provider user-message echo until an immediate interrupt", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MockLoadTestAgentClient();
|
||||
const session = await client.createSession({
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.startTurn("Withhold synthetic user message until interrupted.", {
|
||||
clientMessageId: "client-message-1",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(events.some((event) => event.type === "timeline")).toBe(false);
|
||||
|
||||
await session.interrupt();
|
||||
expect(events.map((event) => event.type)).toEqual(["turn_canceled"]);
|
||||
});
|
||||
|
||||
test("can emit the provider user-message echo before accepting the turn", async () => {
|
||||
const client = new MockLoadTestAgentClient();
|
||||
const session = await client.createSession({
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.startTurn("Emit synthetic user message before accepting turn.", {
|
||||
clientMessageId: "client-message-1",
|
||||
});
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "timeline",
|
||||
provider: "mock",
|
||||
turnId: expect.any(String),
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "Emit synthetic user message before accepting turn.",
|
||||
messageId: expect.any(String),
|
||||
clientMessageId: "client-message-1",
|
||||
},
|
||||
});
|
||||
await session.interrupt();
|
||||
});
|
||||
|
||||
test("can place the provider echo beyond a bounded timeline tail", async () => {
|
||||
const client = new MockLoadTestAgentClient();
|
||||
const session = await client.createSession({
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.run("Emit 205 assistant messages before synthetic user message.", {
|
||||
clientMessageId: "client-message-1",
|
||||
});
|
||||
|
||||
const timelineItems = events.flatMap((event) =>
|
||||
event.type === "timeline" ? [event.item] : [],
|
||||
);
|
||||
expect(timelineItems.filter((item) => item.type === "assistant_message")).toHaveLength(205);
|
||||
expect(timelineItems.at(-1)).toMatchObject({
|
||||
type: "user_message",
|
||||
clientMessageId: "client-message-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns schema-shaped JSON for structured branch-name generation", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MockLoadTestAgentClient();
|
||||
@@ -211,6 +303,32 @@ describe("MockLoadTestAgentClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("emits turn_started before the submitted user message", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MockLoadTestAgentClient();
|
||||
const session = await client.createSession({
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.startTurn("Order the submitted prompt.", {
|
||||
clientMessageId: "client-message-1",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
unsubscribe();
|
||||
|
||||
expect(
|
||||
events
|
||||
.slice(0, 2)
|
||||
.map((event) =>
|
||||
event.type === "timeline" ? `${event.type}:${event.item.type}` : event.type,
|
||||
),
|
||||
).toEqual(["turn_started", "timeline:user_message"]);
|
||||
});
|
||||
|
||||
test("emits the free-write question scenario selected by prompt", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MockLoadTestAgentClient();
|
||||
@@ -265,6 +383,39 @@ describe("MockLoadTestAgentClient", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("emits a settled assistant Markdown image path selected by prompt", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MockLoadTestAgentClient();
|
||||
const session = await client.createSession({
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => events.push(event));
|
||||
const markdown = "";
|
||||
|
||||
const resultPromise = session.run(`Emit settled assistant image Markdown: ${markdown}`);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(
|
||||
events.flatMap((event): AgentTimelineItem[] =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item] : [],
|
||||
),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: markdown,
|
||||
}),
|
||||
]);
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
sessionId: session.id,
|
||||
finalText: markdown,
|
||||
canceled: false,
|
||||
});
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("agent manager coalesces adjacent assistant tokens into fewer messages", async () => {
|
||||
vi.useFakeTimers();
|
||||
const workdir = mkdtempSync(join(tmpdir(), "paseo-mock-load-test-"));
|
||||
|
||||
@@ -33,9 +33,12 @@ import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest
|
||||
|
||||
export const MOCK_LOAD_TEST_PROVIDER_ID = "mock";
|
||||
export const MOCK_LOAD_TEST_DEFAULT_MODEL_ID = "five-minute-stream";
|
||||
export const MOCK_LOAD_TEST_HANDLED_COMMAND = "/mock handled-command";
|
||||
const MOCK_LOAD_TEST_MODE_ID = "load-test";
|
||||
const MOCK_LOAD_TEST_DURATION_MS = 5 * 60 * 1000;
|
||||
const MOCK_LOAD_TEST_INTERVAL_MS = 40;
|
||||
const ONE_PIXEL_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl4Kj8AAAAASUVORK5CYII=";
|
||||
|
||||
const CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
@@ -156,6 +159,13 @@ function shouldEmitTurnFailure(prompt: AgentPromptInput): boolean {
|
||||
return /emit\s+(?:a\s+)?synthetic\s+turn\s+failure/i.test(promptToText(prompt));
|
||||
}
|
||||
|
||||
function parseSettledAssistantImageMarkdown(prompt: AgentPromptInput): string | null {
|
||||
const match = /^emit settled assistant image markdown:\s*(!\[[^\]\r\n]*\]\(.+\))\s*$/i.exec(
|
||||
promptToText(prompt),
|
||||
);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null {
|
||||
const text = promptToText(prompt);
|
||||
if (!/emit\s+(?:a\s+)?synthetic\s+questions?/i.test(text)) {
|
||||
@@ -234,6 +244,28 @@ function promptToText(prompt: AgentPromptInput): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseUserMessageDelayMs(prompt: AgentPromptInput): number {
|
||||
const match = /delay synthetic user message by (\d+)ms/i.exec(promptToText(prompt));
|
||||
const delayMs = Number(match?.[1] ?? 0);
|
||||
return Number.isSafeInteger(delayMs) ? Math.min(delayMs, 2_000) : 0;
|
||||
}
|
||||
|
||||
function shouldWithholdUserMessageUntilInterrupt(prompt: AgentPromptInput): boolean {
|
||||
return /withhold synthetic user message until interrupted/i.test(promptToText(prompt));
|
||||
}
|
||||
|
||||
function shouldEmitUserMessageBeforeTurnAcceptance(prompt: AgentPromptInput): boolean {
|
||||
return /emit synthetic user message before accepting turn/i.test(promptToText(prompt));
|
||||
}
|
||||
|
||||
function parseAssistantMessagesBeforeUserMessage(prompt: AgentPromptInput): number | null {
|
||||
const match = /emit (\d+) assistant messages before synthetic user message/i.exec(
|
||||
promptToText(prompt),
|
||||
);
|
||||
const count = Number(match?.[1]);
|
||||
return Number.isSafeInteger(count) && count > 0 ? Math.min(count, 500) : null;
|
||||
}
|
||||
|
||||
function parseLargeAgentStreamPayloadPrompt(
|
||||
prompt: AgentPromptInput,
|
||||
): LargeAgentStreamPayloadRequest | null {
|
||||
@@ -583,6 +615,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
private modeId: string | null;
|
||||
private modelId: string | null;
|
||||
private readonly rewindError: string | null;
|
||||
private remainingPromptRejections: number;
|
||||
|
||||
constructor(options: { config: AgentSessionConfig; sessionId: string; logger?: Logger }) {
|
||||
this.id = options.sessionId;
|
||||
@@ -593,6 +626,13 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
typeof options.config.featureValues?.mockRewindError === "string"
|
||||
? options.config.featureValues.mockRewindError
|
||||
: null;
|
||||
const requestedPromptRejections = options.config.featureValues?.mockPromptRejections;
|
||||
this.remainingPromptRejections =
|
||||
typeof requestedPromptRejections === "number" &&
|
||||
Number.isSafeInteger(requestedPromptRejections) &&
|
||||
requestedPromptRejections > 0
|
||||
? requestedPromptRejections
|
||||
: 0;
|
||||
}
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
@@ -611,6 +651,10 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
if (this.activeTurn) {
|
||||
throw new Error("Mock load-test provider already has an active turn");
|
||||
}
|
||||
if (this.remainingPromptRejections > 0) {
|
||||
this.remainingPromptRejections -= 1;
|
||||
throw new Error("Requested mock prompt rejection");
|
||||
}
|
||||
|
||||
const profile = resolveModelProfile(this.modelId);
|
||||
const turnId = randomUUID();
|
||||
@@ -635,11 +679,35 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
turnStarted: false,
|
||||
};
|
||||
this.activeTurn = turn;
|
||||
const userMessageId = randomUUID();
|
||||
setTimeout(() => {
|
||||
const largePayload = parseLargeAgentStreamPayloadPrompt(prompt);
|
||||
const stress = parseAgentStreamStressPrompt(prompt);
|
||||
const questionPrompt = parseMockQuestionPrompt(prompt);
|
||||
const structuredBranchName = parseStructuredBranchNamePrompt(prompt);
|
||||
const settledAssistantImageMarkdown = parseSettledAssistantImageMarkdown(prompt);
|
||||
const scheduleTurn = () => {
|
||||
if (shouldEmitTurnFailure(prompt)) {
|
||||
this.scheduleFailedTurn(turn);
|
||||
} else if (structuredBranchName) {
|
||||
this.scheduleSettledAssistantTurn(turn, JSON.stringify(structuredBranchName));
|
||||
} else if (settledAssistantImageMarkdown) {
|
||||
this.scheduleSettledAssistantTurn(turn, settledAssistantImageMarkdown);
|
||||
} else if (shouldEmitPlanApprovalPrompt(prompt)) {
|
||||
this.schedulePlanApprovalTurn(turn);
|
||||
} else if (questionPrompt) {
|
||||
this.scheduleQuestionPromptTurn(turn, questionPrompt);
|
||||
} else if (largePayload) {
|
||||
this.scheduleLargePayloadTurn(turn, largePayload);
|
||||
} else if (stress) {
|
||||
this.scheduleStressTurn(turn, stress);
|
||||
} else {
|
||||
this.schedule(turn, 0);
|
||||
}
|
||||
};
|
||||
const emitUserMessage = () => {
|
||||
if (this.activeTurn?.turnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
this.emitTurnStarted(turn);
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
@@ -647,34 +715,66 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: promptToText(prompt),
|
||||
messageId: userMessageId,
|
||||
messageId: randomUUID(),
|
||||
...(options?.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
|
||||
},
|
||||
});
|
||||
}, 0);
|
||||
|
||||
const largePayload = parseLargeAgentStreamPayloadPrompt(prompt);
|
||||
const stress = parseAgentStreamStressPrompt(prompt);
|
||||
const questionPrompt = parseMockQuestionPrompt(prompt);
|
||||
const structuredBranchName = parseStructuredBranchNamePrompt(prompt);
|
||||
if (shouldEmitTurnFailure(prompt)) {
|
||||
this.scheduleFailedTurn(turn);
|
||||
} else if (structuredBranchName) {
|
||||
this.scheduleStructuredJsonTurn(turn, structuredBranchName);
|
||||
} else if (shouldEmitPlanApprovalPrompt(prompt)) {
|
||||
this.schedulePlanApprovalTurn(turn);
|
||||
} else if (questionPrompt) {
|
||||
this.scheduleQuestionPromptTurn(turn, questionPrompt);
|
||||
} else if (largePayload) {
|
||||
this.scheduleLargePayloadTurn(turn, largePayload);
|
||||
} else if (stress) {
|
||||
this.scheduleStressTurn(turn, stress);
|
||||
} else {
|
||||
this.schedule(turn, 0);
|
||||
};
|
||||
if (shouldEmitUserMessageBeforeTurnAcceptance(prompt)) {
|
||||
emitUserMessage();
|
||||
scheduleTurn();
|
||||
return { turnId };
|
||||
}
|
||||
if (shouldWithholdUserMessageUntilInterrupt(prompt)) {
|
||||
return { turnId };
|
||||
}
|
||||
const assistantMessagesBeforeUserMessage = parseAssistantMessagesBeforeUserMessage(prompt);
|
||||
if (assistantMessagesBeforeUserMessage !== null) {
|
||||
turn.timer = setTimeout(async () => {
|
||||
if (this.activeTurn !== turn) return;
|
||||
this.emitTurnStarted(turn);
|
||||
for (let index = 0; index < assistantMessagesBeforeUserMessage; index += 1) {
|
||||
this.emitTimeline(turnId, {
|
||||
type: "assistant_message",
|
||||
text: `Synthetic pre-echo message ${index + 1}`,
|
||||
messageId: `${turn.assistantMessageId}-${index + 1}`,
|
||||
});
|
||||
await new Promise<void>((resolvePromise) => setImmediate(resolvePromise));
|
||||
if (this.activeTurn !== turn) return;
|
||||
}
|
||||
emitUserMessage();
|
||||
await new Promise<void>((resolvePromise) => setImmediate(resolvePromise));
|
||||
if (this.activeTurn !== turn) return;
|
||||
this.finishTurnWithText(turn, "Synthetic pre-echo stream complete");
|
||||
}, 0);
|
||||
turn.timer.unref?.();
|
||||
return { turnId };
|
||||
}
|
||||
const userMessageDelayMs = parseUserMessageDelayMs(prompt);
|
||||
const userMessageTimer = setTimeout(() => {
|
||||
emitUserMessage();
|
||||
if (userMessageDelayMs > 0) scheduleTurn();
|
||||
}, userMessageDelayMs);
|
||||
userMessageTimer.unref?.();
|
||||
if (userMessageDelayMs === 0) scheduleTurn();
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
tryHandleOutOfBand(
|
||||
prompt: AgentPromptInput,
|
||||
): { run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void> } | null {
|
||||
if (prompt !== MOCK_LOAD_TEST_HANDLED_COMMAND) return null;
|
||||
return {
|
||||
run: async ({ emit }) => {
|
||||
emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "assistant_message", text: "Mock command handled" },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.listeners.add(callback);
|
||||
return () => {
|
||||
@@ -781,18 +881,20 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
async revertConversation(_input: { messageId: string }): Promise<void> {
|
||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||
this.failConfiguredRewind();
|
||||
this.validateRewindTarget(input.messageId);
|
||||
this.keepFirstUserMessageHistory();
|
||||
}
|
||||
|
||||
async revertFiles(_input: { messageId: string }): Promise<void> {
|
||||
async revertFiles(input: { messageId: string }): Promise<void> {
|
||||
this.failConfiguredRewind();
|
||||
this.keepFirstUserMessageHistory();
|
||||
this.validateRewindTarget(input.messageId);
|
||||
}
|
||||
|
||||
async revertBoth(_input: { messageId: string }): Promise<void> {
|
||||
async revertBoth(input: { messageId: string }): Promise<void> {
|
||||
this.failConfiguredRewind();
|
||||
this.validateRewindTarget(input.messageId);
|
||||
this.keepFirstUserMessageHistory();
|
||||
}
|
||||
|
||||
@@ -807,12 +909,36 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
turn.timer.unref?.();
|
||||
}
|
||||
|
||||
private emitTurnStarted(turn: ActiveTurn): void {
|
||||
if (turn.turnStarted) {
|
||||
return;
|
||||
}
|
||||
turn.turnStarted = true;
|
||||
this.emit({
|
||||
type: "turn_started",
|
||||
provider: this.provider,
|
||||
turnId: turn.turnId,
|
||||
});
|
||||
}
|
||||
|
||||
private failConfiguredRewind(): void {
|
||||
if (this.rewindError) {
|
||||
throw new Error(this.rewindError);
|
||||
}
|
||||
}
|
||||
|
||||
private validateRewindTarget(messageId: string): void {
|
||||
const isKnownUserMessage = this.history.some(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "user_message" &&
|
||||
event.item.messageId === messageId,
|
||||
);
|
||||
if (!isKnownUserMessage) {
|
||||
throw new Error(`Mock rewind target ${messageId} was not found in session history`);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleLargePayloadTurn(
|
||||
turn: ActiveTurn,
|
||||
largePayload: LargeAgentStreamPayloadRequest,
|
||||
@@ -829,11 +955,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
this.clearTurnTimer(turn);
|
||||
this.emit({
|
||||
type: "turn_started",
|
||||
provider: this.provider,
|
||||
turnId: turn.turnId,
|
||||
});
|
||||
this.emitTurnStarted(turn);
|
||||
this.activeTurn = null;
|
||||
this.emit({
|
||||
type: "turn_failed",
|
||||
@@ -875,26 +997,21 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
turn.timer.unref?.();
|
||||
}
|
||||
|
||||
private scheduleStructuredJsonTurn(turn: ActiveTurn, result: Record<string, string>): void {
|
||||
private scheduleSettledAssistantTurn(turn: ActiveTurn, finalText: string): void {
|
||||
turn.timer = setTimeout(() => {
|
||||
this.emitStructuredJsonTurn(turn, result);
|
||||
this.emitSettledAssistantTurn(turn, finalText);
|
||||
}, 0);
|
||||
turn.timer.unref?.();
|
||||
}
|
||||
|
||||
private emitStructuredJsonTurn(turn: ActiveTurn, result: Record<string, string>): void {
|
||||
private emitSettledAssistantTurn(turn: ActiveTurn, finalText: string): void {
|
||||
if (this.activeTurn !== turn) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearTurnTimer(turn);
|
||||
this.emit({
|
||||
type: "turn_started",
|
||||
provider: this.provider,
|
||||
turnId: turn.turnId,
|
||||
});
|
||||
this.emitTurnStarted(turn);
|
||||
|
||||
const finalText = JSON.stringify(result);
|
||||
this.emitTimeline(turn.turnId, {
|
||||
type: "assistant_message",
|
||||
text: finalText,
|
||||
@@ -926,11 +1043,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
this.clearTurnTimer(turn);
|
||||
this.emit({
|
||||
type: "turn_started",
|
||||
provider: this.provider,
|
||||
turnId: turn.turnId,
|
||||
});
|
||||
this.emitTurnStarted(turn);
|
||||
|
||||
const request: AgentPermissionRequest = {
|
||||
id: `mock-plan-${turn.turnId}`,
|
||||
@@ -981,11 +1094,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
this.clearTurnTimer(turn);
|
||||
this.emit({
|
||||
type: "turn_started",
|
||||
provider: this.provider,
|
||||
turnId: turn.turnId,
|
||||
});
|
||||
this.emitTurnStarted(turn);
|
||||
|
||||
const request: AgentPermissionRequest = {
|
||||
id: `mock-questions-${turn.turnId}`,
|
||||
@@ -1016,11 +1125,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
this.clearTurnTimer(turn);
|
||||
this.emit({
|
||||
type: "turn_started",
|
||||
provider: this.provider,
|
||||
turnId: turn.turnId,
|
||||
});
|
||||
this.emitTurnStarted(turn);
|
||||
|
||||
for (let index = 0; index < stress.count; index += 1) {
|
||||
this.emitTimeline(
|
||||
@@ -1069,11 +1174,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
this.clearTurnTimer(turn);
|
||||
this.emit({
|
||||
type: "turn_started",
|
||||
provider: this.provider,
|
||||
turnId: turn.turnId,
|
||||
});
|
||||
this.emitTurnStarted(turn);
|
||||
|
||||
const payload = buildRepeatedPayload(largePayload.bytes, largePayload.kind);
|
||||
if (largePayload.kind === "diff") {
|
||||
@@ -1105,9 +1206,14 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const imageBytes = Buffer.from(ONE_PIXEL_PNG_BASE64, "base64");
|
||||
const imagePayload = Buffer.concat([
|
||||
imageBytes,
|
||||
Buffer.alloc(Math.max(0, largePayload.bytes - imageBytes.length)),
|
||||
]).toString("base64");
|
||||
this.emitTimeline(turn.turnId, {
|
||||
type: "assistant_message",
|
||||
text: `data:image/png;base64,${payload}`,
|
||||
text: ``,
|
||||
messageId: turn.assistantMessageId,
|
||||
});
|
||||
}
|
||||
@@ -1140,14 +1246,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
this.clearTurnTimer(turn);
|
||||
if (!turn.turnStarted) {
|
||||
turn.turnStarted = true;
|
||||
this.emit({
|
||||
type: "turn_started",
|
||||
provider: this.provider,
|
||||
turnId: turn.turnId,
|
||||
});
|
||||
}
|
||||
this.emitTurnStarted(turn);
|
||||
|
||||
const elapsedMs = Date.now() - turn.startedAt;
|
||||
if (elapsedMs >= turn.durationMs) {
|
||||
|
||||
@@ -147,6 +147,7 @@ describe("OMP agent client and session", () => {
|
||||
{ type: "user_message", text: "hello OMP", messageId: "user-1" },
|
||||
{ type: "assistant_message", text: "hello from OMP", messageId: "omp-assistant-1" },
|
||||
]);
|
||||
expect(omp.eventTypes().slice(0, 2)).toEqual(["turn_started", "timeline"]);
|
||||
expect(omp.completedTurnCount()).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -391,6 +391,10 @@ export class OmpHarness {
|
||||
return this.events.flatMap((event) => (event.type === "timeline" ? [event.item] : []));
|
||||
}
|
||||
|
||||
eventTypes(): AgentStreamEvent["type"][] {
|
||||
return this.events.map((event) => event.type);
|
||||
}
|
||||
|
||||
async history(): Promise<AgentTimelineItem[]> {
|
||||
const items: AgentTimelineItem[] = [];
|
||||
for await (const event of this.requireSession().streamHistory()) {
|
||||
|
||||
@@ -1182,6 +1182,19 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
directory: "/tmp/test",
|
||||
payload: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "msg_user",
|
||||
sessionID: "ses_unit_test",
|
||||
role: "user",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
directory: "/tmp/test",
|
||||
payload: {
|
||||
@@ -1243,17 +1256,25 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
createTestLogger(),
|
||||
);
|
||||
|
||||
const turn = await collectTurnEvents(streamSession(session, "hello"));
|
||||
const turn = await collectTurnEvents(
|
||||
streamSession(session, "hello", { clientMessageId: "client-message-1" }),
|
||||
);
|
||||
|
||||
expect(turn.events.map((event) => event.type)).toEqual([
|
||||
"turn_started",
|
||||
"timeline",
|
||||
"timeline",
|
||||
"turn_completed",
|
||||
]);
|
||||
expect(turn.events[1]).toMatchObject({
|
||||
type: "timeline",
|
||||
item: { type: "user_message", clientMessageId: "client-message-1" },
|
||||
});
|
||||
expect(turn.events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -181,6 +181,10 @@ class SessionEvents {
|
||||
});
|
||||
}
|
||||
|
||||
eventTypes(): AgentStreamEvent["type"][] {
|
||||
return this.events.map((event) => event.type);
|
||||
}
|
||||
|
||||
turnCompletedEvents() {
|
||||
return this.events.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
|
||||
@@ -608,6 +612,7 @@ describe("PiRpcAgentSession", () => {
|
||||
const fakeSession = pi.latestSession();
|
||||
|
||||
await session.startTurn("hello");
|
||||
fakeSession.emit({ type: "turn_start" });
|
||||
fakeSession.finishSubmittedUserMessage({
|
||||
id: "entry-user-1",
|
||||
parentId: null,
|
||||
@@ -619,6 +624,7 @@ describe("PiRpcAgentSession", () => {
|
||||
expect(events.timelineItems()).toEqual([
|
||||
{ type: "user_message", text: "hello", messageId: "entry-user-1" },
|
||||
]);
|
||||
expect(events.eventTypes().slice(0, 2)).toEqual(["turn_started", "timeline"]);
|
||||
});
|
||||
|
||||
test("uses the Pi entry attached to a submitted prompt after resuming old history", async () => {
|
||||
|
||||
@@ -627,7 +627,63 @@ describe("selectProjectedTimelinePage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("before page includes a wide tool whose earlier source range is before the cursor", () => {
|
||||
test("before limit counts projected entries without repeating a merged assistant", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
...Array.from({ length: 80 }, (_, index) => ({
|
||||
seq: index + 1,
|
||||
timestamp: new Date(1000 + index).toISOString(),
|
||||
item: { type: "assistant_message" as const, text: "x" },
|
||||
})),
|
||||
...Array.from({ length: 4 }, (_, index) => ({
|
||||
seq: index + 81,
|
||||
timestamp: new Date(2000 + index).toISOString(),
|
||||
item: { type: "user_message" as const, text: `message ${index + 1}` },
|
||||
})),
|
||||
];
|
||||
|
||||
const newest = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 85,
|
||||
limit: 2,
|
||||
});
|
||||
const middle = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: newest.startSeq ?? 0,
|
||||
limit: 2,
|
||||
});
|
||||
const oldest = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: middle.startSeq ?? 0,
|
||||
limit: 2,
|
||||
});
|
||||
const exhausted = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: oldest.startSeq ?? 0,
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
const pageSummaries = [];
|
||||
for (const page of [newest, middle, oldest]) {
|
||||
pageSummaries.push({
|
||||
entryTypes: page.entries.map((entry) => entry.item.type),
|
||||
startSeq: page.startSeq,
|
||||
endSeq: page.endSeq,
|
||||
hasOlder: page.hasOlder,
|
||||
});
|
||||
}
|
||||
expect(pageSummaries).toEqual([
|
||||
{ entryTypes: ["user_message", "user_message"], startSeq: 83, endSeq: 84, hasOlder: true },
|
||||
{ entryTypes: ["user_message", "user_message"], startSeq: 81, endSeq: 82, hasOlder: true },
|
||||
{ entryTypes: ["assistant_message"], startSeq: 1, endSeq: 80, hasOlder: false },
|
||||
]);
|
||||
expect(exhausted.entries).toEqual([]);
|
||||
});
|
||||
|
||||
test("before pages place a full wide tool on the page containing its projected anchor", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
toolRow(1, "running"),
|
||||
...Array.from({ length: 498 }, (_, index) => ({
|
||||
@@ -638,16 +694,32 @@ describe("selectProjectedTimelinePage", () => {
|
||||
toolRow(500, "completed"),
|
||||
];
|
||||
|
||||
const page = selectProjectedTimelinePage({
|
||||
const newestPage = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 500,
|
||||
limit: 100,
|
||||
});
|
||||
const anchoredPage = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 101,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
expect(page.entries.some((entry) => entry.item.type === "tool_call")).toBe(true);
|
||||
expect(page.endSeq).toBeLessThan(500);
|
||||
expect(page.hasOlder).toBe(true);
|
||||
expect(newestPage.entries).toHaveLength(100);
|
||||
expect(newestPage.entries.some((entry) => entry.item.type === "tool_call")).toBe(false);
|
||||
expect(newestPage.startSeq).toBe(400);
|
||||
expect(newestPage.endSeq).toBe(499);
|
||||
expect(newestPage.hasOlder).toBe(true);
|
||||
|
||||
expect(anchoredPage.entries).toHaveLength(100);
|
||||
expect(anchoredPage.entries[0]?.item.type).toBe("tool_call");
|
||||
expect(anchoredPage.entries[0]?.seqStart).toBe(1);
|
||||
expect(anchoredPage.entries[0]?.seqEnd).toBe(500);
|
||||
expect(anchoredPage.startSeq).toBe(1);
|
||||
expect(anchoredPage.endSeq).toBe(100);
|
||||
expect(anchoredPage.hasOlder).toBe(false);
|
||||
});
|
||||
|
||||
test("tail page includes a wide tool when its completion is the newest seq", () => {
|
||||
|
||||
@@ -366,16 +366,6 @@ function getTimelineBounds(
|
||||
return { minSeq: first.seq, maxSeq: last.seq };
|
||||
}
|
||||
|
||||
function selectEntriesOverlappingSeqRange(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
}): TimelineProjectionEntry[] {
|
||||
return input.entries.filter(
|
||||
(entry) => entry.seqStart <= input.endSeq && entry.seqEnd >= input.startSeq,
|
||||
);
|
||||
}
|
||||
|
||||
function firstSourceSeqInRange(
|
||||
entry: TimelineProjectionEntry,
|
||||
startSeq: number,
|
||||
@@ -439,6 +429,27 @@ function selectProjectedEntriesAfter(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesBefore(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
endSeq: number;
|
||||
limit: number;
|
||||
}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } {
|
||||
// Older history follows projected display order. Lifecycle updates can move an
|
||||
// entry's seqEnd without moving its display anchor, so seqStart keeps the full
|
||||
// projected item on exactly one backward page.
|
||||
const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq);
|
||||
const selected =
|
||||
input.limit === 0 || input.limit >= eligible.length
|
||||
? eligible
|
||||
: eligible.slice(eligible.length - input.limit);
|
||||
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
hasOlder: selected.length < eligible.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectProjectedTimelinePage(input: {
|
||||
rows: readonly AgentTimelineRow[];
|
||||
bounds?: { minSeq: number; maxSeq: number };
|
||||
@@ -504,11 +515,9 @@ export function selectProjectedTimelinePage(input: {
|
||||
};
|
||||
}
|
||||
|
||||
let startSeq: number;
|
||||
let endSeq: number;
|
||||
if (input.direction === "after") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
|
||||
startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
|
||||
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
|
||||
const selected = selectProjectedEntriesAfter({
|
||||
entries: projectedAll,
|
||||
rows: input.rows,
|
||||
@@ -523,28 +532,26 @@ export function selectProjectedTimelinePage(input: {
|
||||
hasOlder: startSeq > bounds.minSeq,
|
||||
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
|
||||
};
|
||||
} else {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
|
||||
startSeq = limit === 0 ? bounds.minSeq : Math.max(bounds.minSeq, cursorSeq - limit);
|
||||
}
|
||||
|
||||
if (startSeq > endSeq) {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
|
||||
if (endSeq < bounds.minSeq) {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: startSeq > bounds.minSeq,
|
||||
hasOlder: false,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = selectEntriesOverlappingSeqRange({ entries: projectedAll, startSeq, endSeq });
|
||||
const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit });
|
||||
return {
|
||||
entries,
|
||||
startSeq,
|
||||
entries: selected.entries,
|
||||
startSeq: selected.startSeq,
|
||||
endSeq,
|
||||
hasOlder: startSeq > bounds.minSeq,
|
||||
hasOlder: selected.hasOlder,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1096,7 +1096,7 @@ export async function createPaseoDaemon(
|
||||
listActiveWorkspaces: listActiveWorkspacesExternal,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
emit: emitExternalSessionMessage,
|
||||
emitAgentRemove: () => undefined,
|
||||
emitAgentRemove: async () => undefined,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
|
||||
@@ -4,6 +4,22 @@ import type { AgentStreamEvent } from "./agent/agent-sdk-types.js";
|
||||
import { SessionInboundMessageSchema, serializeAgentStreamEvent } from "./messages.js";
|
||||
|
||||
describe("serializeAgentStreamEvent", () => {
|
||||
test("preserves daemon turn identity on lifecycle events", () => {
|
||||
expect(
|
||||
serializeAgentStreamEvent({
|
||||
type: "turn_canceled",
|
||||
provider: "codex",
|
||||
turnId: "turn-accepted-1",
|
||||
reason: "interrupted",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "turn_canceled",
|
||||
provider: "codex",
|
||||
turnId: "turn-accepted-1",
|
||||
reason: "interrupted",
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts create_agent_request env records", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
|
||||
@@ -86,11 +86,7 @@ import {
|
||||
setAgentModeCommand,
|
||||
updateAgentCommand,
|
||||
} from "./agent/lifecycle-command.js";
|
||||
import {
|
||||
buildStoredAgentPayload,
|
||||
resolveStoredAgentPayloadUpdatedAt,
|
||||
toAgentPayload,
|
||||
} from "./agent/agent-projections.js";
|
||||
import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
@@ -900,7 +896,7 @@ export class Session {
|
||||
});
|
||||
this.agentUpdates = createAgentUpdatesService({
|
||||
emit: (message) => this.emit(message),
|
||||
buildAgentPayload: (agent) => this.buildAgentPayload(agent),
|
||||
enrichAgentPayload: (payload) => this.enrichAgentPayload(payload),
|
||||
buildStoredAgentPayload: (record) => this.buildStoredAgentPayload(record),
|
||||
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
|
||||
buildProjectPlacementForWorkspaceId: (workspaceId) =>
|
||||
@@ -1646,22 +1642,17 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
private async buildAgentPayload(agent: ManagedAgent): Promise<AgentSnapshotPayload> {
|
||||
const storedRecord = await this.agentStorage.get(agent.id);
|
||||
const title = storedRecord?.title ?? null;
|
||||
const payload = toAgentPayload(agent, { title });
|
||||
const storedUpdatedAt = storedRecord ? resolveStoredAgentPayloadUpdatedAt(storedRecord) : null;
|
||||
if (storedUpdatedAt) {
|
||||
const liveUpdatedAt = Date.parse(payload.updatedAt);
|
||||
const persistedUpdatedAt = Date.parse(storedUpdatedAt);
|
||||
if (Number.isNaN(liveUpdatedAt) || persistedUpdatedAt > liveUpdatedAt) {
|
||||
payload.updatedAt = storedUpdatedAt;
|
||||
}
|
||||
}
|
||||
private async enrichAgentPayload(payload: AgentSnapshotPayload): Promise<AgentSnapshotPayload> {
|
||||
const storedRecord = await this.agentStorage.get(payload.id);
|
||||
payload.title = storedRecord?.title ?? null;
|
||||
payload.archivedAt = storedRecord?.archivedAt ?? null;
|
||||
return payload;
|
||||
}
|
||||
|
||||
private buildAgentPayload(agent: ManagedAgent): Promise<AgentSnapshotPayload> {
|
||||
return this.enrichAgentPayload(toAgentPayload(agent));
|
||||
}
|
||||
|
||||
private buildStoredAgentPayload(
|
||||
record: StoredAgentRecord,
|
||||
registeredProviderIds = new Set(this.providerSnapshotManager.listRegisteredProviderIds()),
|
||||
@@ -1880,7 +1871,7 @@ export class Session {
|
||||
): Promise<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "fetch_agent_timeline_request":
|
||||
return this.handleFetchAgentTimelineRequest(msg);
|
||||
return this.handleFetchAgentTimelineRequest(msg, source);
|
||||
case "agent.provider_subagents.list.request":
|
||||
return this.handleProviderSubagentListRequest(msg);
|
||||
case "agent.provider_subagents.timeline.get.request":
|
||||
@@ -2371,7 +2362,7 @@ export class Session {
|
||||
},
|
||||
});
|
||||
|
||||
this.agentUpdates.removeAgent(agentId);
|
||||
await this.agentUpdates.removeAgent(agentId);
|
||||
|
||||
if (knownWorkspaceId) {
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId);
|
||||
@@ -6094,27 +6085,30 @@ export class Session {
|
||||
direction: AgentTimelineFetchDirection;
|
||||
cursor?: AgentTimelineCursor;
|
||||
pageLimit: number;
|
||||
fullTimeline?: AgentTimelineFetchResult;
|
||||
}): AgentTimelineProjectionSelection {
|
||||
const timeline = this.shouldUseFullTimelineForProjectedPage({
|
||||
const selectedTimeline = this.shouldUseFullTimelineForProjectedPage({
|
||||
timeline: input.controlTimeline,
|
||||
pageLimit: input.pageLimit,
|
||||
})
|
||||
? this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 })
|
||||
? (input.fullTimeline ??
|
||||
this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 }))
|
||||
: input.controlTimeline;
|
||||
const page = selectProjectedTimelinePage({
|
||||
rows: timeline.rows,
|
||||
bounds: timeline.window,
|
||||
rows: selectedTimeline.rows,
|
||||
bounds: selectedTimeline.window,
|
||||
direction: input.controlTimeline.reset ? "tail" : input.direction,
|
||||
...(input.cursor ? { cursorSeq: input.cursor.seq } : {}),
|
||||
limit: input.pageLimit,
|
||||
});
|
||||
|
||||
return {
|
||||
timeline,
|
||||
timeline: selectedTimeline,
|
||||
entries: page.entries,
|
||||
startSeq: page.startSeq,
|
||||
endSeq: page.endSeq,
|
||||
hasOlder: page.hasOlder || (page.startSeq !== null && page.startSeq > timeline.window.minSeq),
|
||||
hasOlder:
|
||||
page.hasOlder || (page.startSeq !== null && page.startSeq > selectedTimeline.window.minSeq),
|
||||
hasNewer: page.hasNewer,
|
||||
};
|
||||
}
|
||||
@@ -6126,6 +6120,7 @@ export class Session {
|
||||
direction: AgentTimelineFetchDirection;
|
||||
cursor?: AgentTimelineCursor;
|
||||
pageLimit: number;
|
||||
fullTimeline?: AgentTimelineFetchResult;
|
||||
}): AgentTimelineProjectionSelection {
|
||||
if (input.projection === "canonical") {
|
||||
return this.selectCanonicalTimelineProjection({ timeline: input.controlTimeline });
|
||||
@@ -6136,6 +6131,7 @@ export class Session {
|
||||
|
||||
private async handleFetchAgentTimelineRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>,
|
||||
source?: object,
|
||||
): Promise<void> {
|
||||
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
|
||||
const projection: TimelineProjectionMode = msg.projection ?? "projected";
|
||||
@@ -6156,7 +6152,7 @@ export class Session {
|
||||
});
|
||||
const agentPayload = await this.buildAgentPayload(snapshot);
|
||||
|
||||
const controlTimeline = this.agentManager.fetchTimeline(msg.agentId, {
|
||||
const fetchedControlTimeline = this.agentManager.fetchTimeline(msg.agentId, {
|
||||
direction,
|
||||
cursor,
|
||||
limit: pageLimit,
|
||||
@@ -6164,7 +6160,7 @@ export class Session {
|
||||
const selectedTimeline = this.selectTimelineProjection({
|
||||
agentId: msg.agentId,
|
||||
projection,
|
||||
controlTimeline,
|
||||
controlTimeline: fetchedControlTimeline,
|
||||
direction,
|
||||
...(cursor ? { cursor } : {}),
|
||||
pageLimit,
|
||||
@@ -6178,63 +6174,73 @@ export class Session {
|
||||
? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.endSeq }
|
||||
: null;
|
||||
|
||||
this.emit({
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId: msg.agentId,
|
||||
agent: agentPayload,
|
||||
direction,
|
||||
projection,
|
||||
epoch: selectedTimeline.timeline.epoch,
|
||||
reset: controlTimeline.reset,
|
||||
staleCursor: controlTimeline.staleCursor,
|
||||
gap: controlTimeline.gap,
|
||||
window: selectedTimeline.timeline.window,
|
||||
startCursor,
|
||||
endCursor,
|
||||
hasOlder: selectedTimeline.hasOlder,
|
||||
hasNewer: selectedTimeline.hasNewer,
|
||||
entries: selectedTimeline.entries.map((entry) => ({
|
||||
provider: snapshot.provider,
|
||||
item: entry.item,
|
||||
timestamp: entry.timestamp,
|
||||
seqStart: entry.seqStart,
|
||||
seqEnd: entry.seqEnd,
|
||||
sourceSeqRanges: entry.sourceSeqRanges,
|
||||
collapsed: this.supports(CLIENT_CAPS.reasoningMergeEnum)
|
||||
? entry.collapsed
|
||||
: entry.collapsed.filter((value) => value !== "reasoning_merge"),
|
||||
})),
|
||||
error: null,
|
||||
this.emitForSource(
|
||||
{
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId: msg.agentId,
|
||||
agent: agentPayload,
|
||||
direction,
|
||||
projection,
|
||||
epoch: selectedTimeline.timeline.epoch,
|
||||
reset: fetchedControlTimeline.reset,
|
||||
staleCursor: fetchedControlTimeline.staleCursor,
|
||||
gap: fetchedControlTimeline.gap,
|
||||
window: selectedTimeline.timeline.window,
|
||||
startCursor,
|
||||
endCursor,
|
||||
hasOlder: selectedTimeline.hasOlder,
|
||||
hasNewer: selectedTimeline.hasNewer,
|
||||
entries: selectedTimeline.entries.map((entry) => ({
|
||||
provider: snapshot.provider,
|
||||
item: entry.item,
|
||||
timestamp: entry.timestamp,
|
||||
seqStart: entry.seqStart,
|
||||
seqEnd: entry.seqEnd,
|
||||
sourceSeqRanges: entry.sourceSeqRanges,
|
||||
collapsed: (
|
||||
source
|
||||
? this.supportsForSource(CLIENT_CAPS.reasoningMergeEnum, source)
|
||||
: this.supports(CLIENT_CAPS.reasoningMergeEnum)
|
||||
)
|
||||
? entry.collapsed
|
||||
: entry.collapsed.filter((value) => value !== "reasoning_merge"),
|
||||
})),
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
source,
|
||||
);
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId: msg.agentId },
|
||||
"Failed to handle fetch_agent_timeline_request",
|
||||
);
|
||||
this.emit({
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId: msg.agentId,
|
||||
agent: null,
|
||||
direction,
|
||||
projection,
|
||||
epoch: "",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 0, maxSeq: 0, nextSeq: 0 },
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
this.emitForSource(
|
||||
{
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId: msg.agentId,
|
||||
agent: null,
|
||||
direction,
|
||||
projection,
|
||||
epoch: "",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 0, maxSeq: 0, nextSeq: 0 },
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
},
|
||||
});
|
||||
source,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import type { AgentUpdatesService } from "./session/agent-updates/agent-updates-
|
||||
import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { createTerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { AgentManager, type AgentManagerEvent, type ManagedAgent } from "./agent/agent-manager.js";
|
||||
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
@@ -80,6 +80,14 @@ const UNREGISTERED_CWD = path.resolve("/tmp/unregistered");
|
||||
|
||||
const terminalManagers: TerminalManager[] = [];
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function flushWorkspaceUpdateBackgroundWork(): Promise<void> {
|
||||
await waitForImmediate();
|
||||
}
|
||||
@@ -303,6 +311,7 @@ function makeManagedAgent(input: {
|
||||
workspaceId?: string;
|
||||
lifecycle: AgentSnapshotPayload["status"];
|
||||
updatedAt: string;
|
||||
activeTurn?: { turnId: string; startedAt: string };
|
||||
}) {
|
||||
const now = new Date(input.updatedAt);
|
||||
const snapshot = makeAgent({
|
||||
@@ -338,6 +347,8 @@ function makeManagedAgent(input: {
|
||||
unsubscribeSession: null,
|
||||
session: null,
|
||||
activeForegroundTurnId: input.lifecycle === "running" ? "turn-1" : null,
|
||||
activeTurnId: input.activeTurn?.turnId ?? null,
|
||||
activeTurnStartedAt: input.activeTurn ? new Date(input.activeTurn.startedAt) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -537,6 +548,8 @@ function createSessionForWorkspaceTests(
|
||||
onWorkspaceRecovered?: SessionOptions["onWorkspaceRecovered"];
|
||||
workspaceGitService?: ReturnType<typeof createNoopWorkspaceGitService>;
|
||||
terminalManager?: TerminalManager | null;
|
||||
agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown };
|
||||
agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown };
|
||||
projectRegistry?: SessionOptions["projectRegistry"];
|
||||
workspaceRegistry?: SessionOptions["workspaceRegistry"];
|
||||
github?: ForgeService;
|
||||
@@ -566,6 +579,7 @@ function createSessionForWorkspaceTests(
|
||||
unarchiveSnapshot: async () => true,
|
||||
clearAgentAttention: async () => {},
|
||||
notifyAgentState: () => {},
|
||||
...options.agentManager,
|
||||
});
|
||||
const workspaceRegistry: SessionOptions["workspaceRegistry"] = options.workspaceRegistry ?? {
|
||||
initialize: async () => {},
|
||||
@@ -654,6 +668,7 @@ function createSessionForWorkspaceTests(
|
||||
})
|
||||
: null,
|
||||
upsert: async () => {},
|
||||
...options.agentStorage,
|
||||
}),
|
||||
projectRegistry: options.projectRegistry ?? {
|
||||
initialize: async () => {},
|
||||
@@ -723,6 +738,113 @@ function createSessionForWorkspaceTests(
|
||||
return session;
|
||||
}
|
||||
|
||||
test("agent updates preserve queued live transitions across stored metadata reads", async () => {
|
||||
const running = makeManagedAgent({
|
||||
id: "agent-coherent",
|
||||
cwd: REPO_CWD,
|
||||
workspaceId: "ws-repo-running",
|
||||
lifecycle: "running",
|
||||
updatedAt: "2026-07-31T10:00:01.000Z",
|
||||
activeTurn: {
|
||||
turnId: "turn-running",
|
||||
startedAt: "2026-07-31T10:00:00.500Z",
|
||||
},
|
||||
}) as unknown as ManagedAgent;
|
||||
const idle = makeManagedAgent({
|
||||
id: "agent-coherent",
|
||||
cwd: REPO_CWD,
|
||||
workspaceId: "ws-repo-running",
|
||||
lifecycle: "idle",
|
||||
updatedAt: "2026-07-31T10:00:02.000Z",
|
||||
}) as unknown as ManagedAgent;
|
||||
running.config.model = "running-model";
|
||||
idle.config = running.config;
|
||||
const stored = makeStoredAgent({
|
||||
id: "agent-coherent",
|
||||
cwd: REPO_CWD,
|
||||
updatedAt: "2026-07-31T10:00:03.000Z",
|
||||
});
|
||||
stored.title = "Stored title";
|
||||
const storageReadStarted = deferred<void>();
|
||||
const firstStorageRead = deferred<StoredAgentRecord | null>();
|
||||
const emittedAgentUpdates: Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent_update" }
|
||||
>["payload"][] = [];
|
||||
const twoUpdatesEmitted = deferred<void>();
|
||||
let storageReadCount = 0;
|
||||
let forwardAgentEvent: ((event: AgentManagerEvent) => void) | null = null;
|
||||
const session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => {
|
||||
if (message.type !== "agent_update") return;
|
||||
emittedAgentUpdates.push(message.payload);
|
||||
if (emittedAgentUpdates.length === 2) twoUpdatesEmitted.resolve();
|
||||
},
|
||||
agentManager: {
|
||||
subscribe: (listener: (event: AgentManagerEvent) => void) => {
|
||||
forwardAgentEvent = listener;
|
||||
return () => {};
|
||||
},
|
||||
getAgent: () => idle,
|
||||
},
|
||||
agentStorage: {
|
||||
get: async () => {
|
||||
storageReadCount += 1;
|
||||
if (storageReadCount === 1) {
|
||||
storageReadStarted.resolve();
|
||||
return firstStorageRead.promise;
|
||||
}
|
||||
return stored;
|
||||
},
|
||||
},
|
||||
});
|
||||
session.projectRegistry.get = async () =>
|
||||
createPersistedProjectRecord({
|
||||
projectId: "proj-repo-running",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-07-31T10:00:00.000Z",
|
||||
updatedAt: "2026-07-31T10:00:00.000Z",
|
||||
});
|
||||
activateAgentUpdatesSubscription(session, "sub-coherent");
|
||||
if (!forwardAgentEvent) throw new Error("Agent event listener was not installed");
|
||||
|
||||
forwardAgentEvent({ type: "agent_state", agent: running });
|
||||
await storageReadStarted.promise;
|
||||
idle.config.model = "idle-model";
|
||||
forwardAgentEvent({ type: "agent_state", agent: idle });
|
||||
idle.config.model = "mutated-after-queue";
|
||||
firstStorageRead.resolve(stored);
|
||||
await twoUpdatesEmitted.promise;
|
||||
|
||||
expect(emittedAgentUpdates).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "upsert",
|
||||
agent: expect.objectContaining({
|
||||
status: "running",
|
||||
activeTurn: {
|
||||
turnId: "turn-running",
|
||||
startedAt: "2026-07-31T10:00:00.500Z",
|
||||
},
|
||||
updatedAt: "2026-07-31T10:00:01.000Z",
|
||||
model: "running-model",
|
||||
title: "Stored title",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "upsert",
|
||||
agent: expect.objectContaining({
|
||||
status: "idle",
|
||||
activeTurn: null,
|
||||
updatedAt: "2026-07-31T10:00:02.000Z",
|
||||
model: "idle-model",
|
||||
title: "Stored title",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("client heartbeat clears attention for the focused terminal", async () => {
|
||||
const clearedTerminalIds: string[] = [];
|
||||
const session = createSessionForWorkspaceTests({
|
||||
|
||||
@@ -16,6 +16,14 @@ import type { StoredAgentRecord } from "../../agent/agent-storage.js";
|
||||
|
||||
type AgentUpdatePayload = Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function makeAgentPayload(input: {
|
||||
id: string;
|
||||
workspaceId?: string;
|
||||
@@ -87,21 +95,26 @@ function buildHarness() {
|
||||
const workspaceUpdates: string[] = [];
|
||||
const loggedErrors: unknown[][] = [];
|
||||
const payloadById = new Map<string, AgentSnapshotPayload>();
|
||||
const queuedPayloadBuilds: Promise<AgentSnapshotPayload>[] = [];
|
||||
const projectByWorkspaceId = new Map<string, ProjectPlacementPayload | null>();
|
||||
let providerVisible: (provider: string) => boolean = () => true;
|
||||
let buildAgentPayloadError: Error | null = null;
|
||||
|
||||
const service = createAgentUpdatesService({
|
||||
emit: (message) => emitted.push(message),
|
||||
buildAgentPayload: async (agent) => {
|
||||
enrichAgentPayload: async (payload) => {
|
||||
const queuedPayload = queuedPayloadBuilds.shift();
|
||||
if (queuedPayload) {
|
||||
return queuedPayload;
|
||||
}
|
||||
if (buildAgentPayloadError) {
|
||||
throw buildAgentPayloadError;
|
||||
}
|
||||
const payload = payloadById.get(agent.id);
|
||||
if (!payload) {
|
||||
throw new Error(`no payload registered for ${agent.id}`);
|
||||
const registeredPayload = payloadById.get(payload.id);
|
||||
if (!registeredPayload) {
|
||||
throw new Error(`no payload registered for ${payload.id}`);
|
||||
}
|
||||
return payload;
|
||||
return registeredPayload;
|
||||
},
|
||||
buildStoredAgentPayload: (record) => {
|
||||
const payload = payloadById.get(record.id);
|
||||
@@ -142,6 +155,9 @@ function buildHarness() {
|
||||
failBuildAgentPayload(error: Error) {
|
||||
buildAgentPayloadError = error;
|
||||
},
|
||||
queuePayloadBuilds(...payloads: Promise<AgentSnapshotPayload>[]) {
|
||||
queuedPayloadBuilds.push(...payloads);
|
||||
},
|
||||
agentUpdates(): AgentUpdatePayload[] {
|
||||
return emitted
|
||||
.filter((message) => message.type === "agent_update")
|
||||
@@ -151,7 +167,26 @@ function buildHarness() {
|
||||
);
|
||||
},
|
||||
managed(id: string): ManagedAgent {
|
||||
return { id } as unknown as ManagedAgent;
|
||||
const payload = payloadById.get(id) ?? makeAgentPayload({ id });
|
||||
return {
|
||||
...payload,
|
||||
lifecycle: payload.status,
|
||||
config: {
|
||||
provider: payload.provider,
|
||||
cwd: payload.cwd,
|
||||
},
|
||||
createdAt: new Date(payload.createdAt),
|
||||
updatedAt: new Date(payload.updatedAt),
|
||||
lastUserMessageAt: null,
|
||||
pendingPermissions: new Map(),
|
||||
attention: {
|
||||
requiresAttention: payload.requiresAttention ?? false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: new Date(payload.updatedAt),
|
||||
},
|
||||
activeTurnId: null,
|
||||
activeTurnStartedAt: null,
|
||||
} as unknown as ManagedAgent;
|
||||
},
|
||||
stored(id: string): StoredAgentRecord {
|
||||
return { id } as unknown as StoredAgentRecord;
|
||||
@@ -238,6 +273,48 @@ describe("matchesAgentUpdatesFilter", () => {
|
||||
});
|
||||
|
||||
describe("forwardLiveAgent", () => {
|
||||
test("emits snapshots for one agent in forward call order", async () => {
|
||||
const h = buildHarness();
|
||||
h.service.beginSubscription({ subscriptionId: "sub", filter: {} });
|
||||
h.service.flushBootstrapped("sub");
|
||||
const idle = makeAgentPayload({ id: "a", workspaceId: "ws-1", status: "idle" });
|
||||
const running = makeAgentPayload({ id: "a", workspaceId: "ws-1", status: "running" });
|
||||
h.register(running);
|
||||
const delayedIdleBuild = deferred<AgentSnapshotPayload>();
|
||||
h.queuePayloadBuilds(delayedIdleBuild.promise, Promise.resolve(running));
|
||||
|
||||
const idleForward = h.service.forwardLiveAgent(h.managed("a"));
|
||||
const runningForward = h.service.forwardLiveAgent(h.managed("a"));
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
delayedIdleBuild.resolve(idle);
|
||||
await Promise.all([idleForward, runningForward]);
|
||||
|
||||
expect(
|
||||
h.agentUpdates().map((update) => update.kind === "upsert" && update.agent.status),
|
||||
).toEqual(["idle", "running"]);
|
||||
});
|
||||
|
||||
test("emits removal after an earlier queued upsert for the same agent", async () => {
|
||||
const h = buildHarness();
|
||||
h.service.beginSubscription({ subscriptionId: "sub", filter: {} });
|
||||
h.service.flushBootstrapped("sub");
|
||||
const running = makeAgentPayload({ id: "a", workspaceId: "ws-1", status: "running" });
|
||||
h.register(running);
|
||||
const delayedBuild = deferred<AgentSnapshotPayload>();
|
||||
h.queuePayloadBuilds(delayedBuild.promise);
|
||||
|
||||
const runningForward = h.service.forwardLiveAgent(h.managed("a"));
|
||||
const removal = h.service.removeAgent("a");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
delayedBuild.resolve(running);
|
||||
await Promise.all([runningForward, removal]);
|
||||
|
||||
expect(h.agentUpdates()).toEqual([
|
||||
{ kind: "upsert", agent: expect.objectContaining({ id: "a" }), project: makeProject() },
|
||||
{ kind: "remove", agentId: "a" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits an upsert for a matching agent and updates its workspace", async () => {
|
||||
const h = buildHarness();
|
||||
h.service.beginSubscription({ subscriptionId: "sub", filter: {} });
|
||||
@@ -394,7 +471,7 @@ describe("bootstrap buffering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("skips a buffered upsert that is not newer than the snapshot", async () => {
|
||||
test("skips a buffered upsert that is older than the snapshot", async () => {
|
||||
const h = buildHarness();
|
||||
h.service.beginSubscription({ subscriptionId: "sub", filter: {} });
|
||||
|
||||
@@ -419,11 +496,39 @@ describe("bootstrap buffering", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("replays a buffered live update with the same revision as the snapshot", async () => {
|
||||
const h = buildHarness();
|
||||
h.service.beginSubscription({ subscriptionId: "sub", filter: {} });
|
||||
h.register(
|
||||
makeAgentPayload({
|
||||
id: "equal-revision",
|
||||
workspaceId: "ws-1",
|
||||
status: "running",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
await h.service.forwardLiveAgent(h.managed("equal-revision"));
|
||||
h.service.flushBootstrapped("sub", {
|
||||
snapshotUpdatedAtByAgentId: new Map([
|
||||
["equal-revision", Date.parse("2026-03-02T00:00:00.000Z")],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(h.agentUpdates()).toEqual([
|
||||
{
|
||||
kind: "upsert",
|
||||
agent: expect.objectContaining({ id: "equal-revision", status: "running" }),
|
||||
project: makeProject(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("a removed agent is always replayed, even against a snapshot", async () => {
|
||||
const h = buildHarness();
|
||||
h.service.beginSubscription({ subscriptionId: "sub", filter: {} });
|
||||
|
||||
h.service.removeAgent("a");
|
||||
await h.service.removeAgent("a");
|
||||
expect(h.agentUpdates()).toEqual([]); // buffered
|
||||
|
||||
h.service.flushBootstrapped("sub", {
|
||||
@@ -479,18 +584,18 @@ describe("subscription lifecycle", () => {
|
||||
expect(h.service.hasSubscription()).toBe(false);
|
||||
});
|
||||
|
||||
test("removeAgent is a no-op without a subscription", () => {
|
||||
test("removeAgent is a no-op without a subscription", async () => {
|
||||
const h = buildHarness();
|
||||
h.service.removeAgent("a");
|
||||
await h.service.removeAgent("a");
|
||||
expect(h.agentUpdates()).toEqual([]);
|
||||
});
|
||||
|
||||
test("removeAgent emits a remove for a live subscription", () => {
|
||||
test("removeAgent emits a remove for a live subscription", async () => {
|
||||
const h = buildHarness();
|
||||
h.service.beginSubscription({ subscriptionId: "sub", filter: {} });
|
||||
h.service.flushBootstrapped("sub");
|
||||
|
||||
h.service.removeAgent("a");
|
||||
await h.service.removeAgent("a");
|
||||
|
||||
expect(h.agentUpdates()).toEqual([{ kind: "remove", agentId: "a" }]);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
} from "../../messages.js";
|
||||
import type { ManagedAgent } from "../../agent/agent-manager.js";
|
||||
import type { StoredAgentRecord } from "../../agent/agent-storage.js";
|
||||
import { resolveEffectiveThinkingOptionId } from "../../agent/agent-projections.js";
|
||||
import { resolveEffectiveThinkingOptionId, toAgentPayload } from "../../agent/agent-projections.js";
|
||||
|
||||
type AgentUpdatePayload = Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
|
||||
type AgentUpdatesFilter = NonNullable<
|
||||
@@ -46,13 +46,13 @@ export interface AgentUpdatesService {
|
||||
hasSubscription(): boolean;
|
||||
forwardLiveAgent(agent: ManagedAgent): Promise<void>;
|
||||
emitStoredRecord(record: StoredAgentRecord): Promise<AgentSnapshotPayload>;
|
||||
removeAgent(agentId: string): void;
|
||||
removeAgent(agentId: string): Promise<void>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface AgentUpdatesServiceDeps {
|
||||
emit(message: SessionOutboundMessage): void;
|
||||
buildAgentPayload(agent: ManagedAgent): Promise<AgentSnapshotPayload>;
|
||||
enrichAgentPayload(payload: AgentSnapshotPayload): Promise<AgentSnapshotPayload>;
|
||||
buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload;
|
||||
isProviderVisibleToClient(provider: string): boolean;
|
||||
buildProjectPlacementForWorkspaceId(workspaceId: string): Promise<ProjectPlacementPayload | null>;
|
||||
@@ -150,6 +150,7 @@ function agentUpdateTargetId(update: AgentUpdatePayload): string {
|
||||
|
||||
export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentUpdatesService {
|
||||
let subscription: AgentUpdatesSubscriptionState | null = null;
|
||||
const liveAgentUpdateTails = new Map<string, Promise<void>>();
|
||||
|
||||
function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void {
|
||||
if (payload.kind === "upsert" && !deps.isProviderVisibleToClient(payload.agent.provider)) {
|
||||
@@ -195,7 +196,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
const snapshotUpdatedAt = options?.snapshotUpdatedAtByAgentId?.get(payload.agent.id);
|
||||
if (typeof snapshotUpdatedAt === "number") {
|
||||
const updateUpdatedAt = Date.parse(payload.agent.updatedAt);
|
||||
if (!Number.isNaN(updateUpdatedAt) && updateUpdatedAt <= snapshotUpdatedAt) {
|
||||
if (!Number.isNaN(updateUpdatedAt) && updateUpdatedAt < snapshotUpdatedAt) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -218,13 +219,6 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
return subscription !== null;
|
||||
}
|
||||
|
||||
function removeAgent(agentId: string): void {
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
bufferOrEmit(subscription, { kind: "remove", agentId });
|
||||
}
|
||||
|
||||
async function emitStoredRecord(record: StoredAgentRecord): Promise<AgentSnapshotPayload> {
|
||||
const payload = deps.buildStoredAgentPayload(record);
|
||||
const sub = subscription;
|
||||
@@ -264,10 +258,10 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function forwardLiveAgent(agent: ManagedAgent): Promise<void> {
|
||||
async function emitLiveAgentUpdate(payload: AgentSnapshotPayload): Promise<void> {
|
||||
try {
|
||||
const sub = subscription;
|
||||
const payload = await deps.buildAgentPayload(agent);
|
||||
payload = await deps.enrichAgentPayload(payload);
|
||||
if (sub) {
|
||||
const project = payload.workspaceId
|
||||
? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId)
|
||||
@@ -309,6 +303,34 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueAgentUpdate(
|
||||
agentId: string,
|
||||
emitUpdate: () => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const previous = liveAgentUpdateTails.get(agentId) ?? Promise.resolve();
|
||||
const next = previous.catch(() => undefined).then(emitUpdate);
|
||||
liveAgentUpdateTails.set(agentId, next);
|
||||
void next.finally(() => {
|
||||
if (liveAgentUpdateTails.get(agentId) === next) {
|
||||
liveAgentUpdateTails.delete(agentId);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function forwardLiveAgent(agent: ManagedAgent): Promise<void> {
|
||||
const payload = toAgentPayload(agent);
|
||||
return enqueueAgentUpdate(payload.id, () => emitLiveAgentUpdate(payload));
|
||||
}
|
||||
|
||||
function removeAgent(agentId: string): Promise<void> {
|
||||
return enqueueAgentUpdate(agentId, () => {
|
||||
if (subscription) {
|
||||
bufferOrEmit(subscription, { kind: "remove", agentId });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
subscription = null;
|
||||
}
|
||||
|
||||
@@ -921,7 +921,7 @@ describe("relay external socket reconnect behavior", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("advertises stable project identity in initial server_info", async () => {
|
||||
test("advertises current features in initial server_info", async () => {
|
||||
const server = createServer();
|
||||
const socket = new MockSocket();
|
||||
|
||||
@@ -932,6 +932,8 @@ describe("relay external socket reconnect behavior", () => {
|
||||
});
|
||||
|
||||
expect(serverInfo.features?.stableProjectIdentity).toBe(true);
|
||||
expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true);
|
||||
expect(serverInfo.features?.agentTurnIdentity).toBeUndefined();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -1587,6 +1587,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
forgeProviders: true,
|
||||
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
|
||||
selectiveAgentTimeline: true,
|
||||
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30.
|
||||
canonicalSubmittedPrompts: true,
|
||||
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||
stableProjectIdentity: true,
|
||||
// COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10.
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
import { Session, type SessionOptions } from "./session.js";
|
||||
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
|
||||
import type { AgentTimelineRow } from "./agent/agent-manager.js";
|
||||
import { InMemoryAgentTimelineStore } from "./agent/agent-timeline-store.js";
|
||||
import type { AgentTimelineFetchOptions } from "./agent/agent-timeline-store-types.js";
|
||||
import { handleCreatePaseoWorktreeRequest } from "./worktree-session.js";
|
||||
import { createPersistedProjectRecord } from "./workspace-registry.js";
|
||||
|
||||
@@ -87,7 +89,15 @@ interface SessionInternals {
|
||||
}
|
||||
|
||||
class InMemoryAgentManager {
|
||||
constructor(private readonly rows: AgentTimelineRow[]) {}
|
||||
private readonly timeline = new InMemoryAgentTimelineStore();
|
||||
|
||||
constructor(rows: AgentTimelineRow[]) {
|
||||
this.timeline.initialize("agent-1", {
|
||||
epoch: "epoch-1",
|
||||
rows,
|
||||
nextSeq: (rows.at(-1)?.seq ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
|
||||
getAgent() {
|
||||
return {
|
||||
@@ -133,17 +143,8 @@ class InMemoryAgentManager {
|
||||
};
|
||||
}
|
||||
|
||||
fetchTimeline() {
|
||||
return {
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 3, nextSeq: 4 },
|
||||
rows: this.rows,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
fetchTimeline(_agentId: string, options?: AgentTimelineFetchOptions) {
|
||||
return this.timeline.fetch("agent-1", options);
|
||||
}
|
||||
|
||||
listAgents() {
|
||||
@@ -218,6 +219,7 @@ class InMemoryWorktreeWorkflow {
|
||||
function createSessionForWireCompatTest(options?: {
|
||||
clientCapabilities?: Record<string, unknown> | null;
|
||||
messages?: SessionOutboundMessage[];
|
||||
rows?: AgentTimelineRow[];
|
||||
}): Session {
|
||||
const messages = options?.messages ?? [];
|
||||
const rows: AgentTimelineRow[] = [
|
||||
@@ -247,7 +249,9 @@ function createSessionForWireCompatTest(options?: {
|
||||
downloadTokenStore: {} as SessionOptions["downloadTokenStore"],
|
||||
pushTokenStore: {} as SessionOptions["pushTokenStore"],
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
agentManager: new InMemoryAgentManager(rows) as unknown as SessionOptions["agentManager"],
|
||||
agentManager: new InMemoryAgentManager(
|
||||
options?.rows ?? rows,
|
||||
) as unknown as SessionOptions["agentManager"],
|
||||
agentStorage: new EmptyAgentStorage() as unknown as SessionOptions["agentStorage"],
|
||||
projectRegistry: new EmptyProjectRegistry() as unknown as SessionOptions["projectRegistry"],
|
||||
workspaceRegistry:
|
||||
@@ -308,11 +312,19 @@ function createSessionForWireCompatTest(options?: {
|
||||
return session;
|
||||
}
|
||||
|
||||
async function emitTimelineResponse(
|
||||
clientCapabilities?: Record<string, unknown> | null,
|
||||
): Promise<Extract<SessionOutboundMessage, { type: "fetch_agent_timeline_response" }>> {
|
||||
async function emitTimelineResponse(options?: {
|
||||
clientCapabilities?: Record<string, unknown> | null;
|
||||
rows?: AgentTimelineRow[];
|
||||
request?: Partial<
|
||||
Extract<z.infer<typeof SessionInboundMessageSchema>, { type: "fetch_agent_timeline_request" }>
|
||||
>;
|
||||
}): Promise<Extract<SessionOutboundMessage, { type: "fetch_agent_timeline_response" }>> {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWireCompatTest({ clientCapabilities, messages });
|
||||
const session = createSessionForWireCompatTest({
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
rows: options?.rows,
|
||||
messages,
|
||||
});
|
||||
const internals = session as unknown as SessionInternals;
|
||||
|
||||
await internals.handleFetchAgentTimelineRequest({
|
||||
@@ -320,6 +332,7 @@ async function emitTimelineResponse(
|
||||
requestId: "req-timeline",
|
||||
agentId: "agent-1",
|
||||
projection: "projected",
|
||||
...options?.request,
|
||||
});
|
||||
|
||||
const response = messages[0];
|
||||
@@ -377,7 +390,7 @@ describe("wire compatibility", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("hello parses with and without the project update capability", () => {
|
||||
test("hello parses with and without current client capabilities", () => {
|
||||
const legacy = WSHelloMessageSchema.parse({
|
||||
type: "hello",
|
||||
clientId: "legacy-client",
|
||||
@@ -389,7 +402,9 @@ describe("wire compatibility", () => {
|
||||
clientId: "capable-client",
|
||||
clientType: "mobile",
|
||||
protocolVersion: 1,
|
||||
capabilities: { [CLIENT_CAPS.projectUpdates]: true },
|
||||
capabilities: {
|
||||
[CLIENT_CAPS.projectUpdates]: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect([legacy, capable]).toEqual([
|
||||
@@ -404,16 +419,21 @@ describe("wire compatibility", () => {
|
||||
clientId: "capable-client",
|
||||
clientType: "mobile",
|
||||
protocolVersion: 1,
|
||||
capabilities: { project_updates: true },
|
||||
capabilities: {
|
||||
project_updates: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("server info accepts legacy feature payloads without stable project identity", () => {
|
||||
test("server info strips unknown legacy features while accepting former turn identity", () => {
|
||||
const parsed = ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "legacy-server",
|
||||
features: { workspaceGithubClone: true },
|
||||
features: {
|
||||
workspaceGithubClone: true,
|
||||
agentTurnIdentity: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
@@ -421,7 +441,7 @@ describe("wire compatibility", () => {
|
||||
serverId: "legacy-server",
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: {},
|
||||
features: { agentTurnIdentity: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -460,7 +480,7 @@ describe("wire compatibility", () => {
|
||||
|
||||
test("preserves reasoning_merge for clients that declare the capability", async () => {
|
||||
const response = await emitTimelineResponse({
|
||||
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
||||
clientCapabilities: { [CLIENT_CAPS.reasoningMergeEnum]: true },
|
||||
});
|
||||
|
||||
const currentParsed = FetchAgentTimelineResponseMessageSchema.parse(response);
|
||||
|
||||
Reference in New Issue
Block a user