diff --git a/src/agents/run-wait.test.ts b/src/agents/run-wait.test.ts index 869627a8fec3..c7536bba17bd 100644 --- a/src/agents/run-wait.test.ts +++ b/src/agents/run-wait.test.ts @@ -460,464 +460,224 @@ describe("waitForAgentRunAndReadUpdatedAssistantReply", () => { callGatewayMock.mockReset(); }); - it("returns undefined when the latest assistant fingerprint matches the baseline", async () => { - const assistantMessage = { - role: "assistant", - content: [{ type: "text", text: "same reply" }], - timestamp: 42, - }; - callGatewayMock - .mockResolvedValueOnce({ - status: "ok", - }) - .mockResolvedValueOnce({ - messages: [assistantMessage], - }); + type TranscriptMessage = Record; + type WaitedReplyCase = { + name: string; + runId: string; + messages: TranscriptMessage[]; + expected: Record; + baseline?: { text?: string; fingerprint?: string }; + sessionKey?: string; + wait?: Record; + }; - const result = await waitForAgentRunAndReadUpdatedAssistantReply({ + const assistant = (text: string, metadata: TranscriptMessage = {}): TranscriptMessage => ({ + role: "assistant", + content: [{ type: "text", text }], + ...metadata, + }); + const interSession = (text: string, metadata: TranscriptMessage = {}): TranscriptMessage => + assistant(text, { + provenance: { + kind: "inter_session", + sourceSessionKey: "agent:main:source", + sourceTool: "sessions_send", + }, + ...metadata, + }); + const messageToolMirror = ( + text: string, + mirror: TranscriptMessage, + metadata: TranscriptMessage = {}, + ): TranscriptMessage => + assistant(text, { + openclawMessageToolMirror: { toolName: "message", ...mirror }, + ...metadata, + }); + + const sameReply = assistant("same reply", { timestamp: 42 }); + const previousReply = assistant("previous real reply", { timestamp: 41 }); + const forwardedRequest = interSession("forwarded request", { + __openclaw: { seq: 41 }, + timestamp: 41, + }); + const pendingSourceReply = messageToolMirror( + "source reply awaiting delivery", + { + toolCallId: "call-message-send", + sourceReplySink: "internal-ui", + sourceMessageSeq: 42, + }, + { timestamp: 42 }, + ); + const olderBaseline = { text: "older reply", fingerprint: "old-fingerprint" }; + + const cases: WaitedReplyCase[] = [ + { + name: "returns undefined when the latest assistant fingerprint matches the baseline", runId: "run-1", - sessionKey: "agent:main:child", - timeoutMs: 1_000, - baseline: { - text: "same reply", - fingerprint: JSON.stringify(assistantMessage), - }, - }); - - expect(result).toEqual({ - status: "ok", - replyText: undefined, - }); - }); - - it("returns undefined when a text-only baseline matches the latest assistant reply", async () => { - callGatewayMock - .mockResolvedValueOnce({ - status: "ok", - }) - .mockResolvedValueOnce({ - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "same reply" }], - timestamp: 42, - }, - ], - }); - - const result = await waitForAgentRunAndReadUpdatedAssistantReply({ + messages: [sameReply], + baseline: { text: "same reply", fingerprint: JSON.stringify(sameReply) }, + expected: { status: "ok", replyText: undefined }, + }, + { + name: "returns undefined when a text-only baseline matches the latest assistant reply", runId: "run-text-baseline", - sessionKey: "agent:main:child", - timeoutMs: 1_000, - baseline: { - text: "same reply", - }, - }); - - expect(result).toEqual({ - status: "ok", - replyText: undefined, - }); - }); - - it("does not treat a message-tool delivery mirror as a new waited reply", async () => { - const baselineMessage = { - role: "assistant", - content: [{ type: "text", text: "previous real reply" }], - timestamp: 41, - }; - callGatewayMock - .mockResolvedValueOnce({ - status: "ok", - }) - .mockResolvedValueOnce({ - messages: [ - baselineMessage, - { - role: "assistant", - provider: "openclaw", - model: "delivery-mirror", - content: [{ type: "text", text: "already delivered source reply" }], - timestamp: 42, - }, - ], - }); - - const result = await waitForAgentRunAndReadUpdatedAssistantReply({ + messages: [sameReply], + baseline: { text: "same reply" }, + expected: { status: "ok", replyText: undefined }, + }, + { + name: "does not treat a message-tool delivery mirror as a new waited reply", runId: "run-source-reply", - sessionKey: "agent:main:child", - timeoutMs: 1_000, - baseline: { - text: "previous real reply", - fingerprint: JSON.stringify(baselineMessage), - }, - }); - - expect(result).toEqual({ - status: "ok", - replyText: undefined, - }); - }); - - it("does not treat a projected message-tool mirror as a new waited reply", async () => { - const baselineMessage = { - role: "assistant", - content: [{ type: "text", text: "previous real reply" }], - timestamp: 41, - }; - callGatewayMock - .mockResolvedValueOnce({ - status: "ok", - }) - .mockResolvedValueOnce({ - messages: [ - baselineMessage, - { - role: "assistant", - content: [{ type: "text", text: "already delivered source reply" }], - openclawMessageToolMirror: { - toolName: "message", - toolCallId: "call-message-send", - }, - timestamp: 42, - }, - ], - }); - - const result = await waitForAgentRunAndReadUpdatedAssistantReply({ - runId: "run-projected-source-reply", - sessionKey: "agent:main:child", - timeoutMs: 1_000, - baseline: { - text: "previous real reply", - fingerprint: JSON.stringify(baselineMessage), - }, - }); - - expect(result).toEqual({ - status: "ok", - replyText: undefined, - }); - }); - - it("returns a projected message-tool reply held for outer A2A delivery", async () => { - callGatewayMock.mockResolvedValueOnce({ status: "ok" }).mockResolvedValueOnce({ messages: [ - { - role: "assistant", - provenance: { - kind: "inter_session", - sourceSessionKey: "agent:main:source", - sourceTool: "sessions_send", - }, - content: [{ type: "text", text: "forwarded request" }], - __openclaw: { seq: 41 }, - timestamp: 41, - }, - { - role: "assistant", - content: [{ type: "text", text: "source reply awaiting delivery" }], - openclawMessageToolMirror: { - toolName: "message", - toolCallId: "call-message-send", - sourceReplySink: "internal-ui", - sourceMessageSeq: 42, - }, + previousReply, + assistant("already delivered source reply", { + provider: "openclaw", + model: "delivery-mirror", timestamp: 42, - }, + }), ], - }); - - const result = await waitForAgentRunAndReadUpdatedAssistantReply({ + baseline: { text: "previous real reply", fingerprint: JSON.stringify(previousReply) }, + expected: { status: "ok", replyText: undefined }, + }, + { + name: "does not treat a projected message-tool mirror as a new waited reply", + runId: "run-projected-source-reply", + messages: [ + previousReply, + messageToolMirror( + "already delivered source reply", + { toolCallId: "call-message-send" }, + { timestamp: 42 }, + ), + ], + baseline: { text: "previous real reply", fingerprint: JSON.stringify(previousReply) }, + expected: { status: "ok", replyText: undefined }, + }, + { + name: "returns a projected message-tool reply held for outer A2A delivery", runId: "run-internal-source-reply", sessionKey: "agent:worker:main", - timeoutMs: 1_000, - }); - - expect(result).toEqual({ - status: "ok", - replyText: "source reply awaiting delivery", - }); - }); - - it("prefers an internal source reply over a later private final", async () => { - callGatewayMock.mockResolvedValueOnce({ status: "ok" }).mockResolvedValueOnce({ - messages: [ - { - role: "assistant", - provenance: { - kind: "inter_session", - sourceSessionKey: "agent:main:source", - sourceTool: "sessions_send", - }, - content: [{ type: "text", text: "forwarded request" }], - __openclaw: { seq: 41 }, - timestamp: 41, - }, - { - role: "assistant", - content: [{ type: "text", text: "source reply awaiting delivery" }], - openclawMessageToolMirror: { - toolName: "message", - toolCallId: "call-message-send", - sourceReplySink: "internal-ui", - sourceMessageSeq: 42, - }, - timestamp: 42, - }, - { - role: "assistant", - content: [{ type: "text", text: "Done" }], - timestamp: 43, - }, - ], - }); - - const result = await waitForAgentRunAndReadUpdatedAssistantReply({ + messages: [forwardedRequest, pendingSourceReply], + expected: { status: "ok", replyText: "source reply awaiting delivery" }, + }, + { + name: "prefers an internal source reply over a later private final", runId: "run-internal-source-reply-with-private-final", sessionKey: "agent:worker:main", - timeoutMs: 1_000, - }); - - expect(result).toEqual({ - status: "ok", - replyText: "source reply awaiting delivery", - }); - }); - - it("does not let a late internal result cross an inter-session turn boundary", async () => { - callGatewayMock.mockResolvedValueOnce({ status: "ok" }).mockResolvedValueOnce({ + messages: [forwardedRequest, pendingSourceReply, assistant("Done", { timestamp: 43 })], + expected: { status: "ok", replyText: "source reply awaiting delivery" }, + }, + { + name: "does not let a late internal result cross an inter-session turn boundary", + runId: "run-after-late-internal-source-reply", + sessionKey: "agent:worker:main", messages: [ - { - role: "assistant", - provenance: { - kind: "inter_session", - sourceSessionKey: "agent:main:source", - sourceTool: "sessions_send", - }, - content: [{ type: "text", text: "new forwarded request" }], - __openclaw: { seq: 42 }, - timestamp: 42, - }, - { - role: "assistant", - content: [{ type: "text", text: "stale source reply" }], - openclawMessageToolMirror: { - toolName: "message", + interSession("new forwarded request", { __openclaw: { seq: 42 }, timestamp: 42 }), + messageToolMirror( + "stale source reply", + { toolCallId: "call-message-before-request", sourceReplySink: "internal-ui", sourceMessageSeq: 41, }, - timestamp: 41, - }, - { - role: "assistant", - content: [{ type: "text", text: "fresh reply" }], - timestamp: 43, - }, + { timestamp: 41 }, + ), + assistant("fresh reply", { timestamp: 43 }), ], - }); - - const result = await waitForAgentRunAndReadUpdatedAssistantReply({ - runId: "run-after-late-internal-source-reply", - sessionKey: "agent:worker:main", - timeoutMs: 1_000, - }); - - expect(result).toEqual({ - status: "ok", - replyText: "fresh reply", - }); - }); - - it("does not return a private final written after a message-tool delivery mirror", async () => { - callGatewayMock.mockResolvedValueOnce({ status: "ok" }).mockResolvedValueOnce({ - messages: [ - { - role: "assistant", - provenance: { - kind: "inter_session", - sourceSessionKey: "agent:main:source", - sourceTool: "sessions_send", - }, - content: [{ type: "text", text: "forwarded request" }], - timestamp: 41, - }, - { - role: "assistant", - content: [{ type: "text", text: "already delivered source reply" }], - openclawMessageToolMirror: { - toolName: "message", - toolCallId: "call-message-send", - }, - timestamp: 42, - }, - { - role: "assistant", - content: [{ type: "text", text: "Done" }], - timestamp: 43, - }, - ], - }); - - const result = await waitForAgentRunAndReadUpdatedAssistantReply({ + expected: { status: "ok", replyText: "fresh reply" }, + }, + { + name: "does not return a private final written after a message-tool delivery mirror", runId: "run-source-reply-with-private-final", - sessionKey: "agent:main:child", - timeoutMs: 1_000, - }); - - expect(result).toEqual({ - status: "ok", - replyText: undefined, - }); - }); - - it("does not let an older turn's message-tool mirror suppress a fresh reply", async () => { - callGatewayMock.mockResolvedValueOnce({ status: "ok" }).mockResolvedValueOnce({ messages: [ - { - role: "assistant", - content: [{ type: "text", text: "older delivered reply" }], - openclawMessageToolMirror: { - toolName: "message", - toolCallId: "call-older-message-send", - }, - timestamp: 40, - }, - { - role: "assistant", - provenance: { - kind: "inter_session", - sourceSessionKey: "agent:main:source", - sourceTool: "sessions_send", - }, - content: [{ type: "text", text: "new forwarded request" }], - timestamp: 41, - }, - { - role: "assistant", - content: [{ type: "text", text: "fresh reply" }], - timestamp: 42, - }, + interSession("forwarded request", { timestamp: 41 }), + messageToolMirror( + "already delivered source reply", + { toolCallId: "call-message-send" }, + { timestamp: 42 }, + ), + assistant("Done", { timestamp: 43 }), ], - }); - - const result = await waitForAgentRunAndReadUpdatedAssistantReply({ + expected: { status: "ok", replyText: undefined }, + }, + { + name: "does not let an older turn's message-tool mirror suppress a fresh reply", runId: "run-after-older-source-reply", - sessionKey: "agent:main:child", - timeoutMs: 1_000, - }); - - expect(result).toEqual({ - status: "ok", - replyText: "fresh reply", - }); - }); - - it("does not resurrect an older reply when only a delivery mirror is newer", async () => { - callGatewayMock - .mockResolvedValueOnce({ - status: "ok", - }) - .mockResolvedValueOnce({ - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "stale previous reply" }], - timestamp: 41, - }, - { - role: "assistant", - provider: "openclaw", - model: "delivery-mirror", - content: [{ type: "text", text: "already delivered source reply" }], - timestamp: 42, - }, - ], - }); - - const result = await waitForAgentRunAndReadUpdatedAssistantReply({ + messages: [ + messageToolMirror( + "older delivered reply", + { toolCallId: "call-older-message-send" }, + { timestamp: 40 }, + ), + interSession("new forwarded request", { timestamp: 41 }), + assistant("fresh reply", { timestamp: 42 }), + ], + expected: { status: "ok", replyText: "fresh reply" }, + }, + { + name: "does not resurrect an older reply when only a delivery mirror is newer", runId: "run-source-reply-without-baseline", - sessionKey: "agent:main:child", - timeoutMs: 1_000, - }); - - expect(result).toEqual({ - status: "ok", - replyText: undefined, - }); - }); - - it("returns the new assistant text when the fingerprint changes", async () => { - callGatewayMock - .mockResolvedValueOnce({ - status: "ok", - }) - .mockResolvedValueOnce({ - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "fresh reply" }], - timestamp: 99, - }, - ], - }); - - const result = await waitForAgentRunAndReadUpdatedAssistantReply({ + messages: [ + assistant("stale previous reply", { timestamp: 41 }), + assistant("already delivered source reply", { + provider: "openclaw", + model: "delivery-mirror", + timestamp: 42, + }), + ], + expected: { status: "ok", replyText: undefined }, + }, + { + name: "returns the new assistant text when the fingerprint changes", runId: "run-2", - sessionKey: "agent:main:child", - timeoutMs: 1_000, - baseline: { - text: "older reply", - fingerprint: "old-fingerprint", - }, - }); - - expect(result).toEqual({ - status: "ok", - replyText: "fresh reply", - }); - }); - - it("preserves successful wait metadata when returning an updated reply", async () => { - callGatewayMock - .mockResolvedValueOnce({ + messages: [assistant("fresh reply", { timestamp: 99 })], + baseline: olderBaseline, + expected: { status: "ok", replyText: "fresh reply" }, + }, + { + name: "preserves successful wait metadata when returning an updated reply", + runId: "run-with-metadata", + messages: [assistant("fresh reply", { timestamp: 99 })], + baseline: olderBaseline, + wait: { status: "ok", startedAt: 100, endedAt: 200, stopReason: "completed", yielded: true, providerStarted: true, - }) - .mockResolvedValueOnce({ - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "fresh reply" }], - timestamp: 99, - }, - ], - }); + }, + expected: { + status: "ok", + startedAt: 100, + endedAt: 200, + stopReason: "completed", + yielded: true, + providerStarted: true, + replyText: "fresh reply", + }, + }, + ]; + + it.each(cases)("$name", async ({ runId, messages, expected, baseline, sessionKey, wait }) => { + callGatewayMock + .mockResolvedValueOnce(wait ?? { status: "ok" }) + .mockResolvedValueOnce({ messages }); const result = await waitForAgentRunAndReadUpdatedAssistantReply({ - runId: "run-with-metadata", - sessionKey: "agent:main:child", + runId, + sessionKey: sessionKey ?? "agent:main:child", timeoutMs: 1_000, - baseline: { - text: "older reply", - fingerprint: "old-fingerprint", - }, + baseline, }); - expect(result).toEqual({ - status: "ok", - startedAt: 100, - endedAt: 200, - stopReason: "completed", - yielded: true, - providerStarted: true, - replyText: "fresh reply", - }); + expect(result).toEqual(expected); + expect(callGatewayMock.mock.calls.map(([request]) => request.method)).toEqual([ + "agent.wait", + "chat.history", + ]); }); });