| author | |
| committer | |
| log | 749baec353d5675f1acac81f22aa15da573e71c7 |
| tree | 9c3f5297f785948819fe8bad14641f77ffe0aa66 |
| parent | d7baa37e5c25a1335a117ee3c9f5b5b0d5bbd832 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>2 files changed, 289 insertions(+), 3 deletions(-)
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+190| ... | ... | @@ -2947,6 +2947,196 @@ describe("ProviderRuntimeIngestion", () => { |
| 2947 | 2947 | ).toBe("# Plan title"); |
| 2948 | 2948 | }); |
| 2949 | 2949 | |
| 2950 | it("titles task activities with the task description, including on completion", async () => { | |
| 2951 | const harness = await createHarness(); | |
| 2952 | const now = "2026-01-01T00:00:00.000Z"; | |
| 2953 | ||
| 2954 | harness.emit({ | |
| 2955 | type: "task.started", | |
| 2956 | eventId: asEventId("evt-named-task-started"), | |
| 2957 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 2958 | createdAt: now, | |
| 2959 | threadId: asThreadId("thread-1"), | |
| 2960 | turnId: asTurnId("turn-named-task"), | |
| 2961 | payload: { | |
| 2962 | taskId: "named-task-1", | |
| 2963 | description: "Typecheck mobile app", | |
| 2964 | taskType: "local_bash", | |
| 2965 | }, | |
| 2966 | }); | |
| 2967 | ||
| 2968 | harness.emit({ | |
| 2969 | type: "task.progress", | |
| 2970 | eventId: asEventId("evt-named-task-progress"), | |
| 2971 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 2972 | createdAt: now, | |
| 2973 | threadId: asThreadId("thread-1"), | |
| 2974 | turnId: asTurnId("turn-named-task"), | |
| 2975 | payload: { | |
| 2976 | taskId: "named-task-1", | |
| 2977 | description: "Typecheck mobile app", | |
| 2978 | summary: "Running tsc across the mobile workspace.", | |
| 2979 | }, | |
| 2980 | }); | |
| 2981 | ||
| 2982 | harness.emit({ | |
| 2983 | type: "task.completed", | |
| 2984 | eventId: asEventId("evt-named-task-completed"), | |
| 2985 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 2986 | createdAt: now, | |
| 2987 | threadId: asThreadId("thread-1"), | |
| 2988 | turnId: asTurnId("turn-named-task"), | |
| 2989 | payload: { | |
| 2990 | taskId: "named-task-1", | |
| 2991 | status: "completed", | |
| 2992 | summary: "Typecheck finished without errors.", | |
| 2993 | }, | |
| 2994 | }); | |
| 2995 | ||
| 2996 | const thread = await waitForThread(harness.readModel, (entry) => | |
| 2997 | entry.activities.some( | |
| 2998 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed", | |
| 2999 | ), | |
| 3000 | ); | |
| 3001 | ||
| 3002 | const progress = thread.activities.find( | |
| 3003 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-progress", | |
| 3004 | ); | |
| 3005 | const completed = thread.activities.find( | |
| 3006 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed", | |
| 3007 | ); | |
| 3008 | ||
| 3009 | const progressPayload = | |
| 3010 | progress?.payload && typeof progress.payload === "object" | |
| 3011 | ? (progress.payload as Record<string, unknown>) | |
| 3012 | : undefined; | |
| 3013 | const completedPayload = | |
| 3014 | completed?.payload && typeof completed.payload === "object" | |
| 3015 | ? (completed.payload as Record<string, unknown>) | |
| 3016 | : undefined; | |
| 3017 | ||
| 3018 | expect(progress?.summary).toBe("Typecheck mobile app"); | |
| 3019 | expect(progressPayload?.title).toBe("Typecheck mobile app"); | |
| 3020 | expect(completed?.summary).toBe("Task completed"); | |
| 3021 | expect(completedPayload?.title).toBe("Typecheck mobile app"); | |
| 3022 | expect(completedPayload?.summary).toBe("Typecheck finished without errors."); | |
| 3023 | expect(completedPayload?.detail).toBe("Typecheck finished without errors."); | |
| 3024 | }); | |
| 3025 | ||
| 3026 | it("titles task completion from task.started when no progress event carried the name", async () => { | |
| 3027 | const harness = await createHarness(); | |
| 3028 | const now = "2026-01-01T00:00:00.000Z"; | |
| 3029 | ||
| 3030 | harness.emit({ | |
| 3031 | type: "task.started", | |
| 3032 | eventId: asEventId("evt-fast-task-started"), | |
| 3033 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 3034 | createdAt: now, | |
| 3035 | threadId: asThreadId("thread-1"), | |
| 3036 | turnId: asTurnId("turn-fast-task"), | |
| 3037 | payload: { | |
| 3038 | taskId: "fast-task-1", | |
| 3039 | description: "wait for codex review to finish", | |
| 3040 | taskType: "local_bash", | |
| 3041 | }, | |
| 3042 | }); | |
| 3043 | ||
| 3044 | harness.emit({ | |
| 3045 | type: "task.completed", | |
| 3046 | eventId: asEventId("evt-fast-task-completed"), | |
| 3047 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 3048 | createdAt: now, | |
| 3049 | threadId: asThreadId("thread-1"), | |
| 3050 | turnId: asTurnId("turn-fast-task"), | |
| 3051 | payload: { | |
| 3052 | taskId: "fast-task-1", | |
| 3053 | status: "completed", | |
| 3054 | }, | |
| 3055 | }); | |
| 3056 | ||
| 3057 | const thread = await waitForThread(harness.readModel, (entry) => | |
| 3058 | entry.activities.some( | |
| 3059 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-fast-task-completed", | |
| 3060 | ), | |
| 3061 | ); | |
| 3062 | ||
| 3063 | const completed = thread.activities.find( | |
| 3064 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-fast-task-completed", | |
| 3065 | ); | |
| 3066 | const completedPayload = | |
| 3067 | completed?.payload && typeof completed.payload === "object" | |
| 3068 | ? (completed.payload as Record<string, unknown>) | |
| 3069 | : undefined; | |
| 3070 | ||
| 3071 | expect(completedPayload?.title).toBe("wait for codex review to finish"); | |
| 3072 | }); | |
| 3073 | ||
| 3074 | it("titles task completion from persisted activities after the description cache is swept", async () => { | |
| 3075 | const harness = await createHarness(); | |
| 3076 | const now = "2026-01-01T00:00:00.000Z"; | |
| 3077 | ||
| 3078 | harness.emit({ | |
| 3079 | type: "task.progress", | |
| 3080 | eventId: asEventId("evt-swept-task-progress"), | |
| 3081 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 3082 | createdAt: now, | |
| 3083 | threadId: asThreadId("thread-1"), | |
| 3084 | turnId: asTurnId("turn-swept-task"), | |
| 3085 | payload: { | |
| 3086 | taskId: "swept-task-1", | |
| 3087 | description: "Watch round-3 CI and bots", | |
| 3088 | summary: "Polling CI checks.", | |
| 3089 | }, | |
| 3090 | }); | |
| 3091 | ||
| 3092 | await waitForThread(harness.readModel, (entry) => | |
| 3093 | entry.activities.some( | |
| 3094 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-progress", | |
| 3095 | ), | |
| 3096 | ); | |
| 3097 | ||
| 3098 | // session.exited sweeps the in-memory description cache; the completion | |
| 3099 | // that follows must recover the name from persisted activities. | |
| 3100 | harness.emit({ | |
| 3101 | type: "session.exited", | |
| 3102 | eventId: asEventId("evt-swept-task-session-exited"), | |
| 3103 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 3104 | createdAt: now, | |
| 3105 | threadId: asThreadId("thread-1"), | |
| 3106 | payload: {}, | |
| 3107 | }); | |
| 3108 | ||
| 3109 | harness.emit({ | |
| 3110 | type: "task.completed", | |
| 3111 | eventId: asEventId("evt-swept-task-completed"), | |
| 3112 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 3113 | createdAt: now, | |
| 3114 | threadId: asThreadId("thread-1"), | |
| 3115 | turnId: asTurnId("turn-swept-task"), | |
| 3116 | payload: { | |
| 3117 | taskId: "swept-task-1", | |
| 3118 | status: "completed", | |
| 3119 | summary: "CI is green.", | |
| 3120 | }, | |
| 3121 | }); | |
| 3122 | ||
| 3123 | const thread = await waitForThread(harness.readModel, (entry) => | |
| 3124 | entry.activities.some( | |
| 3125 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", | |
| 3126 | ), | |
| 3127 | ); | |
| 3128 | ||
| 3129 | const completed = thread.activities.find( | |
| 3130 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", | |
| 3131 | ); | |
| 3132 | const completedPayload = | |
| 3133 | completed?.payload && typeof completed.payload === "object" | |
| 3134 | ? (completed.payload as Record<string, unknown>) | |
| 3135 | : undefined; | |
| 3136 | ||
| 3137 | expect(completedPayload?.title).toBe("Watch round-3 CI and bots"); | |
| 3138 | }); | |
| 3139 | ||
| 2950 | 3140 | it("projects structured user input request and resolution as thread activities", async () => { |
| 2951 | 3141 | const harness = await createHarness(); |
| 2952 | 3142 | const now = "2026-01-01T00:00:00.000Z"; |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+99-3| ... | ... | @@ -40,6 +40,42 @@ import { |
| 40 | 40 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 41 | 41 | |
| 42 | 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 | 80 | interface AssistantSegmentState { |
| 45 | 81 | baseKey: string; |
| ... | ... | @@ -53,6 +89,8 @@ const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; |
| 53 | 89 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); |
| 54 | 90 | const BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY = 10_000; |
| 55 | 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 | 94 | const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; |
| 57 | 95 | const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; |
| 58 | 96 | |
| ... | ... | @@ -264,6 +302,7 @@ function requestKindFromCanonicalRequestType( |
| 264 | 302 | |
| 265 | 303 | function runtimeEventToActivities( |
| 266 | 304 | event: ProviderRuntimeEvent, |
| 305 | taskTitle?: string, | |
| 267 | 306 | ): ReadonlyArray<OrchestrationThreadActivity> { |
| 268 | 307 | const maybeSequence = (() => { |
| 269 | 308 | const eventWithSequence = event as ProviderRuntimeEvent & { sessionSequence?: number }; |
| ... | ... | @@ -473,9 +512,15 @@ function runtimeEventToActivities( |
| 473 | 512 | createdAt: event.createdAt, |
| 474 | 513 | tone: "info", |
| 475 | 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 | 519 | payload: { |
| 478 | 520 | taskId: event.payload.taskId, |
| 521 | ...(event.payload.description.trim().length > 0 | |
| 522 | ? { title: truncateDetail(event.payload.description, 120) } | |
| 523 | : {}), | |
| 479 | 524 | detail: truncateDetail(event.payload.summary ?? event.payload.description), |
| 480 | 525 | ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), |
| 481 | 526 | ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), |
| ... | ... | @@ -503,7 +548,15 @@ function runtimeEventToActivities( |
| 503 | 548 | payload: { |
| 504 | 549 | taskId: event.payload.taskId, |
| 505 | 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 | 560 | ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), |
| 508 | 561 | }, |
| 509 | 562 | turnId: toTurnId(event.turnId) ?? null, |
| ... | ... | @@ -666,6 +719,27 @@ const make = Effect.gen(function* () { |
| 666 | 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 | 743 | const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { |
| 670 | 744 | return yield* projectionSnapshotQuery |
| 671 | 745 | .getThreadDetailById(threadId) |
| ... | ... | @@ -1090,6 +1164,7 @@ const make = Effect.gen(function* () { |
| 1090 | 1164 | const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); |
| 1091 | 1165 | const assistantSegmentKeys = Array.from(yield* Cache.keys(assistantSegmentStateByTurnKey)); |
| 1092 | 1166 | const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); |
| 1167 | const taskDescriptionKeys = Array.from(yield* Cache.keys(taskDescriptionByTaskKey)); | |
| 1093 | 1168 | yield* Effect.forEach( |
| 1094 | 1169 | turnKeys, |
| 1095 | 1170 | (key) => |
| ... | ... | @@ -1125,6 +1200,12 @@ const make = Effect.gen(function* () { |
| 1125 | 1200 | : Effect.void, |
| 1126 | 1201 | { concurrency: 1 }, |
| 1127 | 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 | 1211 | const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn( |
| ... | ... | @@ -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 | 1754 | yield* Effect.forEach(activities, (activity) => |
| 1659 | 1755 | providerCommandId(event, "thread-activity-append").pipe( |
| 1660 | 1756 | Effect.flatMap((commandId) => |