| author | |
| committer | |
| log | ddee418a8d6d3e242ca26a8053a886ecc3b56b53 |
| tree | 0cffe83bad79ecfdb8673cb1a233310c7e6e89db |
| parent | 2cb1a26f061fa9029ccbe2a614f02bb14b22dd45 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 164 insertions(+), 24 deletions(-)
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+2-13| ... | ... | @@ -39,6 +39,7 @@ import { |
| 39 | 39 | type ProviderCommandReactorShape, |
| 40 | 40 | } from "../Services/ProviderCommandReactor.ts"; |
| 41 | 41 | import { forkParked, ServerActivation } from "../../serverActivation.ts"; |
| 42 | import { canReplaceThreadTitle, DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; | |
| 42 | 43 | import { |
| 43 | 44 | resolveSourceControlWriterModelSelection, |
| 44 | 45 | ServerSettingsService, |
| ... | ... | @@ -91,7 +92,6 @@ const turnStartKeyForEvent = (event: ProviderIntentEvent): string => |
| 91 | 92 | const HANDLED_TURN_START_KEY_MAX = 10_000; |
| 92 | 93 | const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); |
| 93 | 94 | const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 94 | const DEFAULT_THREAD_TITLE = "New thread"; | |
| 95 | 95 | const MAX_REGENERATION_ATTACHMENTS = 4; |
| 96 | 96 | const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000; |
| 97 | 97 | const MAX_FIRST_USER_TITLE_CONTEXT_CHARS = 2_000; |
| ... | ... | @@ -227,18 +227,6 @@ export function providerErrorLabelFromInstanceHint(input: { |
| 227 | 227 | ); |
| 228 | 228 | } |
| 229 | 229 | |
| 230 | function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { | |
| 231 | const trimmedCurrentTitle = currentTitle.trim(); | |
| 232 | if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { | |
| 233 | return true; | |
| 234 | } | |
| 235 | ||
| 236 | const trimmedTitleSeed = titleSeed?.trim(); | |
| 237 | return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 | |
| 238 | ? trimmedCurrentTitle === trimmedTitleSeed | |
| 239 | : false; | |
| 240 | } | |
| 241 | ||
| 242 | 230 | function findProviderAdapterRequestError( |
| 243 | 231 | cause: Cause.Cause<ProviderServiceError>, |
| 244 | 232 | ): ProviderAdapterRequestError | undefined { |
| ... | ... | @@ -626,6 +614,7 @@ const make = Effect.gen(function* () { |
| 626 | 614 | ...(preferredProvider ? { provider: preferredProvider } : {}), |
| 627 | 615 | providerInstanceId: desiredInstanceId, |
| 628 | 616 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), |
| 617 | ...(thread.title ? { title: thread.title } : {}), | |
| 629 | 618 | modelSelection: desiredModelSelection, |
| 630 | 619 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), |
| 631 | 620 | runtimeMode: desiredRuntimeMode, |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+53-4| ... | ... | @@ -48,6 +48,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu |
| 48 | 48 | import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; |
| 49 | 49 | import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; |
| 50 | 50 | import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; |
| 51 | import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; | |
| 51 | 52 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 52 | 53 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 53 | 54 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| ... | ... | @@ -221,7 +222,10 @@ describe("ProviderRuntimeIngestion", () => { |
| 221 | 222 | } |
| 222 | 223 | }); |
| 223 | 224 | |
| 224 | async function createHarness(options?: { serverSettings?: Partial<ServerSettings> }) { | |
| 225 | async function createHarness(options?: { | |
| 226 | serverSettings?: Partial<ServerSettings>; | |
| 227 | threadTitle?: string; | |
| 228 | }) { | |
| 225 | 229 | const workspaceRoot = makeTempDir("t3-provider-project-"); |
| 226 | 230 | NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git")); |
| 227 | 231 | const provider = createProviderServiceHarness(); |
| ... | ... | @@ -277,7 +281,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 277 | 281 | commandId: CommandId.make("cmd-thread-create"), |
| 278 | 282 | threadId: ThreadId.make("thread-1"), |
| 279 | 283 | projectId: asProjectId("project-1"), |
| 280 | title: "Thread", | |
| 284 | title: options?.threadTitle ?? "Thread", | |
| 281 | 285 | modelSelection: { |
| 282 | 286 | instanceId: ProviderInstanceId.make("codex"), |
| 283 | 287 | model: "gpt-5-codex", |
| ... | ... | @@ -2915,7 +2919,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2915 | 2919 | const thread = await waitForThread( |
| 2916 | 2920 | harness.readModel, |
| 2917 | 2921 | (entry) => |
| 2918 | entry.title === "Renamed by provider" && | |
| 2922 | entry.title === "Thread" && | |
| 2919 | 2923 | entry.activities.some( |
| 2920 | 2924 | (activity: ProviderRuntimeTestActivity) => activity.kind === "turn.plan.updated", |
| 2921 | 2925 | ) && |
| ... | ... | @@ -2930,7 +2934,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 2930 | 2934 | ), |
| 2931 | 2935 | ); |
| 2932 | 2936 | |
| 2933 | expect(thread.title).toBe("Renamed by provider"); | |
| 2937 | expect(thread.title).toBe("Thread"); | |
| 2934 | 2938 | |
| 2935 | 2939 | const planActivity = thread.activities.find( |
| 2936 | 2940 | (activity: ProviderRuntimeTestActivity) => activity.id === "evt-turn-plan-updated", |
| ... | ... | @@ -2971,6 +2975,51 @@ describe("ProviderRuntimeIngestion", () => { |
| 2971 | 2975 | expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); |
| 2972 | 2976 | }); |
| 2973 | 2977 | |
| 2978 | it("mirrors a provider title only while the thread still has the default title", async () => { | |
| 2979 | const harness = await createHarness({ threadTitle: DEFAULT_THREAD_TITLE }); | |
| 2980 | const now = "2026-01-01T00:00:00.000Z"; | |
| 2981 | ||
| 2982 | harness.emit({ | |
| 2983 | type: "thread.metadata.updated", | |
| 2984 | eventId: asEventId("evt-thread-metadata-default"), | |
| 2985 | provider: ProviderDriverKind.make("codex"), | |
| 2986 | createdAt: now, | |
| 2987 | threadId: asThreadId("thread-1"), | |
| 2988 | payload: { | |
| 2989 | name: "Renamed by provider", | |
| 2990 | metadata: { source: "provider" }, | |
| 2991 | }, | |
| 2992 | }); | |
| 2993 | ||
| 2994 | const thread = await waitForThread( | |
| 2995 | harness.readModel, | |
| 2996 | (entry) => entry.title === "Renamed by provider", | |
| 2997 | ); | |
| 2998 | expect(thread.title).toBe("Renamed by provider"); | |
| 2999 | }); | |
| 3000 | ||
| 3001 | it("rejects a provider title once the thread has a real title", async () => { | |
| 3002 | const harness = await createHarness({ threadTitle: "User-set title" }); | |
| 3003 | const now = "2026-01-01T00:00:00.000Z"; | |
| 3004 | ||
| 3005 | harness.emit({ | |
| 3006 | type: "thread.metadata.updated", | |
| 3007 | eventId: asEventId("evt-thread-metadata-real"), | |
| 3008 | provider: ProviderDriverKind.make("codex"), | |
| 3009 | createdAt: now, | |
| 3010 | threadId: asThreadId("thread-1"), | |
| 3011 | payload: { | |
| 3012 | name: "Renamed by provider", | |
| 3013 | metadata: { source: "provider" }, | |
| 3014 | }, | |
| 3015 | }); | |
| 3016 | ||
| 3017 | await harness.drain(); | |
| 3018 | const readModel = await harness.readModel(); | |
| 3019 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | |
| 3020 | expect(thread?.title).toBe("User-set title"); | |
| 3021 | }); | |
| 3022 | ||
| 2974 | 3023 | it("projects context window updates into normalized thread activities", async () => { |
| 2975 | 3024 | const harness = await createHarness(); |
| 2976 | 3025 | const now = "2026-01-01T00:00:00.000Z"; |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+9-6| ... | ... | @@ -43,6 +43,7 @@ import { |
| 43 | 43 | } from "../Services/ProviderRuntimeIngestion.ts"; |
| 44 | 44 | import { forkParked } from "../../serverActivation.ts"; |
| 45 | 45 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 46 | import { canReplaceThreadTitle } from "../threadTitles.ts"; | |
| 46 | 47 | |
| 47 | 48 | const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; |
| 48 | 49 | const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; |
| ... | ... | @@ -1892,12 +1893,14 @@ const make = Effect.gen(function* () { |
| 1892 | 1893 | } |
| 1893 | 1894 | |
| 1894 | 1895 | if (event.type === "thread.metadata.updated" && event.payload.name) { |
| 1895 | yield* orchestrationEngine.dispatch({ | |
| 1896 | type: "thread.meta.update", | |
| 1897 | commandId: yield* providerCommandId(event, "thread-meta-update"), | |
| 1898 | threadId: thread.id, | |
| 1899 | title: event.payload.name, | |
| 1900 | }); | |
| 1896 | if (canReplaceThreadTitle(thread.title)) { | |
| 1897 | yield* orchestrationEngine.dispatch({ | |
| 1898 | type: "thread.meta.update", | |
| 1899 | commandId: yield* providerCommandId(event, "thread-meta-update"), | |
| 1900 | threadId: thread.id, | |
| 1901 | title: event.payload.name, | |
| 1902 | }); | |
| 1903 | } | |
| 1901 | 1904 | } |
| 1902 | 1905 | |
| 1903 | 1906 | if (event.type === "turn.diff.updated") { |
apps/server/src/orchestration/threadTitles.ts created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | export const DEFAULT_THREAD_TITLE = "New thread"; | |
| 2 | ||
| 3 | export function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { | |
| 4 | const trimmedCurrentTitle = currentTitle.trim(); | |
| 5 | if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { | |
| 6 | return true; | |
| 7 | } | |
| 8 | ||
| 9 | const trimmedTitleSeed = titleSeed?.trim(); | |
| 10 | return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 | |
| 11 | ? trimmedCurrentTitle === trimmedTitleSeed | |
| 12 | : false; | |
| 13 | } |
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+67| ... | ... | @@ -1191,6 +1191,73 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 1191 | 1191 | }), |
| 1192 | 1192 | ); |
| 1193 | 1193 | |
| 1194 | it.effect("passes the thread title to session.create when provided", () => | |
| 1195 | Effect.gen(function* () { | |
| 1196 | const adapter = yield* OpenCodeAdapter; | |
| 1197 | const threadId = asThreadId("thread-opencode-title-provided"); | |
| 1198 | ||
| 1199 | yield* adapter.startSession({ | |
| 1200 | provider: ProviderDriverKind.make("opencode"), | |
| 1201 | threadId, | |
| 1202 | runtimeMode: "full-access", | |
| 1203 | title: "Investigate reconnect failures", | |
| 1204 | }); | |
| 1205 | ||
| 1206 | NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1); | |
| 1207 | NodeAssert.equal( | |
| 1208 | runtimeMock.state.sessionCreateInputs[0]?.title, | |
| 1209 | "Investigate reconnect failures", | |
| 1210 | ); | |
| 1211 | }), | |
| 1212 | ); | |
| 1213 | ||
| 1214 | it.effect("does not mirror OpenCode's default placeholder session titles", () => | |
| 1215 | Effect.gen(function* () { | |
| 1216 | const adapter = yield* OpenCodeAdapter; | |
| 1217 | const threadId = asThreadId("thread-opencode-placeholder-title"); | |
| 1218 | runtimeMock.state.subscribedEvents = [ | |
| 1219 | { | |
| 1220 | type: "session.updated", | |
| 1221 | properties: { | |
| 1222 | info: { | |
| 1223 | id: "http://127.0.0.1:9999/session", | |
| 1224 | title: "New session - 2026-08-09T10:20:30.456Z", | |
| 1225 | }, | |
| 1226 | }, | |
| 1227 | }, | |
| 1228 | { | |
| 1229 | type: "session.updated", | |
| 1230 | properties: { | |
| 1231 | info: { | |
| 1232 | id: "http://127.0.0.1:9999/session", | |
| 1233 | title: "Investigate reconnect failures", | |
| 1234 | }, | |
| 1235 | }, | |
| 1236 | }, | |
| 1237 | ]; | |
| 1238 | ||
| 1239 | const eventsFiber = yield* adapter.streamEvents.pipe( | |
| 1240 | Stream.filter((event) => event.threadId === threadId), | |
| 1241 | Stream.take(3), | |
| 1242 | Stream.runCollect, | |
| 1243 | Effect.forkChild, | |
| 1244 | ); | |
| 1245 | ||
| 1246 | yield* adapter.startSession({ | |
| 1247 | provider: ProviderDriverKind.make("opencode"), | |
| 1248 | threadId, | |
| 1249 | runtimeMode: "full-access", | |
| 1250 | }); | |
| 1251 | ||
| 1252 | const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); | |
| 1253 | const metadataUpdated = events.filter((event) => event.type === "thread.metadata.updated"); | |
| 1254 | NodeAssert.equal(metadataUpdated.length, 1); | |
| 1255 | if (metadataUpdated[0]?.type === "thread.metadata.updated") { | |
| 1256 | NodeAssert.equal(metadataUpdated[0].payload.name, "Investigate reconnect failures"); | |
| 1257 | } | |
| 1258 | }), | |
| 1259 | ); | |
| 1260 | ||
| 1194 | 1261 | it.effect("writes provider-native observability records using the session thread id", () => |
| 1195 | 1262 | Effect.gen(function* () { |
| 1196 | 1263 | const nativeEvents: Array<{ |
apps/server/src/provider/Layers/OpenCodeAdapter.ts+19-1| ... | ... | @@ -201,7 +201,24 @@ function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | und |
| 201 | 201 | return undefined; |
| 202 | 202 | } |
| 203 | 203 | |
| 204 | return trimText(event.properties.info.title); | |
| 204 | const title = trimText(event.properties.info.title); | |
| 205 | // OpenCode mints a placeholder title at session.create when no title was | |
| 206 | // provided, and re-emits it on every `session.updated`. Mirroring it would | |
| 207 | // overwrite the thread's real title (openCodeEventSessionTitle feeds the | |
| 208 | // `thread.metadata.updated` mirror). Ignore OpenCode's auto-generated | |
| 209 | // placeholders so the thread isn't locked onto them. | |
| 210 | if (!title || isOpenCodeDefaultTitle(title)) { | |
| 211 | return undefined; | |
| 212 | } | |
| 213 | ||
| 214 | return title; | |
| 215 | } | |
| 216 | ||
| 217 | const OPENCODE_DEFAULT_TITLE_PATTERN = | |
| 218 | /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; | |
| 219 | ||
| 220 | function isOpenCodeDefaultTitle(title: string): boolean { | |
| 221 | return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); | |
| 205 | 222 | } |
| 206 | 223 | |
| 207 | 224 | interface OpenCodeSessionContext { |
| ... | ... | @@ -1302,6 +1319,7 @@ export function makeOpenCodeAdapter( |
| 1302 | 1319 | } |
| 1303 | 1320 | const createdSession = yield* runOpenCodeSdk("session.create", () => |
| 1304 | 1321 | client.session.create({ |
| 1322 | ...(input.title ? { title: input.title } : {}), | |
| 1305 | 1323 | permission: buildOpenCodePermissionRules(input.runtimeMode), |
| 1306 | 1324 | }), |
| 1307 | 1325 | ); |
packages/contracts/src/provider.ts+1| ... | ... | @@ -56,6 +56,7 @@ export const ProviderSessionStartInput = Schema.Struct({ |
| 56 | 56 | // See ProviderSession for the migration story. |
| 57 | 57 | providerInstanceId: Schema.optional(ProviderInstanceId), |
| 58 | 58 | cwd: Schema.optional(TrimmedNonEmptyString), |
| 59 | title: Schema.optional(TrimmedNonEmptyString), | |
| 59 | 60 | modelSelection: Schema.optional(ModelSelection), |
| 60 | 61 | resumeCursor: Schema.optional(Schema.Unknown), |
| 61 | 62 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), |