| ... | @@ -322,6 +322,16 @@ function isOpenCodeDefaultTitle(title: string): boolean { | ... | @@ -322,6 +322,16 @@ function isOpenCodeDefaultTitle(title: string): boolean { |
| 322 | return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); | 322 | return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); |
| 323 | } | 323 | } |
| 324 | | 324 | |
| | 325 | type OpenCodeTextPart = Extract<Part, { readonly type: "text" | "reasoning" }>; |
| | 326 | |
| | 327 | type OpenCodeTextPartState = Pick<OpenCodeTextPart, "id" | "messageID" | "type" | "time"> & { |
| | 328 | text: string | undefined; |
| | 329 | emittedText: string | undefined; |
| | 330 | completed: boolean; |
| | 331 | }; |
| | 332 | |
| | 333 | type OpenCodeStepUsage = Pick<Extract<Part, { readonly type: "step-finish" }>, "id" | "tokens">; |
| | 334 | |
| 325 | interface OpenCodeSessionContext { | 335 | interface OpenCodeSessionContext { |
| 326 | session: ProviderSession; | 336 | session: ProviderSession; |
| 327 | readonly client: OpencodeClient; | 337 | readonly client: OpencodeClient; |
| ... | @@ -336,9 +346,9 @@ interface OpenCodeSessionContext { | ... | @@ -336,9 +346,9 @@ interface OpenCodeSessionContext { |
| 336 | readonly pendingPermissions: Map<string, PermissionRequest>; | 346 | readonly pendingPermissions: Map<string, PermissionRequest>; |
| 337 | readonly pendingQuestions: Map<string, QuestionRequest>; | 347 | readonly pendingQuestions: Map<string, QuestionRequest>; |
| 338 | readonly messageRoleById: Map<string, "user" | "assistant">; | 348 | readonly messageRoleById: Map<string, "user" | "assistant">; |
| 339 | readonly partById: Map<string, Part>; | 349 | // OpenCode permits edits to completed parts. Keep text for snapshot comparison |
| 340 | readonly emittedTextByPartId: Map<string, string>; | 350 | // until native removal or session teardown, but do not retain other part payloads. |
| 341 | readonly completedAssistantPartIds: Set<string>; | 351 | readonly textPartsByMessageId: Map<string, Map<string, OpenCodeTextPartState>>; |
| 342 | turnTokenUsage: OpenCodeTurnTokenUsageAccumulator | undefined; | 352 | turnTokenUsage: OpenCodeTurnTokenUsageAccumulator | undefined; |
| 343 | activeTurnId: TurnId | undefined; | 353 | activeTurnId: TurnId | undefined; |
| 344 | activeAgent: string | undefined; | 354 | activeAgent: string | undefined; |
| ... | @@ -374,7 +384,8 @@ interface OpenCodeTurnTokenUsageAccumulator { | ... | @@ -374,7 +384,8 @@ interface OpenCodeTurnTokenUsageAccumulator { |
| 374 | readonly partIds: Set<string>; | 384 | readonly partIds: Set<string>; |
| 375 | readonly promptMessageIds: Set<string>; | 385 | readonly promptMessageIds: Set<string>; |
| 376 | readonly assistantOwnershipByMessageId: Map<string, "owned" | "other" | "unknown">; | 386 | readonly assistantOwnershipByMessageId: Map<string, "owned" | "other" | "unknown">; |
| 377 | readonly unresolvedStepPartIds: Set<string>; | 387 | // Native removal does not undo usage. Keep unresolved counts until this turn settles. |
| | 388 | readonly unresolvedStepsByMessageId: Map<string, Map<string, OpenCodeStepUsage>>; |
| 378 | inputTokens: number; | 389 | inputTokens: number; |
| 379 | cachedInputTokens: number; | 390 | cachedInputTokens: number; |
| 380 | cacheCreationTokens: number; | 391 | cacheCreationTokens: number; |
| ... | @@ -389,7 +400,7 @@ function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumula | ... | @@ -389,7 +400,7 @@ function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumula |
| 389 | partIds: new Set(), | 400 | partIds: new Set(), |
| 390 | promptMessageIds: new Set(), | 401 | promptMessageIds: new Set(), |
| 391 | assistantOwnershipByMessageId: new Map(), | 402 | assistantOwnershipByMessageId: new Map(), |
| 392 | unresolvedStepPartIds: new Set(), | 403 | unresolvedStepsByMessageId: new Map(), |
| 393 | inputTokens: 0, | 404 | inputTokens: 0, |
| 394 | cachedInputTokens: 0, | 405 | cachedInputTokens: 0, |
| 395 | cacheCreationTokens: 0, | 406 | cacheCreationTokens: 0, |
| ... | @@ -402,7 +413,7 @@ function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumula | ... | @@ -402,7 +413,7 @@ function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumula |
| 402 | | 413 | |
| 403 | function accumulateOpenCodeStepUsage( | 414 | function accumulateOpenCodeStepUsage( |
| 404 | accumulator: OpenCodeTurnTokenUsageAccumulator, | 415 | accumulator: OpenCodeTurnTokenUsageAccumulator, |
| 405 | part: Extract<Part, { readonly type: "step-finish" }>, | 416 | part: OpenCodeStepUsage, |
| 406 | ): void { | 417 | ): void { |
| 407 | if (accumulator.partIds.has(part.id)) return; | 418 | if (accumulator.partIds.has(part.id)) return; |
| 408 | accumulator.partIds.add(part.id); | 419 | accumulator.partIds.add(part.id); |
| ... | @@ -428,7 +439,9 @@ function takeOpenCodeTurnTokenUsage( | ... | @@ -428,7 +439,9 @@ function takeOpenCodeTurnTokenUsage( |
| 428 | } | 439 | } |
| 429 | return { | 440 | return { |
| 430 | usageStatus: | 441 | usageStatus: |
| 431 | complete && usage.complete && usage.unresolvedStepPartIds.size === 0 ? "complete" : "partial", | 442 | complete && usage.complete && usage.unresolvedStepsByMessageId.size === 0 |
| | 443 | ? "complete" |
| | 444 | : "partial", |
| 432 | usageScope: "main_agent", | 445 | usageScope: "main_agent", |
| 433 | inputTokens: usage.inputTokens, | 446 | inputTokens: usage.inputTokens, |
| 434 | cachedInputTokens: usage.cachedInputTokens, | 447 | cachedInputTokens: usage.cachedInputTokens, |
| ... | @@ -579,18 +592,29 @@ function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray<UserI | ... | @@ -579,18 +592,29 @@ function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray<UserI |
| 579 | })); | 592 | })); |
| 580 | } | 593 | } |
| 581 | | 594 | |
| 582 | function resolveTextStreamKind(part: Part | undefined): "assistant_text" | "reasoning_text" { | 595 | function resolveTextStreamKind(part: Pick<Part, "type">): "assistant_text" | "reasoning_text" { |
| 583 | return part?.type === "reasoning" ? "reasoning_text" : "assistant_text"; | 596 | return part.type === "reasoning" ? "reasoning_text" : "assistant_text"; |
| 584 | } | 597 | } |
| 585 | | 598 | |
| 586 | function textFromPart(part: Part): string | undefined { | 599 | function retainOpenCodeTextPart( |
| 587 | switch (part.type) { | 600 | context: OpenCodeSessionContext, |
| 588 | case "text": | 601 | part: OpenCodeTextPart, |
| 589 | case "reasoning": | 602 | ): OpenCodeTextPartState { |
| 590 | return part.text; | 603 | const parts = |
| 591 | default: | 604 | context.textPartsByMessageId.get(part.messageID) ?? new Map<string, OpenCodeTextPartState>(); |
| 592 | return undefined; | 605 | const previous = parts.get(part.id); |
| 593 | } | 606 | const state = { |
| | 607 | id: part.id, |
| | 608 | messageID: part.messageID, |
| | 609 | type: part.type, |
| | 610 | text: part.text, |
| | 611 | ...(part.time !== undefined ? { time: part.time } : {}), |
| | 612 | emittedText: previous?.emittedText, |
| | 613 | completed: previous?.completed ?? false, |
| | 614 | }; |
| | 615 | parts.set(part.id, state); |
| | 616 | context.textPartsByMessageId.set(part.messageID, parts); |
| | 617 | return state; |
| 594 | } | 618 | } |
| 595 | | 619 | |
| 596 | function commonPrefixLength(left: string, right: string): number { | 620 | function commonPrefixLength(left: string, right: string): number { |
| ... | @@ -1362,6 +1386,7 @@ export function makeOpenCodeAdapter( | ... | @@ -1362,6 +1386,7 @@ export function makeOpenCodeAdapter( |
| 1362 | if (message?.info.id === promptAdmission.messageId && message.info.role === "user") { | 1386 | if (message?.info.id === promptAdmission.messageId && message.info.role === "user") { |
| 1363 | promptAdmission.messageObserved = true; | 1387 | promptAdmission.messageObserved = true; |
| 1364 | context.messageRoleById.set(promptAdmission.messageId, "user"); | 1388 | context.messageRoleById.set(promptAdmission.messageId, "user"); |
| | 1389 | context.textPartsByMessageId.delete(promptAdmission.messageId); |
| 1365 | } | 1390 | } |
| 1366 | } | 1391 | } |
| 1367 | | 1392 | |
| ... | @@ -1568,35 +1593,23 @@ export function makeOpenCodeAdapter( | ... | @@ -1568,35 +1593,23 @@ export function makeOpenCodeAdapter( |
| 1568 | /** Emit content.delta and item.completed events for an assistant text part. */ | 1593 | /** Emit content.delta and item.completed events for an assistant text part. */ |
| 1569 | const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( | 1594 | const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( |
| 1570 | context: OpenCodeSessionContext, | 1595 | context: OpenCodeSessionContext, |
| 1571 | part: Part, | 1596 | part: OpenCodeTextPartState, |
| 1572 | turnId: TurnId | undefined, | 1597 | turnId: TurnId | undefined, |
| 1573 | raw: unknown, | 1598 | raw: unknown, |
| 1574 | ) { | 1599 | ) { |
| 1575 | const text = textFromPart(part); | 1600 | if (part.text === undefined) { |
| 1576 | if (text === undefined) { | | |
| 1577 | return; | 1601 | return; |
| 1578 | } | 1602 | } |
| 1579 | const previousText = context.emittedTextByPartId.get(part.id); | 1603 | const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(part.emittedText, part.text); |
| 1580 | const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text); | 1604 | part.emittedText = latestText; |
| 1581 | context.emittedTextByPartId.set(part.id, latestText); | 1605 | part.text = latestText; |
| 1582 | if (latestText !== text) { | | |
| 1583 | context.partById.set( | | |
| 1584 | part.id, | | |
| 1585 | (part.type === "text" || part.type === "reasoning" | | |
| 1586 | ? { ...part, text: latestText } | | |
| 1587 | : part) satisfies Part, | | |
| 1588 | ); | | |
| 1589 | } | | |
| 1590 | if (deltaToEmit.length > 0) { | 1606 | if (deltaToEmit.length > 0) { |
| 1591 | yield* emit({ | 1607 | yield* emit({ |
| 1592 | ...(yield* buildEventBase({ | 1608 | ...(yield* buildEventBase({ |
| 1593 | threadId: context.session.threadId, | 1609 | threadId: context.session.threadId, |
| 1594 | turnId, | 1610 | turnId, |
| 1595 | itemId: part.id, | 1611 | itemId: part.id, |
| 1596 | createdAt: | 1612 | createdAt: part.time !== undefined ? isoFromEpochMs(part.time.start) : undefined, |
| 1597 | (part.type === "text" || part.type === "reasoning") && part.time !== undefined | | |
| 1598 | ? isoFromEpochMs(part.time.start) | | |
| 1599 | : undefined, | | |
| 1600 | raw, | 1613 | raw, |
| 1601 | })), | 1614 | })), |
| 1602 | type: "content.delta", | 1615 | type: "content.delta", |
| ... | @@ -1607,12 +1620,8 @@ export function makeOpenCodeAdapter( | ... | @@ -1607,12 +1620,8 @@ export function makeOpenCodeAdapter( |
| 1607 | }); | 1620 | }); |
| 1608 | } | 1621 | } |
| 1609 | | 1622 | |
| 1610 | if ( | 1623 | if (part.type === "text" && part.time?.end !== undefined && !part.completed) { |
| 1611 | part.type === "text" && | 1624 | part.completed = true; |
| 1612 | part.time?.end !== undefined && | | |
| 1613 | !context.completedAssistantPartIds.has(part.id) | | |
| 1614 | ) { | | |
| 1615 | context.completedAssistantPartIds.add(part.id); | | |
| 1616 | yield* emit({ | 1625 | yield* emit({ |
| 1617 | ...(yield* buildEventBase({ | 1626 | ...(yield* buildEventBase({ |
| 1618 | threadId: context.session.threadId, | 1627 | threadId: context.session.threadId, |
| ... | @@ -2315,6 +2324,9 @@ export function makeOpenCodeAdapter( | ... | @@ -2315,6 +2324,9 @@ export function makeOpenCodeAdapter( |
| 2315 | } | 2324 | } |
| 2316 | } | 2325 | } |
| 2317 | context.messageRoleById.set(event.properties.info.id, event.properties.info.role); | 2326 | context.messageRoleById.set(event.properties.info.id, event.properties.info.role); |
| | 2327 | if (event.properties.info.role === "user") { |
| | 2328 | context.textPartsByMessageId.delete(event.properties.info.id); |
| | 2329 | } |
| 2318 | if (event.properties.info.role === "assistant") { | 2330 | if (event.properties.info.role === "assistant") { |
| 2319 | const usage = context.turnTokenUsage; | 2331 | const usage = context.turnTokenUsage; |
| 2320 | const parentMessageId = | 2332 | const parentMessageId = |
| ... | @@ -2337,15 +2349,19 @@ export function makeOpenCodeAdapter( | ... | @@ -2337,15 +2349,19 @@ export function makeOpenCodeAdapter( |
| 2337 | : priorOwnership; | 2349 | : priorOwnership; |
| 2338 | if (usage) { | 2350 | if (usage) { |
| 2339 | usage.assistantOwnershipByMessageId.set(event.properties.info.id, ownership); | 2351 | usage.assistantOwnershipByMessageId.set(event.properties.info.id, ownership); |
| 2340 | } | 2352 | if (ownership !== "unknown") { |
| 2341 | for (const part of context.partById.values()) { | 2353 | const steps = usage.unresolvedStepsByMessageId.get(event.properties.info.id); |
| 2342 | if (part.messageID !== event.properties.info.id) { | 2354 | if (ownership === "owned" && steps) { |
| 2343 | continue; | 2355 | for (const step of steps.values()) { |
| 2344 | } | 2356 | accumulateOpenCodeStepUsage(usage, step); |
| 2345 | if (usage && part.type === "step-finish") { | 2357 | } |
| 2346 | if (ownership !== "unknown") usage.unresolvedStepPartIds.delete(part.id); | 2358 | } |
| 2347 | if (ownership === "owned") accumulateOpenCodeStepUsage(usage, part); | 2359 | usage.unresolvedStepsByMessageId.delete(event.properties.info.id); |
| 2348 | } | 2360 | } |
| | 2361 | } |
| | 2362 | for (const part of context.textPartsByMessageId |
| | 2363 | .get(event.properties.info.id) |
| | 2364 | ?.values() ?? []) { |
| 2349 | yield* emitAssistantTextDelta(context, part, turnId, event); | 2365 | yield* emitAssistantTextDelta(context, part, turnId, event); |
| 2350 | } | 2366 | } |
| 2351 | } | 2367 | } |
| ... | @@ -2354,16 +2370,24 @@ export function makeOpenCodeAdapter( | ... | @@ -2354,16 +2370,24 @@ export function makeOpenCodeAdapter( |
| 2354 | | 2370 | |
| 2355 | case "message.removed": { | 2371 | case "message.removed": { |
| 2356 | context.messageRoleById.delete(event.properties.messageID); | 2372 | context.messageRoleById.delete(event.properties.messageID); |
| | 2373 | context.textPartsByMessageId.delete(event.properties.messageID); |
| | 2374 | break; |
| | 2375 | } |
| | 2376 | |
| | 2377 | case "message.part.removed": { |
| | 2378 | const parts = context.textPartsByMessageId.get(event.properties.messageID); |
| | 2379 | parts?.delete(event.properties.partID); |
| | 2380 | if (parts?.size === 0) { |
| | 2381 | context.textPartsByMessageId.delete(event.properties.messageID); |
| | 2382 | } |
| 2357 | break; | 2383 | break; |
| 2358 | } | 2384 | } |
| 2359 | | 2385 | |
| 2360 | case "message.part.delta": { | 2386 | case "message.part.delta": { |
| 2361 | const existingPart = context.partById.get(event.properties.partID); | 2387 | const existingPart = context.textPartsByMessageId |
| 2362 | if ( | 2388 | .get(event.properties.messageID) |
| 2363 | !existingPart || | 2389 | ?.get(event.properties.partID); |
| 2364 | (existingPart.type !== "text" && existingPart.type !== "reasoning") || | 2390 | if (existingPart?.text === undefined || event.properties.field !== "text") { |
| 2365 | event.properties.field !== "text" | | |
| 2366 | ) { | | |
| 2367 | break; | 2391 | break; |
| 2368 | } | 2392 | } |
| 2369 | const role = messageRoleForPart(context, existingPart); | 2393 | const role = messageRoleForPart(context, existingPart); |
| ... | @@ -2375,21 +2399,13 @@ export function makeOpenCodeAdapter( | ... | @@ -2375,21 +2399,13 @@ export function makeOpenCodeAdapter( |
| 2375 | if (delta.length === 0) { | 2399 | if (delta.length === 0) { |
| 2376 | break; | 2400 | break; |
| 2377 | } | 2401 | } |
| 2378 | const previousText = | 2402 | const previousText = existingPart.emittedText ?? existingPart.text; |
| 2379 | context.emittedTextByPartId.get(event.properties.partID) ?? | | |
| 2380 | textFromPart(existingPart) ?? | | |
| 2381 | ""; | | |
| 2382 | const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(previousText, delta); | 2403 | const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(previousText, delta); |
| 2383 | if (deltaToEmit.length === 0) { | 2404 | if (deltaToEmit.length === 0) { |
| 2384 | break; | 2405 | break; |
| 2385 | } | 2406 | } |
| 2386 | context.emittedTextByPartId.set(event.properties.partID, nextText); | 2407 | existingPart.emittedText = nextText; |
| 2387 | if (existingPart.type === "text" || existingPart.type === "reasoning") { | 2408 | existingPart.text = nextText; |
| 2388 | context.partById.set(event.properties.partID, { | | |
| 2389 | ...existingPart, | | |
| 2390 | text: nextText, | | |
| 2391 | }); | | |
| 2392 | } | | |
| 2393 | yield* emit({ | 2409 | yield* emit({ |
| 2394 | ...(yield* buildEventBase({ | 2410 | ...(yield* buildEventBase({ |
| 2395 | threadId: context.session.threadId, | 2411 | threadId: context.session.threadId, |
| ... | @@ -2408,29 +2424,38 @@ export function makeOpenCodeAdapter( | ... | @@ -2408,29 +2424,38 @@ export function makeOpenCodeAdapter( |
| 2408 | | 2424 | |
| 2409 | case "message.part.updated": { | 2425 | case "message.part.updated": { |
| 2410 | const part = event.properties.part; | 2426 | const part = event.properties.part; |
| 2411 | // Tool events use the incoming part and do not need a cached copy. | | |
| 2412 | if (part.type !== "tool") { | | |
| 2413 | context.partById.set(part.id, part); | | |
| 2414 | } | | |
| 2415 | const messageRole = messageRoleForPart(context, part); | 2427 | const messageRole = messageRoleForPart(context, part); |
| 2416 | | 2428 | |
| 2417 | if (turnId && part.type === "step-finish" && context.turnTokenUsage) { | 2429 | if (turnId && part.type === "step-finish" && context.turnTokenUsage) { |
| 2418 | const ownership = context.turnTokenUsage.assistantOwnershipByMessageId.get( | 2430 | const usage = context.turnTokenUsage; |
| 2419 | part.messageID, | 2431 | const ownership = usage.assistantOwnershipByMessageId.get(part.messageID); |
| 2420 | ); | | |
| 2421 | if (ownership === "owned") { | 2432 | if (ownership === "owned") { |
| 2422 | accumulateOpenCodeStepUsage(context.turnTokenUsage, part); | 2433 | accumulateOpenCodeStepUsage(usage, part); |
| 2423 | } else if ( | 2434 | } else if ( |
| 2424 | ownership === "unknown" || | 2435 | ownership === "unknown" || |
| 2425 | (ownership === undefined && | 2436 | (ownership === undefined && |
| 2426 | context.messageRoleById.get(part.messageID) !== "assistant") | 2437 | context.messageRoleById.get(part.messageID) !== "assistant") |
| 2427 | ) { | 2438 | ) { |
| 2428 | context.turnTokenUsage.unresolvedStepPartIds.add(part.id); | 2439 | const steps = |
| | 2440 | usage.unresolvedStepsByMessageId.get(part.messageID) ?? |
| | 2441 | new Map<string, OpenCodeStepUsage>(); |
| | 2442 | steps.set(part.id, { id: part.id, tokens: part.tokens }); |
| | 2443 | usage.unresolvedStepsByMessageId.set(part.messageID, steps); |
| 2429 | } | 2444 | } |
| 2430 | } | 2445 | } |
| 2431 | | 2446 | |
| 2432 | if (messageRole === "assistant") { | 2447 | if ((part.type === "text" || part.type === "reasoning") && messageRole !== "user") { |
| 2433 | yield* emitAssistantTextDelta(context, part, turnId, event); | 2448 | const state = retainOpenCodeTextPart(context, part); |
| | 2449 | if (messageRole === "assistant") { |
| | 2450 | yield* emitAssistantTextDelta(context, state, turnId, event); |
| | 2451 | } |
| | 2452 | } else { |
| | 2453 | const previous = context.textPartsByMessageId.get(part.messageID)?.get(part.id); |
| | 2454 | if (previous) { |
| | 2455 | // A non-text PATCH removes the current snapshot. Keep emitted text |
| | 2456 | // so a later text PATCH still emits only the changed suffix. |
| | 2457 | previous.text = undefined; |
| | 2458 | } |
| 2434 | } | 2459 | } |
| 2435 | | 2460 | |
| 2436 | if (part.type === "tool") { | 2461 | if (part.type === "tool") { |
| ... | @@ -2963,10 +2988,8 @@ export function makeOpenCodeAdapter( | ... | @@ -2963,10 +2988,8 @@ export function makeOpenCodeAdapter( |
| 2963 | requestRelationRetries: new Map(), | 2988 | requestRelationRetries: new Map(), |
| 2964 | pendingPermissions: new Map(), | 2989 | pendingPermissions: new Map(), |
| 2965 | pendingQuestions: new Map(), | 2990 | pendingQuestions: new Map(), |
| 2966 | partById: new Map(), | 2991 | textPartsByMessageId: new Map(), |
| 2967 | emittedTextByPartId: new Map(), | | |
| 2968 | messageRoleById: new Map(), | 2992 | messageRoleById: new Map(), |
| 2969 | completedAssistantPartIds: new Set(), | | |
| 2970 | turnTokenUsage: undefined, | 2993 | turnTokenUsage: undefined, |
| 2971 | activeTurnId: undefined, | 2994 | activeTurnId: undefined, |
| 2972 | activeAgent: undefined, | 2995 | activeAgent: undefined, |