Harden terminal streaming and worktree setup flow
This commit is contained in:
@@ -40,7 +40,18 @@ describe("TerminalStreamManager", () => {
|
||||
|
||||
expect(seen).toEqual(["hello"]);
|
||||
expect(sendAck).toHaveBeenCalledTimes(1);
|
||||
expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 });
|
||||
});
|
||||
|
||||
test("acks buffered chunks even while no subscriber is attached", () => {
|
||||
const sendAck = vi.fn();
|
||||
const manager = new TerminalStreamManager({ sendAck });
|
||||
|
||||
manager.receiveChunk({
|
||||
chunk: createChunk({ streamId: 2, offset: 0, data: "abc" }),
|
||||
});
|
||||
|
||||
expect(sendAck).toHaveBeenCalledTimes(1);
|
||||
expect(sendAck).toHaveBeenCalledWith({ streamId: 2, offset: 3 });
|
||||
});
|
||||
|
||||
test("does not ack when every handler throws", () => {
|
||||
|
||||
@@ -79,8 +79,6 @@ export class TerminalStreamManager {
|
||||
const streamHandlers = this.handlers.get(chunk.streamId);
|
||||
if (!streamHandlers || streamHandlers.size === 0) {
|
||||
this.bufferChunk({ chunk });
|
||||
// Ack buffered chunks immediately to keep the daemon-side stream window
|
||||
// moving even before a UI subscriber is attached.
|
||||
this.maybeAckChunk({
|
||||
streamId: chunk.streamId,
|
||||
endOffset: chunk.endOffset,
|
||||
|
||||
@@ -753,6 +753,48 @@ describe("DaemonClient", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("handles terminal binary frames delivered as UTF-8 strings", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const seen: string[] = [];
|
||||
client.onTerminalStreamData(11, (chunk) => {
|
||||
seen.push(new TextDecoder().decode(chunk.data));
|
||||
});
|
||||
|
||||
const payload = new TextEncoder().encode("ls\r\n");
|
||||
const frame = encodeBinaryMuxFrame({
|
||||
channel: BinaryMuxChannel.Terminal,
|
||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
||||
streamId: 11,
|
||||
offset: 0,
|
||||
payload,
|
||||
});
|
||||
const frameAsString = new TextDecoder("utf-8", { fatal: true }).decode(frame);
|
||||
|
||||
mock.triggerMessage(frameAsString);
|
||||
|
||||
expect(seen).toEqual(["ls\r\n"]);
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
|
||||
expect(ackFrame?.channel).toBe(BinaryMuxChannel.Terminal);
|
||||
expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack);
|
||||
expect(ackFrame?.streamId).toBe(11);
|
||||
expect(ackFrame?.offset).toBe(4);
|
||||
});
|
||||
|
||||
test("acks buffered terminal chunks when handler is attached", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -374,7 +374,9 @@ export async function createAgentManagementMcpServer(
|
||||
});
|
||||
|
||||
try {
|
||||
agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
|
||||
agentManager.recordUserMessage(snapshot.id, trimmedPrompt, {
|
||||
emitState: false,
|
||||
});
|
||||
} catch (error) {
|
||||
childLogger.error(
|
||||
{ err: error, agentId: snapshot.id },
|
||||
@@ -599,7 +601,9 @@ export async function createAgentManagementMcpServer(
|
||||
}
|
||||
|
||||
try {
|
||||
agentManager.recordUserMessage(agentId, prompt);
|
||||
agentManager.recordUserMessage(agentId, prompt, {
|
||||
emitState: false,
|
||||
});
|
||||
} catch (error) {
|
||||
childLogger.error(
|
||||
{ err: error, agentId },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -588,6 +588,74 @@ describe("AgentManager", () => {
|
||||
expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex");
|
||||
});
|
||||
|
||||
test("keeps updatedAt monotonic when user message and run start happen in the same millisecond", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000120",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000);
|
||||
try {
|
||||
manager.recordUserMessage(snapshot.id, "hello");
|
||||
const afterMessage = manager.getAgent(snapshot.id);
|
||||
expect(afterMessage).toBeDefined();
|
||||
const messageUpdatedAt = afterMessage!.updatedAt.getTime();
|
||||
|
||||
const stream = manager.streamAgent(snapshot.id, "hello");
|
||||
const afterRunStart = manager.getAgent(snapshot.id);
|
||||
expect(afterRunStart).toBeDefined();
|
||||
expect(afterRunStart!.updatedAt.getTime()).toBeGreaterThan(messageUpdatedAt);
|
||||
|
||||
await stream.return(undefined);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000121",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const lifecycleUpdates: string[] = [];
|
||||
manager.subscribe((event) => {
|
||||
if (event.type !== "agent_state" || event.agent.id !== snapshot.id) {
|
||||
return;
|
||||
}
|
||||
lifecycleUpdates.push(event.agent.lifecycle);
|
||||
});
|
||||
lifecycleUpdates.length = 0;
|
||||
|
||||
manager.recordUserMessage(snapshot.id, "hello", { emitState: false });
|
||||
|
||||
expect(lifecycleUpdates).toEqual([]);
|
||||
});
|
||||
|
||||
test("runAgent assembles finalText from trailing assistant chunks", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -307,6 +307,15 @@ export class AgentManager {
|
||||
this.onAgentAttention = callback;
|
||||
}
|
||||
|
||||
private touchUpdatedAt(agent: ManagedAgent): Date {
|
||||
const nowMs = Date.now();
|
||||
const previousMs = agent.updatedAt.getTime();
|
||||
const nextMs = nowMs > previousMs ? nowMs : previousMs + 1;
|
||||
const next = new Date(nextMs);
|
||||
agent.updatedAt = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void {
|
||||
const targetAgentId =
|
||||
options?.agentId == null
|
||||
@@ -847,7 +856,7 @@ export class AgentManager {
|
||||
recordUserMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { messageId?: string }
|
||||
options?: { messageId?: string; emitState?: boolean }
|
||||
): void {
|
||||
const agent = this.requireAgent(agentId);
|
||||
const item: AgentTimelineItem = {
|
||||
@@ -855,8 +864,8 @@ export class AgentManager {
|
||||
text,
|
||||
messageId: options?.messageId,
|
||||
};
|
||||
agent.updatedAt = new Date();
|
||||
agent.lastUserMessageAt = agent.updatedAt;
|
||||
const updatedAt = this.touchUpdatedAt(agent);
|
||||
agent.lastUserMessageAt = updatedAt;
|
||||
const row = this.recordTimeline(agent, item);
|
||||
this.dispatchStream(agentId, {
|
||||
type: "timeline",
|
||||
@@ -866,12 +875,14 @@ export class AgentManager {
|
||||
seq: row.seq,
|
||||
epoch: this.ensureTimelineState(agent).epoch,
|
||||
});
|
||||
this.emitState(agent);
|
||||
if (options?.emitState !== false) {
|
||||
this.emitState(agent);
|
||||
}
|
||||
}
|
||||
|
||||
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
agent.updatedAt = new Date();
|
||||
this.touchUpdatedAt(agent);
|
||||
const row = this.recordTimeline(agent, item);
|
||||
this.dispatchStream(agentId, {
|
||||
type: "timeline",
|
||||
@@ -955,7 +966,7 @@ export class AgentManager {
|
||||
agent.lifecycle = "running";
|
||||
// Bump updatedAt when lifecycle changes so downstream consumers can
|
||||
// deterministically order idle->running transitions.
|
||||
agent.updatedAt = new Date();
|
||||
this.touchUpdatedAt(agent);
|
||||
self.emitState(agent);
|
||||
|
||||
return streamForwarder;
|
||||
@@ -1527,7 +1538,7 @@ export class AgentManager {
|
||||
): void {
|
||||
// Only update timestamp for live events, not history replay
|
||||
if (!options?.fromHistory) {
|
||||
agent.updatedAt = new Date();
|
||||
this.touchUpdatedAt(agent);
|
||||
}
|
||||
|
||||
let timelineRow: AgentTimelineRow | null = null;
|
||||
|
||||
@@ -579,7 +579,9 @@ export async function createAgentMcpServer(
|
||||
});
|
||||
|
||||
try {
|
||||
agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
|
||||
agentManager.recordUserMessage(snapshot.id, trimmedPrompt, {
|
||||
emitState: false,
|
||||
});
|
||||
} catch (error) {
|
||||
childLogger.error(
|
||||
{ err: error, agentId: snapshot.id },
|
||||
@@ -806,7 +808,9 @@ export async function createAgentMcpServer(
|
||||
}
|
||||
|
||||
try {
|
||||
agentManager.recordUserMessage(agentId, prompt);
|
||||
agentManager.recordUserMessage(agentId, prompt, {
|
||||
emitState: false,
|
||||
});
|
||||
} catch (error) {
|
||||
childLogger.error(
|
||||
{ err: error, agentId },
|
||||
|
||||
@@ -2311,7 +2311,10 @@ export class Session {
|
||||
const prompt = this.buildAgentPrompt(text, images);
|
||||
|
||||
try {
|
||||
this.agentManager.recordUserMessage(agentId, text, { messageId });
|
||||
this.agentManager.recordUserMessage(agentId, text, {
|
||||
messageId,
|
||||
emitState: false,
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId },
|
||||
@@ -5244,7 +5247,10 @@ export class Session {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
|
||||
try {
|
||||
this.agentManager.recordUserMessage(agentId, msg.text, { messageId: msg.messageId });
|
||||
this.agentManager.recordUserMessage(agentId, msg.text, {
|
||||
messageId: msg.messageId,
|
||||
emitState: false,
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId },
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BinaryMuxChannel,
|
||||
TerminalBinaryFlags,
|
||||
TerminalBinaryMessageType,
|
||||
asUint8Array,
|
||||
decodeBinaryMuxFrame,
|
||||
encodeBinaryMuxFrame,
|
||||
} from "./binary-mux.js";
|
||||
@@ -40,4 +41,10 @@ describe("binary mux frame codec", () => {
|
||||
const tampered = encoded.slice(0, encoded.byteLength - 1);
|
||||
expect(decodeBinaryMuxFrame(tampered)).toBeNull();
|
||||
});
|
||||
|
||||
it("converts UTF-8 string payloads to bytes", () => {
|
||||
const bytes = asUint8Array("hello");
|
||||
expect(bytes).not.toBeNull();
|
||||
expect(Array.from(bytes ?? [])).toEqual(Array.from(new TextEncoder().encode("hello")));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,19 @@ export interface BinaryMuxFrame {
|
||||
}
|
||||
|
||||
export function asUint8Array(data: unknown): Uint8Array | null {
|
||||
if (typeof data === "string") {
|
||||
if (typeof TextEncoder !== "undefined") {
|
||||
return new TextEncoder().encode(data);
|
||||
}
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return new Uint8Array(Buffer.from(data, "utf8"));
|
||||
}
|
||||
const out = new Uint8Array(data.length);
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
out[i] = data.charCodeAt(i) & 0xff;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (data instanceof Uint8Array) {
|
||||
return data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user