|
1 | 1 | // Chat transcript injection appends gateway-authored assistant rows while |
2 | 2 | // preserving agent-session parent links and transcript update notifications. |
3 | 3 | import type { SessionManager } from "../../agents/sessions/session-manager.js"; |
4 | | -import { persistSessionTranscriptTurn } from "../../config/sessions/session-accessor.js"; |
| 4 | +import { |
| 5 | + findTranscriptEvent, |
| 6 | + persistSessionTranscriptTurn, |
| 7 | + type TranscriptEvent, |
| 8 | +} from "../../config/sessions/session-accessor.js"; |
5 | 9 | import type { OpenClawConfig } from "../../config/types.openclaw.js"; |
6 | 10 | import { formatErrorMessage } from "../../infra/errors.js"; |
7 | 11 |
|
@@ -53,9 +57,71 @@ function resolveInjectedAssistantContent(params: { |
53 | 57 | return [{ type: "text", text: `${labelPrefix}${params.message}` }]; |
54 | 58 | } |
55 | 59 |
|
| 60 | +function transcriptEventRecord(event: TranscriptEvent): Record<string, unknown> | undefined { |
| 61 | + return event && typeof event === "object" && !Array.isArray(event) |
| 62 | + ? (event as Record<string, unknown>) |
| 63 | + : undefined; |
| 64 | +} |
| 65 | + |
| 66 | +function transcriptEventMessage(event: TranscriptEvent): Record<string, unknown> | undefined { |
| 67 | + const message = transcriptEventRecord(event)?.message; |
| 68 | + return message && typeof message === "object" && !Array.isArray(message) |
| 69 | + ? (message as Record<string, unknown>) |
| 70 | + : undefined; |
| 71 | +} |
| 72 | + |
| 73 | +function transcriptEventId(event: TranscriptEvent): string | undefined { |
| 74 | + const id = transcriptEventRecord(event)?.id; |
| 75 | + return typeof id === "string" && id.trim().length > 0 ? id : undefined; |
| 76 | +} |
| 77 | + |
| 78 | +type InjectedAssistantIdempotencyTarget = { |
| 79 | + agentId?: string; |
| 80 | + sessionFile?: string; |
| 81 | + sessionId?: string; |
| 82 | + sessionKey?: string; |
| 83 | + storePath?: string; |
| 84 | +}; |
| 85 | + |
| 86 | +async function findInjectedAssistantMessageByIdempotencyKey(params: { |
| 87 | + idempotencyKey: string; |
| 88 | + target: InjectedAssistantIdempotencyTarget; |
| 89 | +}): Promise<{ messageId: string; message: Record<string, unknown> } | undefined> { |
| 90 | + if (!params.target.sessionId || !params.target.sessionKey) { |
| 91 | + return undefined; |
| 92 | + } |
| 93 | + // The in-lock duplicate check resolves through the already-resolved |
| 94 | + // sessionFile when present so the lookup reads the file being appended to. |
| 95 | + // findTranscriptEvent scans newest-first with early exit, keeping the hot |
| 96 | + // idempotent append path from materializing the whole transcript. |
| 97 | + const found = await findTranscriptEvent( |
| 98 | + { |
| 99 | + ...(params.target.agentId ? { agentId: params.target.agentId } : {}), |
| 100 | + ...(params.target.sessionFile ? { sessionFile: params.target.sessionFile } : {}), |
| 101 | + sessionId: params.target.sessionId, |
| 102 | + sessionKey: params.target.sessionKey, |
| 103 | + ...(params.target.storePath ? { storePath: params.target.storePath } : {}), |
| 104 | + }, |
| 105 | + (candidate) => { |
| 106 | + const message = transcriptEventMessage(candidate); |
| 107 | + return message?.role === "assistant" && message.idempotencyKey === params.idempotencyKey; |
| 108 | + }, |
| 109 | + ); |
| 110 | + const message = found ? transcriptEventMessage(found.event) : undefined; |
| 111 | + if (!message) { |
| 112 | + return undefined; |
| 113 | + } |
| 114 | + // Legacy shipped transcripts can carry assistant rows without top-level ids; |
| 115 | + // fall back to the idempotency key so re-issued aborts still dedupe there. |
| 116 | + const messageId = (found ? transcriptEventId(found.event) : undefined) ?? params.idempotencyKey; |
| 117 | + return { messageId, message }; |
| 118 | +} |
| 119 | + |
56 | 120 | /** Append a gateway-authored assistant message while preserving transcript parent links. */ |
57 | 121 | export async function appendInjectedAssistantMessageToTranscript(params: { |
58 | | - transcriptPath: string; |
| 122 | + transcriptPath?: string; |
| 123 | + storePath?: string; |
| 124 | + sessionId?: string; |
59 | 125 | sessionKey?: string; |
60 | 126 | agentId?: string; |
61 | 127 | message: string; |
@@ -118,26 +184,60 @@ export async function appendInjectedAssistantMessageToTranscript(params: { |
118 | 184 | }; |
119 | 185 |
|
120 | 186 | try { |
| 187 | + if (!params.transcriptPath && (!params.storePath || !params.sessionId || !params.sessionKey)) { |
| 188 | + return { ok: false, error: "transcript identity not resolved" }; |
| 189 | + } |
| 190 | + const assistantScopedIdempotency = |
| 191 | + params.idempotencyKey && params.storePath && params.sessionId && params.sessionKey |
| 192 | + ? { |
| 193 | + idempotencyKey: params.idempotencyKey, |
| 194 | + target: { |
| 195 | + ...(params.agentId ? { agentId: params.agentId } : {}), |
| 196 | + sessionId: params.sessionId, |
| 197 | + sessionKey: params.sessionKey, |
| 198 | + storePath: params.storePath, |
| 199 | + }, |
| 200 | + } |
| 201 | + : undefined; |
121 | 202 | const turn = await persistSessionTranscriptTurn( |
122 | 203 | { |
123 | | - sessionFile: params.transcriptPath, |
124 | 204 | sessionKey: params.sessionKey ?? "", |
| 205 | + ...(params.transcriptPath ? { sessionFile: params.transcriptPath } : {}), |
| 206 | + ...(params.storePath ? { storePath: params.storePath } : {}), |
| 207 | + ...(params.sessionId ? { sessionId: params.sessionId } : {}), |
125 | 208 | ...(params.agentId ? { agentId: params.agentId } : {}), |
126 | 209 | }, |
127 | 210 | { |
128 | 211 | updateMode: "inline", |
| 212 | + touchSessionEntry: Boolean(params.storePath && params.sessionId && params.sessionKey), |
129 | 213 | ...(params.config ? { config: params.config } : {}), |
130 | 214 | messages: [ |
131 | 215 | { |
132 | 216 | message: messageBody, |
| 217 | + idempotencyLookup: assistantScopedIdempotency ? "caller-checked" : "scan", |
133 | 218 | now, |
134 | 219 | useRawWhenLinear: true, |
| 220 | + shouldAppend: assistantScopedIdempotency |
| 221 | + ? async (target) => |
| 222 | + !(await findInjectedAssistantMessageByIdempotencyKey({ |
| 223 | + idempotencyKey: assistantScopedIdempotency.idempotencyKey, |
| 224 | + target, |
| 225 | + })) |
| 226 | + : undefined, |
135 | 227 | }, |
136 | 228 | ], |
137 | 229 | }, |
138 | 230 | ); |
139 | 231 | const appended = turn.messages[0]; |
140 | 232 | if (!appended) { |
| 233 | + if (assistantScopedIdempotency) { |
| 234 | + const existing = await findInjectedAssistantMessageByIdempotencyKey( |
| 235 | + assistantScopedIdempotency, |
| 236 | + ); |
| 237 | + if (existing) { |
| 238 | + return { ok: true, messageId: existing.messageId, message: existing.message }; |
| 239 | + } |
| 240 | + } |
141 | 241 | return { ok: false, error: "gateway-injected assistant message was not appended" }; |
142 | 242 | } |
143 | 243 | return { |
|
0 commit comments