| ... | @@ -40,6 +40,42 @@ import { | ... | @@ -40,6 +40,42 @@ import { |
| 40 | import { ServerSettingsService } from "../../serverSettings.ts"; | 40 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 41 | | 41 | |
| 42 | const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; | 42 | const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; |
| | 43 | const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; |
| | 44 | |
| | 45 | // Fallback when the in-memory description cache no longer has the task name |
| | 46 | // (server restart, session-exit sweep, TTL/capacity eviction): earlier |
| | 47 | // task.started/task.progress activities for the task are persisted with it. |
| | 48 | function findTaskTitleInActivities( |
| | 49 | activities: ReadonlyArray<OrchestrationThreadActivity> | undefined, |
| | 50 | taskId: string, |
| | 51 | ): string | undefined { |
| | 52 | if (!activities) { |
| | 53 | return undefined; |
| | 54 | } |
| | 55 | for (let index = activities.length - 1; index >= 0; index -= 1) { |
| | 56 | const activity = activities[index]; |
| | 57 | if (!activity || (activity.kind !== "task.started" && activity.kind !== "task.progress")) { |
| | 58 | continue; |
| | 59 | } |
| | 60 | const payload = |
| | 61 | activity.payload && typeof activity.payload === "object" |
| | 62 | ? (activity.payload as { taskId?: unknown; title?: unknown; detail?: unknown }) |
| | 63 | : undefined; |
| | 64 | if (payload?.taskId !== taskId) { |
| | 65 | continue; |
| | 66 | } |
| | 67 | const title = |
| | 68 | typeof payload.title === "string" |
| | 69 | ? payload.title |
| | 70 | : activity.kind === "task.started" && typeof payload.detail === "string" |
| | 71 | ? payload.detail |
| | 72 | : undefined; |
| | 73 | if (title && title.trim().length > 0) { |
| | 74 | return title; |
| | 75 | } |
| | 76 | } |
| | 77 | return undefined; |
| | 78 | } |
| 43 | | 79 | |
| 44 | interface AssistantSegmentState { | 80 | interface AssistantSegmentState { |
| 45 | baseKey: string; | 81 | baseKey: string; |
| ... | @@ -53,6 +89,8 @@ const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; | ... | @@ -53,6 +89,8 @@ const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; |
| 53 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); | 89 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); |
| 54 | const BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY = 10_000; | 90 | const BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY = 10_000; |
| 55 | const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); | 91 | const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); |
| | 92 | const TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY = 10_000; |
| | 93 | const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120); |
| 56 | const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; | 94 | const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; |
| 57 | const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; | 95 | const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; |
| 58 | | 96 | |
| ... | @@ -264,6 +302,7 @@ function requestKindFromCanonicalRequestType( | ... | @@ -264,6 +302,7 @@ function requestKindFromCanonicalRequestType( |
| 264 | | 302 | |
| 265 | function runtimeEventToActivities( | 303 | function runtimeEventToActivities( |
| 266 | event: ProviderRuntimeEvent, | 304 | event: ProviderRuntimeEvent, |
| | 305 | taskTitle?: string, |
| 267 | ): ReadonlyArray<OrchestrationThreadActivity> { | 306 | ): ReadonlyArray<OrchestrationThreadActivity> { |
| 268 | const maybeSequence = (() => { | 307 | const maybeSequence = (() => { |
| 269 | const eventWithSequence = event as ProviderRuntimeEvent & { sessionSequence?: number }; | 308 | const eventWithSequence = event as ProviderRuntimeEvent & { sessionSequence?: number }; |
| ... | @@ -473,9 +512,15 @@ function runtimeEventToActivities( | ... | @@ -473,9 +512,15 @@ function runtimeEventToActivities( |
| 473 | createdAt: event.createdAt, | 512 | createdAt: event.createdAt, |
| 474 | tone: "info", | 513 | tone: "info", |
| 475 | kind: "task.progress", | 514 | kind: "task.progress", |
| 476 | summary: "Reasoning update", | 515 | summary: |
| | 516 | event.payload.description.trim().length > 0 |
| | 517 | ? truncateDetail(event.payload.description, 120) |
| | 518 | : "Reasoning update", |
| 477 | payload: { | 519 | payload: { |
| 478 | taskId: event.payload.taskId, | 520 | taskId: event.payload.taskId, |
| | 521 | ...(event.payload.description.trim().length > 0 |
| | 522 | ? { title: truncateDetail(event.payload.description, 120) } |
| | 523 | : {}), |
| 479 | detail: truncateDetail(event.payload.summary ?? event.payload.description), | 524 | detail: truncateDetail(event.payload.summary ?? event.payload.description), |
| 480 | ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), | 525 | ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), |
| 481 | ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), | 526 | ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), |
| ... | @@ -503,7 +548,15 @@ function runtimeEventToActivities( | ... | @@ -503,7 +548,15 @@ function runtimeEventToActivities( |
| 503 | payload: { | 548 | payload: { |
| 504 | taskId: event.payload.taskId, | 549 | taskId: event.payload.taskId, |
| 505 | status: event.payload.status, | 550 | status: event.payload.status, |
| 506 | ...(event.payload.summary ? { detail: truncateDetail(event.payload.summary) } : {}), | 551 | ...(taskTitle ? { title: truncateDetail(taskTitle, 120) } : {}), |
| | 552 | // summary + detail mirror task.progress: clients label the row from |
| | 553 | // summary and keep detail for the preview/expanded body. |
| | 554 | ...(event.payload.summary |
| | 555 | ? { |
| | 556 | summary: truncateDetail(event.payload.summary), |
| | 557 | detail: truncateDetail(event.payload.summary), |
| | 558 | } |
| | 559 | : {}), |
| 507 | ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), | 560 | ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), |
| 508 | }, | 561 | }, |
| 509 | turnId: toTurnId(event.turnId) ?? null, | 562 | turnId: toTurnId(event.turnId) ?? null, |
| ... | @@ -666,6 +719,27 @@ const make = Effect.gen(function* () { | ... | @@ -666,6 +719,27 @@ const make = Effect.gen(function* () { |
| 666 | lookup: () => Effect.succeed({ text: "", createdAt: "" }), | 719 | lookup: () => Effect.succeed({ text: "", createdAt: "" }), |
| 667 | }); | 720 | }); |
| 668 | | 721 | |
| | 722 | // Task names arrive on task.started/task.progress but not on task.completed, |
| | 723 | // so remember them per task to title the completion activity. |
| | 724 | const taskDescriptionByTaskKey = yield* Cache.make<string, string>({ |
| | 725 | capacity: TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY, |
| | 726 | timeToLive: TASK_DESCRIPTION_BY_TASK_TTL, |
| | 727 | lookup: () => Effect.succeed(""), |
| | 728 | }); |
| | 729 | |
| | 730 | const rememberTaskDescription = (threadId: ThreadId, taskId: string, description: string) => |
| | 731 | Cache.set(taskDescriptionByTaskKey, providerTaskKey(threadId, taskId), description); |
| | 732 | |
| | 733 | // Entries are left in place after completion so replayed or duplicate |
| | 734 | // terminal events stay titled; TTL, capacity, and the session-exit sweep |
| | 735 | // bound the cache. |
| | 736 | const lookupTaskDescription = (threadId: ThreadId, taskId: string) => |
| | 737 | Cache.getOption(taskDescriptionByTaskKey, providerTaskKey(threadId, taskId)).pipe( |
| | 738 | Effect.map((description) => |
| | 739 | Option.filter(description, (value) => value.length > 0).pipe(Option.getOrUndefined), |
| | 740 | ), |
| | 741 | ); |
| | 742 | |
| 669 | const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { | 743 | const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { |
| 670 | return yield* projectionSnapshotQuery | 744 | return yield* projectionSnapshotQuery |
| 671 | .getThreadDetailById(threadId) | 745 | .getThreadDetailById(threadId) |
| ... | @@ -1090,6 +1164,7 @@ const make = Effect.gen(function* () { | ... | @@ -1090,6 +1164,7 @@ const make = Effect.gen(function* () { |
| 1090 | const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); | 1164 | const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); |
| 1091 | const assistantSegmentKeys = Array.from(yield* Cache.keys(assistantSegmentStateByTurnKey)); | 1165 | const assistantSegmentKeys = Array.from(yield* Cache.keys(assistantSegmentStateByTurnKey)); |
| 1092 | const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); | 1166 | const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); |
| | 1167 | const taskDescriptionKeys = Array.from(yield* Cache.keys(taskDescriptionByTaskKey)); |
| 1093 | yield* Effect.forEach( | 1168 | yield* Effect.forEach( |
| 1094 | turnKeys, | 1169 | turnKeys, |
| 1095 | (key) => | 1170 | (key) => |
| ... | @@ -1125,6 +1200,12 @@ const make = Effect.gen(function* () { | ... | @@ -1125,6 +1200,12 @@ const make = Effect.gen(function* () { |
| 1125 | : Effect.void, | 1200 | : Effect.void, |
| 1126 | { concurrency: 1 }, | 1201 | { concurrency: 1 }, |
| 1127 | ).pipe(Effect.asVoid); | 1202 | ).pipe(Effect.asVoid); |
| | 1203 | yield* Effect.forEach( |
| | 1204 | taskDescriptionKeys, |
| | 1205 | (key) => |
| | 1206 | key.startsWith(prefix) ? Cache.invalidate(taskDescriptionByTaskKey, key) : Effect.void, |
| | 1207 | { concurrency: 1 }, |
| | 1208 | ).pipe(Effect.asVoid); |
| 1128 | }); | 1209 | }); |
| 1129 | | 1210 | |
| 1130 | const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn( | 1211 | const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn( |
| ... | @@ -1654,7 +1735,22 @@ const make = Effect.gen(function* () { | ... | @@ -1654,7 +1735,22 @@ const make = Effect.gen(function* () { |
| 1654 | } | 1735 | } |
| 1655 | } | 1736 | } |
| 1656 | | 1737 | |
| 1657 | const activities = runtimeEventToActivities(event); | 1738 | if (event.type === "task.started" || event.type === "task.progress") { |
| | 1739 | const description = event.payload.description?.trim(); |
| | 1740 | if (description) { |
| | 1741 | yield* rememberTaskDescription(thread.id, event.payload.taskId, description); |
| | 1742 | } |
| | 1743 | } |
| | 1744 | let taskTitle: string | undefined; |
| | 1745 | if (event.type === "task.completed") { |
| | 1746 | taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); |
| | 1747 | if (!taskTitle) { |
| | 1748 | const threadDetail = yield* getLoadedThreadDetail(); |
| | 1749 | taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId); |
| | 1750 | } |
| | 1751 | } |
| | 1752 | |
| | 1753 | const activities = runtimeEventToActivities(event, taskTitle); |
| 1658 | yield* Effect.forEach(activities, (activity) => | 1754 | yield* Effect.forEach(activities, (activity) => |
| 1659 | providerCommandId(event, "thread-activity-append").pipe( | 1755 | providerCommandId(event, "thread-activity-append").pipe( |
| 1660 | Effect.flatMap((commandId) => | 1756 | Effect.flatMap((commandId) => |