* Commit any existing partial message to chat history and delete partial.json. * * This is idempotent: * - If the partial has already been finalized in history, it is not committed again. * - After committing (or if already finalized), partial.json is deleted.
(workspaceId: string)
| 1257 | * - After committing (or if already finalized), partial.json is deleted. |
| 1258 | */ |
| 1259 | async commitPartial(workspaceId: string): Promise<Result<void>> { |
| 1260 | try { |
| 1261 | let partial = await this.readPartial(workspaceId); |
| 1262 | if (!partial) { |
| 1263 | return Ok(undefined); |
| 1264 | } |
| 1265 | |
| 1266 | const hadErrorMetadata = partial.metadata?.error != null; |
| 1267 | |
| 1268 | // Strip transient error metadata, but persist accumulated content. |
| 1269 | if (partial.metadata?.error) { |
| 1270 | const { error, errorType, ...cleanMetadata } = partial.metadata; |
| 1271 | partial = { ...partial, metadata: cleanMetadata }; |
| 1272 | } |
| 1273 | |
| 1274 | const partialSeq = partial.metadata?.historySequence; |
| 1275 | if (partialSeq === undefined) { |
| 1276 | return Err("Partial message has no historySequence"); |
| 1277 | } |
| 1278 | |
| 1279 | const historyResult = await this.getHistoryFromLatestBoundary(workspaceId); |
| 1280 | if (!historyResult.success) { |
| 1281 | return Err(`Failed to read history: ${historyResult.error}`); |
| 1282 | } |
| 1283 | |
| 1284 | const existingMessages = historyResult.data; |
| 1285 | const maxExistingSequence = this.getNewestHistorySequence(existingMessages); |
| 1286 | |
| 1287 | const hasCommitWorthyParts = (partial.parts ?? []).some((part) => { |
| 1288 | if (part.type === "text" || part.type === "reasoning") { |
| 1289 | return part.text.trim().length > 0; |
| 1290 | } |
| 1291 | |
| 1292 | if (part.type === "file") { |
| 1293 | return true; |
| 1294 | } |
| 1295 | |
| 1296 | if (part.type === "dynamic-tool") { |
| 1297 | // Incomplete tool calls (input-available) are dropped during provider request |
| 1298 | // conversion. Persisting tool-only incomplete partials can brick future requests. |
| 1299 | return part.state === "output-available"; |
| 1300 | } |
| 1301 | |
| 1302 | return false; |
| 1303 | }); |
| 1304 | |
| 1305 | // Refusal errors can be durable even with zero assistant-visible parts: |
| 1306 | // finishReason lets the UI show a refusal row after error/errorType are |
| 1307 | // stripped on commit, and usage/toolModelUsages may be absent if the |
| 1308 | // provider omitted usage or metadata reads timed out. |
| 1309 | const hasDurableRefusalMetadata = |
| 1310 | hadErrorMetadata && isRefusalFinishReason(partial.metadata?.finishReason); |
| 1311 | |
| 1312 | const existingMessage = existingMessages.find( |
| 1313 | (message) => message.metadata?.historySequence === partialSeq |
| 1314 | ); |
| 1315 | |
| 1316 | if ( |