| author | |
| committer | |
| log | 5e55aa387f4d33209f55dc6ec00cb590ae7cbc2a |
| tree | 606e9d0c17741c18327b1210d61b569c232c1baa |
| parent | a21095bc9ae48f9555f6344a97834528ec6891af |
- Generate and rename temporary `t3code/*` worktree branches on first user turn in `ProviderCommandReactor`
- Remove websocket/client branch-generation RPCs and related contract/API surface
- Update Codex text-generation prompt to include attachment metadata only when attachments exist, with expanded tests12 files changed, 405 insertions(+), 264 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+17| ... | ... | @@ -23,6 +23,8 @@ import { |
| 23 | 23 | |
| 24 | 24 | import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; |
| 25 | 25 | import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; |
| 26 | import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts"; | |
| 27 | import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; | |
| 26 | 28 | import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 27 | 29 | import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; |
| 28 | 30 | import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; |
| ... | ... | @@ -227,8 +229,23 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { |
| 227 | 229 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
| 228 | 230 | Layer.provideMerge(runtimeServicesLayer), |
| 229 | 231 | ); |
| 232 | const gitCoreLayer = Layer.succeed( | |
| 233 | GitCore, | |
| 234 | { | |
| 235 | renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) => | |
| 236 | Effect.succeed({ branch: input.newBranch }), | |
| 237 | } as unknown as GitCoreShape, | |
| 238 | ); | |
| 239 | const textGenerationLayer = Layer.succeed( | |
| 240 | TextGeneration, | |
| 241 | { | |
| 242 | generateBranchName: () => Effect.succeed({ branch: null }), | |
| 243 | } as unknown as TextGenerationShape, | |
| 244 | ); | |
| 230 | 245 | const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( |
| 231 | 246 | Layer.provideMerge(runtimeServicesLayer), |
| 247 | Layer.provideMerge(gitCoreLayer), | |
| 248 | Layer.provideMerge(textGenerationLayer), | |
| 232 | 249 | ); |
| 233 | 250 | const checkpointReactorLayer = CheckpointReactorLive.pipe( |
| 234 | 251 | Layer.provideMerge(runtimeServicesLayer), |
apps/server/src/git/Layers/CodexTextGeneration.test.ts+66-1| ... | ... | @@ -38,7 +38,19 @@ function makeFakeCodexBinary(dir: string) { |
| 38 | 38 | ' printf "%s\\n" "missing --image input" >&2', |
| 39 | 39 | " exit 2", |
| 40 | 40 | "fi", |
| 41 | "cat >/dev/null", | |
| 41 | 'stdin_content="$(cat)"', | |
| 42 | 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then', | |
| 43 | ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {', | |
| 44 | ' printf "%s\\n" "stdin missing expected content" >&2', | |
| 45 | " exit 3", | |
| 46 | " }", | |
| 47 | "fi", | |
| 48 | 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" ]; then', | |
| 49 | ' if printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" >/dev/null; then', | |
| 50 | ' printf "%s\\n" "stdin contained forbidden content" >&2', | |
| 51 | " exit 4", | |
| 52 | " fi", | |
| 53 | "fi", | |
| 42 | 54 | 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then', |
| 43 | 55 | ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2', |
| 44 | 56 | "fi", |
| ... | ... | @@ -60,6 +72,8 @@ function withFakeCodexEnv<A, E, R>( |
| 60 | 72 | exitCode?: number; |
| 61 | 73 | stderr?: string; |
| 62 | 74 | requireImage?: boolean; |
| 75 | stdinMustContain?: string; | |
| 76 | stdinMustNotContain?: string; | |
| 63 | 77 | }, |
| 64 | 78 | effect: Effect.Effect<A, E, R>, |
| 65 | 79 | ) { |
| ... | ... | @@ -73,6 +87,8 @@ function withFakeCodexEnv<A, E, R>( |
| 73 | 87 | const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; |
| 74 | 88 | const previousStderr = process.env.T3_FAKE_CODEX_STDERR; |
| 75 | 89 | const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; |
| 90 | const previousStdinMustContain = process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; | |
| 91 | const previousStdinMustNotContain = process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; | |
| 76 | 92 | |
| 77 | 93 | yield* Effect.sync(() => { |
| 78 | 94 | process.env.PATH = `${binDir}:${previousPath ?? ""}`; |
| ... | ... | @@ -95,6 +111,18 @@ function withFakeCodexEnv<A, E, R>( |
| 95 | 111 | } else { |
| 96 | 112 | delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; |
| 97 | 113 | } |
| 114 | ||
| 115 | if (input.stdinMustContain !== undefined) { | |
| 116 | process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = input.stdinMustContain; | |
| 117 | } else { | |
| 118 | delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; | |
| 119 | } | |
| 120 | ||
| 121 | if (input.stdinMustNotContain !== undefined) { | |
| 122 | process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = input.stdinMustNotContain; | |
| 123 | } else { | |
| 124 | delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; | |
| 125 | } | |
| 98 | 126 | }); |
| 99 | 127 | |
| 100 | 128 | return { |
| ... | ... | @@ -103,6 +131,8 @@ function withFakeCodexEnv<A, E, R>( |
| 103 | 131 | previousExitCode, |
| 104 | 132 | previousStderr, |
| 105 | 133 | previousRequireImage, |
| 134 | previousStdinMustContain, | |
| 135 | previousStdinMustNotContain, | |
| 106 | 136 | }; |
| 107 | 137 | }), |
| 108 | 138 | () => effect, |
| ... | ... | @@ -133,6 +163,18 @@ function withFakeCodexEnv<A, E, R>( |
| 133 | 163 | } else { |
| 134 | 164 | process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage; |
| 135 | 165 | } |
| 166 | ||
| 167 | if (previous.previousStdinMustContain === undefined) { | |
| 168 | delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; | |
| 169 | } else { | |
| 170 | process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = previous.previousStdinMustContain; | |
| 171 | } | |
| 172 | ||
| 173 | if (previous.previousStdinMustNotContain === undefined) { | |
| 174 | delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; | |
| 175 | } else { | |
| 176 | process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = previous.previousStdinMustNotContain; | |
| 177 | } | |
| 136 | 178 | }), |
| 137 | 179 | ); |
| 138 | 180 | } |
| ... | ... | @@ -202,6 +244,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 202 | 244 | output: JSON.stringify({ |
| 203 | 245 | branch: " Feat/Session ", |
| 204 | 246 | }), |
| 247 | stdinMustNotContain: "Image attachments supplied to the model", | |
| 205 | 248 | }, |
| 206 | 249 | Effect.gen(function* () { |
| 207 | 250 | const textGeneration = yield* TextGeneration; |
| ... | ... | @@ -216,6 +259,27 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 216 | 259 | ), |
| 217 | 260 | ); |
| 218 | 261 | |
| 262 | it.effect("omits attachment metadata section when no attachments are provided", () => | |
| 263 | withFakeCodexEnv( | |
| 264 | { | |
| 265 | output: JSON.stringify({ | |
| 266 | branch: "fix/session-timeout", | |
| 267 | }), | |
| 268 | stdinMustNotContain: "Attachment metadata:", | |
| 269 | }, | |
| 270 | Effect.gen(function* () { | |
| 271 | const textGeneration = yield* TextGeneration; | |
| 272 | ||
| 273 | const generated = yield* textGeneration.generateBranchName({ | |
| 274 | cwd: process.cwd(), | |
| 275 | message: "Fix timeout behavior.", | |
| 276 | }); | |
| 277 | ||
| 278 | expect(generated.branch).toBe("fix/session-timeout"); | |
| 279 | }), | |
| 280 | ), | |
| 281 | ); | |
| 282 | ||
| 219 | 283 | it.effect("passes image attachments through as codex image inputs", () => |
| 220 | 284 | withFakeCodexEnv( |
| 221 | 285 | { |
| ... | ... | @@ -223,6 +287,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 223 | 287 | branch: "fix/ui-regression", |
| 224 | 288 | }), |
| 225 | 289 | requireImage: true, |
| 290 | stdinMustContain: "Attachment metadata:", | |
| 226 | 291 | }, |
| 227 | 292 | Effect.gen(function* () { |
| 228 | 293 | const textGeneration = yield* TextGeneration; |
apps/server/src/git/Layers/CodexTextGeneration.ts+10-7| ... | ... | @@ -503,7 +503,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 503 | 503 | (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, |
| 504 | 504 | ); |
| 505 | 505 | |
| 506 | const prompt = [ | |
| 506 | const promptSections = [ | |
| 507 | 507 | "You generate concise git branch names.", |
| 508 | 508 | "Return a JSON object with key: branch.", |
| 509 | 509 | "Rules:", |
| ... | ... | @@ -512,14 +512,17 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 512 | 512 | "- Use plain words only, no issue prefixes and no punctuation-heavy text.", |
| 513 | 513 | "- If images are attached, use them as primary context for visual/UI issues.", |
| 514 | 514 | "", |
| 515 | `Image attachments supplied to the model: ${imagePaths.length}`, | |
| 516 | "", | |
| 517 | 515 | "User message:", |
| 518 | 516 | limitSection(input.message, 8_000), |
| 519 | "", | |
| 520 | "Attachment metadata:", | |
| 521 | attachmentLines.length > 0 ? limitSection(attachmentLines.join("\n"), 4_000) : "- none", | |
| 522 | ].join("\n"); | |
| 517 | ]; | |
| 518 | if (attachmentLines.length > 0) { | |
| 519 | promptSections.push( | |
| 520 | "", | |
| 521 | "Attachment metadata:", | |
| 522 | limitSection(attachmentLines.join("\n"), 4_000), | |
| 523 | ); | |
| 524 | } | |
| 525 | const prompt = promptSections.join("\n"); | |
| 523 | 526 | |
| 524 | 527 | const generated = yield* runCodexJson({ |
| 525 | 528 | operation: "generateBranchName", |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+189| ... | ... | @@ -20,6 +20,8 @@ import { |
| 20 | 20 | ProviderService, |
| 21 | 21 | type ProviderServiceShape, |
| 22 | 22 | } from "../../provider/Services/ProviderService.ts"; |
| 23 | import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; | |
| 24 | import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; | |
| 23 | 25 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 24 | 26 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 25 | 27 | import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; |
| ... | ... | @@ -92,6 +94,16 @@ describe("ProviderCommandReactor", () => { |
| 92 | 94 | const interruptTurn = vi.fn((_: unknown) => Effect.void); |
| 93 | 95 | const respondToRequest = vi.fn((_: unknown) => Effect.void); |
| 94 | 96 | const stopSession = vi.fn((_: unknown) => Effect.void); |
| 97 | const renameBranch = vi.fn((_: unknown) => | |
| 98 | Effect.succeed({ | |
| 99 | branch: "t3code/generated-name", | |
| 100 | }), | |
| 101 | ); | |
| 102 | const generateBranchName = vi.fn((_: unknown) => | |
| 103 | Effect.succeed({ | |
| 104 | branch: "generated-name" as string | null, | |
| 105 | }), | |
| 106 | ); | |
| 95 | 107 | |
| 96 | 108 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; |
| 97 | 109 | const service: ProviderServiceShape = { |
| ... | ... | @@ -115,6 +127,13 @@ describe("ProviderCommandReactor", () => { |
| 115 | 127 | const layer = ProviderCommandReactorLive.pipe( |
| 116 | 128 | Layer.provideMerge(orchestrationLayer), |
| 117 | 129 | Layer.provideMerge(Layer.succeed(ProviderService, service)), |
| 130 | Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), | |
| 131 | Layer.provideMerge( | |
| 132 | Layer.succeed( | |
| 133 | TextGeneration, | |
| 134 | { generateBranchName } as unknown as TextGenerationShape, | |
| 135 | ), | |
| 136 | ), | |
| 118 | 137 | ); |
| 119 | 138 | runtime = ManagedRuntime.make(layer); |
| 120 | 139 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); |
| ... | ... | @@ -155,6 +174,8 @@ describe("ProviderCommandReactor", () => { |
| 155 | 174 | interruptTurn, |
| 156 | 175 | respondToRequest, |
| 157 | 176 | stopSession, |
| 177 | renameBranch, | |
| 178 | generateBranchName, | |
| 158 | 179 | }; |
| 159 | 180 | } |
| 160 | 181 | |
| ... | ... | @@ -196,6 +217,174 @@ describe("ProviderCommandReactor", () => { |
| 196 | 217 | expect(thread?.session?.sandboxMode).toBe("workspace-write"); |
| 197 | 218 | }); |
| 198 | 219 | |
| 220 | it("generates and renames temporary worktree branch on first turn", async () => { | |
| 221 | const harness = await createHarness(); | |
| 222 | const now = new Date().toISOString(); | |
| 223 | ||
| 224 | await Effect.runPromise( | |
| 225 | harness.engine.dispatch({ | |
| 226 | type: "thread.meta.update", | |
| 227 | commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch"), | |
| 228 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 229 | branch: "t3code/89abc123", | |
| 230 | worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", | |
| 231 | }), | |
| 232 | ); | |
| 233 | ||
| 234 | await Effect.runPromise( | |
| 235 | harness.engine.dispatch({ | |
| 236 | type: "thread.turn.start", | |
| 237 | commandId: CommandId.makeUnsafe("cmd-turn-start-worktree-rename"), | |
| 238 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 239 | message: { | |
| 240 | messageId: asMessageId("user-message-worktree-rename"), | |
| 241 | role: "user", | |
| 242 | text: "Fix visual bug from screenshot", | |
| 243 | attachments: [ | |
| 244 | { | |
| 245 | type: "image", | |
| 246 | name: "bug.png", | |
| 247 | mimeType: "image/png", | |
| 248 | sizeBytes: 5, | |
| 249 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 250 | }, | |
| 251 | ], | |
| 252 | }, | |
| 253 | approvalPolicy: "on-request", | |
| 254 | sandboxMode: "workspace-write", | |
| 255 | createdAt: now, | |
| 256 | }), | |
| 257 | ); | |
| 258 | ||
| 259 | await waitFor(() => harness.generateBranchName.mock.calls.length === 1); | |
| 260 | await waitFor(() => harness.renameBranch.mock.calls.length === 1); | |
| 261 | ||
| 262 | expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({ | |
| 263 | cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", | |
| 264 | message: "Fix visual bug from screenshot", | |
| 265 | attachments: [ | |
| 266 | { | |
| 267 | type: "image", | |
| 268 | name: "bug.png", | |
| 269 | mimeType: "image/png", | |
| 270 | sizeBytes: 5, | |
| 271 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 272 | }, | |
| 273 | ], | |
| 274 | }); | |
| 275 | expect(harness.renameBranch.mock.calls[0]?.[0]).toEqual({ | |
| 276 | cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", | |
| 277 | oldBranch: "t3code/89abc123", | |
| 278 | newBranch: "t3code/generated-name", | |
| 279 | }); | |
| 280 | ||
| 281 | await waitFor(() => { | |
| 282 | const readModel = Effect.runSync(harness.engine.getReadModel()); | |
| 283 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 284 | return thread?.branch === "t3code/generated-name"; | |
| 285 | }); | |
| 286 | }); | |
| 287 | ||
| 288 | it("skips worktree branch generation after the first user turn", async () => { | |
| 289 | const harness = await createHarness(); | |
| 290 | const now = new Date().toISOString(); | |
| 291 | ||
| 292 | await Effect.runPromise( | |
| 293 | harness.engine.dispatch({ | |
| 294 | type: "thread.meta.update", | |
| 295 | commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-2"), | |
| 296 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 297 | branch: "t3code/1234abcd", | |
| 298 | worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-1234abcd", | |
| 299 | }), | |
| 300 | ); | |
| 301 | ||
| 302 | await Effect.runPromise( | |
| 303 | harness.engine.dispatch({ | |
| 304 | type: "thread.turn.start", | |
| 305 | commandId: CommandId.makeUnsafe("cmd-turn-start-first"), | |
| 306 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 307 | message: { | |
| 308 | messageId: asMessageId("user-message-first"), | |
| 309 | role: "user", | |
| 310 | text: "first", | |
| 311 | attachments: [], | |
| 312 | }, | |
| 313 | approvalPolicy: "on-request", | |
| 314 | sandboxMode: "workspace-write", | |
| 315 | createdAt: now, | |
| 316 | }), | |
| 317 | ); | |
| 318 | ||
| 319 | await waitFor(() => harness.generateBranchName.mock.calls.length === 1); | |
| 320 | ||
| 321 | await Effect.runPromise( | |
| 322 | harness.engine.dispatch({ | |
| 323 | type: "thread.turn.start", | |
| 324 | commandId: CommandId.makeUnsafe("cmd-turn-start-second"), | |
| 325 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 326 | message: { | |
| 327 | messageId: asMessageId("user-message-second"), | |
| 328 | role: "user", | |
| 329 | text: "second", | |
| 330 | attachments: [], | |
| 331 | }, | |
| 332 | approvalPolicy: "on-request", | |
| 333 | sandboxMode: "workspace-write", | |
| 334 | createdAt: now, | |
| 335 | }), | |
| 336 | ); | |
| 337 | ||
| 338 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); | |
| 339 | expect(harness.generateBranchName.mock.calls.length).toBe(1); | |
| 340 | expect(harness.renameBranch.mock.calls.length).toBe(1); | |
| 341 | }); | |
| 342 | ||
| 343 | it("skips worktree rename when generated branch is null", async () => { | |
| 344 | const harness = await createHarness(); | |
| 345 | const now = new Date().toISOString(); | |
| 346 | harness.generateBranchName.mockImplementationOnce((_: unknown) => | |
| 347 | Effect.succeed({ | |
| 348 | branch: null, | |
| 349 | }), | |
| 350 | ); | |
| 351 | ||
| 352 | await Effect.runPromise( | |
| 353 | harness.engine.dispatch({ | |
| 354 | type: "thread.meta.update", | |
| 355 | commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-null"), | |
| 356 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 357 | branch: "t3code/0000abcd", | |
| 358 | worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-0000abcd", | |
| 359 | }), | |
| 360 | ); | |
| 361 | ||
| 362 | await Effect.runPromise( | |
| 363 | harness.engine.dispatch({ | |
| 364 | type: "thread.turn.start", | |
| 365 | commandId: CommandId.makeUnsafe("cmd-turn-start-null-branch"), | |
| 366 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 367 | message: { | |
| 368 | messageId: asMessageId("user-message-null-branch"), | |
| 369 | role: "user", | |
| 370 | text: "Fix visual regression", | |
| 371 | attachments: [], | |
| 372 | }, | |
| 373 | approvalPolicy: "on-request", | |
| 374 | sandboxMode: "workspace-write", | |
| 375 | createdAt: now, | |
| 376 | }), | |
| 377 | ); | |
| 378 | ||
| 379 | await waitFor(() => harness.generateBranchName.mock.calls.length === 1); | |
| 380 | await Effect.runPromise(Effect.sleep("20 millis")); | |
| 381 | expect(harness.renameBranch.mock.calls.length).toBe(0); | |
| 382 | ||
| 383 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 384 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 385 | expect(thread?.branch).toBe("t3code/0000abcd"); | |
| 386 | }); | |
| 387 | ||
| 199 | 388 | it("reuses the same provider session when runtime mode is unchanged", async () => { |
| 200 | 389 | const harness = await createHarness(); |
| 201 | 390 | const now = new Date().toISOString(); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+117| ... | ... | @@ -15,6 +15,8 @@ import { |
| 15 | 15 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; |
| 16 | 16 | |
| 17 | 17 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| 18 | import { GitCore } from "../../git/Services/GitCore.ts"; | |
| 19 | import { TextGeneration } from "../../git/Services/TextGeneration.ts"; | |
| 18 | 20 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 19 | 21 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 20 | 22 | import { |
| ... | ... | @@ -66,10 +68,41 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; |
| 66 | 68 | const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); |
| 67 | 69 | const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never"; |
| 68 | 70 | const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write"; |
| 71 | const WORKTREE_BRANCH_PREFIX = "t3code"; | |
| 72 | const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); | |
| 73 | ||
| 74 | function isTemporaryWorktreeBranch(branch: string): boolean { | |
| 75 | return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); | |
| 76 | } | |
| 77 | ||
| 78 | function buildGeneratedWorktreeBranchName(raw: string): string { | |
| 79 | const normalized = raw | |
| 80 | .trim() | |
| 81 | .toLowerCase() | |
| 82 | .replace(/^refs\/heads\//, "") | |
| 83 | .replace(/['"`]/g, ""); | |
| 84 | ||
| 85 | const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`) | |
| 86 | ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length) | |
| 87 | : normalized; | |
| 88 | ||
| 89 | const branchFragment = withoutPrefix | |
| 90 | .replace(/[^a-z0-9/_-]+/g, "-") | |
| 91 | .replace(/\/+/g, "/") | |
| 92 | .replace(/-+/g, "-") | |
| 93 | .replace(/^[./_-]+|[./_-]+$/g, "") | |
| 94 | .slice(0, 64) | |
| 95 | .replace(/[./_-]+$/g, ""); | |
| 96 | ||
| 97 | const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; | |
| 98 | return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; | |
| 99 | } | |
| 69 | 100 | |
| 70 | 101 | const make = Effect.gen(function* () { |
| 71 | 102 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 72 | 103 | const providerService = yield* ProviderService; |
| 104 | const git = yield* GitCore; | |
| 105 | const textGeneration = yield* TextGeneration; | |
| 73 | 106 | const handledTurnStartKeys = yield* Cache.make<string, true>({ |
| 74 | 107 | capacity: HANDLED_TURN_START_KEY_MAX, |
| 75 | 108 | timeToLive: HANDLED_TURN_START_KEY_TTL, |
| ... | ... | @@ -247,6 +280,81 @@ const make = Effect.gen(function* () { |
| 247 | 280 | }); |
| 248 | 281 | }); |
| 249 | 282 | |
| 283 | const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: { | |
| 284 | readonly threadId: ThreadId; | |
| 285 | readonly branch: string | null; | |
| 286 | readonly worktreePath: string | null; | |
| 287 | readonly messageId: string; | |
| 288 | readonly messageText: string; | |
| 289 | readonly attachments?: ReadonlyArray<ChatAttachment>; | |
| 290 | }) { | |
| 291 | if (!input.branch || !input.worktreePath) { | |
| 292 | return; | |
| 293 | } | |
| 294 | if (!isTemporaryWorktreeBranch(input.branch)) { | |
| 295 | return; | |
| 296 | } | |
| 297 | ||
| 298 | const thread = yield* resolveThread(input.threadId); | |
| 299 | if (!thread) { | |
| 300 | return; | |
| 301 | } | |
| 302 | ||
| 303 | const userMessages = thread.messages.filter((message) => message.role === "user"); | |
| 304 | if (userMessages.length !== 1 || userMessages[0]?.id !== input.messageId) { | |
| 305 | return; | |
| 306 | } | |
| 307 | ||
| 308 | const oldBranch = input.branch; | |
| 309 | const cwd = input.worktreePath; | |
| 310 | const attachments = input.attachments ?? []; | |
| 311 | const renameEffect = textGeneration | |
| 312 | .generateBranchName({ | |
| 313 | cwd, | |
| 314 | message: input.messageText, | |
| 315 | ...(attachments.length > 0 ? { attachments } : {}), | |
| 316 | }) | |
| 317 | .pipe( | |
| 318 | Effect.flatMap((generated) => { | |
| 319 | if (!generated.branch) { | |
| 320 | return Effect.void; | |
| 321 | } | |
| 322 | const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); | |
| 323 | if (targetBranch === oldBranch) { | |
| 324 | return Effect.void; | |
| 325 | } | |
| 326 | return git | |
| 327 | .renameBranch({ | |
| 328 | cwd, | |
| 329 | oldBranch, | |
| 330 | newBranch: targetBranch, | |
| 331 | }) | |
| 332 | .pipe( | |
| 333 | Effect.flatMap((renamed) => | |
| 334 | orchestrationEngine.dispatch({ | |
| 335 | type: "thread.meta.update", | |
| 336 | commandId: serverCommandId("worktree-branch-rename"), | |
| 337 | threadId: input.threadId, | |
| 338 | branch: renamed.branch, | |
| 339 | worktreePath: cwd, | |
| 340 | }), | |
| 341 | ), | |
| 342 | Effect.asVoid, | |
| 343 | ); | |
| 344 | }), | |
| 345 | Effect.catchCause((cause) => | |
| 346 | Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { | |
| 347 | threadId: input.threadId, | |
| 348 | cwd, | |
| 349 | oldBranch, | |
| 350 | cause: Cause.pretty(cause), | |
| 351 | }), | |
| 352 | ), | |
| 353 | ); | |
| 354 | ||
| 355 | yield* Effect.forkScoped(renameEffect); | |
| 356 | }); | |
| 357 | ||
| 250 | 358 | const processTurnStartRequested = Effect.fnUntraced(function* ( |
| 251 | 359 | event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>, |
| 252 | 360 | ) { |
| ... | ... | @@ -273,6 +381,15 @@ const make = Effect.gen(function* () { |
| 273 | 381 | return; |
| 274 | 382 | } |
| 275 | 383 | |
| 384 | yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ | |
| 385 | threadId: event.payload.threadId, | |
| 386 | branch: thread.branch, | |
| 387 | worktreePath: thread.worktreePath, | |
| 388 | messageId: message.id, | |
| 389 | messageText: message.text, | |
| 390 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), | |
| 391 | }); | |
| 392 | ||
| 276 | 393 | yield* sendTurnForThread({ |
| 277 | 394 | threadId: event.payload.threadId, |
| 278 | 395 | messageText: message.text, |
apps/server/src/serverLayers.ts+6-3| ... | ... | @@ -56,6 +56,9 @@ export function makeServerProviderLayer(): Layer.Layer< |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | 58 | export function makeServerRuntimeServicesLayer() { |
| 59 | const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); | |
| 60 | const textGenerationLayer = CodexTextGenerationLive; | |
| 61 | ||
| 59 | 62 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 60 | 63 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 61 | 64 | Layer.provide(OrchestrationEventStoreLive), |
| ... | ... | @@ -78,6 +81,8 @@ export function makeServerRuntimeServicesLayer() { |
| 78 | 81 | ); |
| 79 | 82 | const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( |
| 80 | 83 | Layer.provideMerge(runtimeServicesLayer), |
| 84 | Layer.provideMerge(gitCoreLayer), | |
| 85 | Layer.provideMerge(textGenerationLayer), | |
| 81 | 86 | ); |
| 82 | 87 | const checkpointReactorLayer = CheckpointReactorLive.pipe( |
| 83 | 88 | Layer.provideMerge(runtimeServicesLayer), |
| ... | ... | @@ -92,12 +97,10 @@ export function makeServerRuntimeServicesLayer() { |
| 92 | 97 | Layer.provide(typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive), |
| 93 | 98 | ); |
| 94 | 99 | |
| 95 | const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); | |
| 96 | ||
| 97 | 100 | const gitManagerLayer = GitManagerLive.pipe( |
| 98 | 101 | Layer.provideMerge(gitCoreLayer), |
| 99 | 102 | Layer.provideMerge(GitHubCliLive), |
| 100 | Layer.provideMerge(CodexTextGenerationLive), | |
| 103 | Layer.provideMerge(textGenerationLayer), | |
| 101 | 104 | ); |
| 102 | 105 | |
| 103 | 106 | return Layer.mergeAll( |
apps/server/src/wsServer.test.ts-110| ... | ... | @@ -46,7 +46,6 @@ import { Open, type OpenShape } from "./open"; |
| 46 | 46 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; |
| 47 | 47 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; |
| 48 | 48 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 49 | import { TextGeneration, type TextGenerationShape } from "./git/Services/TextGeneration.ts"; | |
| 50 | 49 | import { GitCommandError, GitManagerError } from "./git/Errors.ts"; |
| 51 | 50 | import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; |
| 52 | 51 | |
| ... | ... | @@ -374,7 +373,6 @@ describe("WebSocket Server", () => { |
| 374 | 373 | GitCoreShape, |
| 375 | 374 | "listBranches" | "initRepo" | "pullCurrentBranch" | "renameBranch" |
| 376 | 375 | >; |
| 377 | textGeneration?: Pick<TextGenerationShape, "generateBranchName">; | |
| 378 | 376 | terminalManager?: TerminalManagerShape; |
| 379 | 377 | } = {}, |
| 380 | 378 | ): Promise<Http.Server> { |
| ... | ... | @@ -407,9 +405,6 @@ describe("WebSocket Server", () => { |
| 407 | 405 | options.gitCore |
| 408 | 406 | ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) |
| 409 | 407 | : Layer.empty, |
| 410 | options.textGeneration | |
| 411 | ? Layer.succeed(TextGeneration, options.textGeneration as unknown as TextGenerationShape) | |
| 412 | : Layer.empty, | |
| 413 | 408 | options.terminalManager |
| 414 | 409 | ? Layer.succeed(TerminalManager, options.terminalManager) |
| 415 | 410 | : Layer.empty, |
| ... | ... | @@ -1477,7 +1472,6 @@ describe("WebSocket Server", () => { |
| 1477 | 1472 | ); |
| 1478 | 1473 | const initRepo = vi.fn(() => Effect.void); |
| 1479 | 1474 | const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" })); |
| 1480 | const generateBranchName = vi.fn(() => Effect.succeed({ branch: "new-name" as const })); | |
| 1481 | 1475 | const pullCurrentBranch = vi.fn(() => |
| 1482 | 1476 | Effect.fail( |
| 1483 | 1477 | new GitCommandError({ |
| ... | ... | @@ -1497,9 +1491,6 @@ describe("WebSocket Server", () => { |
| 1497 | 1491 | renameBranch, |
| 1498 | 1492 | pullCurrentBranch, |
| 1499 | 1493 | }, |
| 1500 | textGeneration: { | |
| 1501 | generateBranchName, | |
| 1502 | }, | |
| 1503 | 1494 | }); |
| 1504 | 1495 | const addr = server.address(); |
| 1505 | 1496 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| ... | ... | @@ -1522,61 +1513,6 @@ describe("WebSocket Server", () => { |
| 1522 | 1513 | expect(pullResponse.error?.message).toContain("No upstream configured"); |
| 1523 | 1514 | expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); |
| 1524 | 1515 | |
| 1525 | const generatedBranchResponse = await sendRequest(ws, WS_METHODS.gitGenerateBranchName, { | |
| 1526 | cwd: "/repo/path", | |
| 1527 | message: "Add retry logic for websocket reconnects", | |
| 1528 | attachments: [ | |
| 1529 | { | |
| 1530 | type: "image", | |
| 1531 | name: "bug.png", | |
| 1532 | mimeType: "image/png", | |
| 1533 | sizeBytes: 5, | |
| 1534 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 1535 | }, | |
| 1536 | ], | |
| 1537 | }); | |
| 1538 | expect(generatedBranchResponse.error).toBeUndefined(); | |
| 1539 | expect(generatedBranchResponse.result).toEqual({ branch: "new-name" }); | |
| 1540 | expect(generateBranchName).toHaveBeenCalledWith({ | |
| 1541 | cwd: "/repo/path", | |
| 1542 | message: "Add retry logic for websocket reconnects", | |
| 1543 | attachments: [ | |
| 1544 | { | |
| 1545 | type: "image", | |
| 1546 | name: "bug.png", | |
| 1547 | mimeType: "image/png", | |
| 1548 | sizeBytes: 5, | |
| 1549 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 1550 | }, | |
| 1551 | ], | |
| 1552 | }); | |
| 1553 | ||
| 1554 | const generateAndRenameResponse = await sendRequest( | |
| 1555 | ws, | |
| 1556 | WS_METHODS.gitGenerateAndRenameBranch, | |
| 1557 | { | |
| 1558 | cwd: "/repo/path", | |
| 1559 | oldBranch: "t3code/tmp-123", | |
| 1560 | message: "Add retry logic for websocket reconnects", | |
| 1561 | attachments: [ | |
| 1562 | { | |
| 1563 | type: "image", | |
| 1564 | name: "bug.png", | |
| 1565 | mimeType: "image/png", | |
| 1566 | sizeBytes: 5, | |
| 1567 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 1568 | }, | |
| 1569 | ], | |
| 1570 | }, | |
| 1571 | ); | |
| 1572 | expect(generateAndRenameResponse.error).toBeUndefined(); | |
| 1573 | expect(generateAndRenameResponse.result).toEqual({ branch: "t3code/new-name" }); | |
| 1574 | expect(renameBranch).toHaveBeenCalledWith({ | |
| 1575 | cwd: "/repo/path", | |
| 1576 | oldBranch: "t3code/tmp-123", | |
| 1577 | newBranch: "t3code/new-name", | |
| 1578 | }); | |
| 1579 | ||
| 1580 | 1516 | const renameResponse = await sendRequest(ws, WS_METHODS.gitRenameBranch, { |
| 1581 | 1517 | cwd: "/repo/path", |
| 1582 | 1518 | oldBranch: "t3code/tmp-456", |
| ... | ... | @@ -1591,52 +1527,6 @@ describe("WebSocket Server", () => { |
| 1591 | 1527 | }); |
| 1592 | 1528 | }); |
| 1593 | 1529 | |
| 1594 | it("skips rename in git.generateAndRenameBranch when branch generation returns null", async () => { | |
| 1595 | const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" })); | |
| 1596 | const generateBranchName = vi.fn(() => Effect.succeed({ branch: null })); | |
| 1597 | ||
| 1598 | server = await createTestServer({ | |
| 1599 | cwd: "/test", | |
| 1600 | gitCore: { | |
| 1601 | listBranches: () => | |
| 1602 | Effect.succeed({ | |
| 1603 | branches: [], | |
| 1604 | isRepo: false, | |
| 1605 | }), | |
| 1606 | initRepo: () => Effect.void, | |
| 1607 | renameBranch, | |
| 1608 | pullCurrentBranch: () => | |
| 1609 | Effect.fail( | |
| 1610 | new GitCommandError({ | |
| 1611 | operation: "GitCore.test.pullCurrentBranch", | |
| 1612 | detail: "No upstream configured", | |
| 1613 | command: "git pull", | |
| 1614 | cwd: "/repo/path", | |
| 1615 | }), | |
| 1616 | ), | |
| 1617 | }, | |
| 1618 | textGeneration: { | |
| 1619 | generateBranchName, | |
| 1620 | }, | |
| 1621 | }); | |
| 1622 | const addr = server.address(); | |
| 1623 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1624 | const ws = await connectWs(port); | |
| 1625 | connections.push(ws); | |
| 1626 | await waitForMessage(ws); | |
| 1627 | ||
| 1628 | const response = await sendRequest(ws, WS_METHODS.gitGenerateAndRenameBranch, { | |
| 1629 | cwd: "/repo/path", | |
| 1630 | oldBranch: "t3code/tmp-123", | |
| 1631 | message: "Fix visual regression", | |
| 1632 | attachments: [], | |
| 1633 | }); | |
| 1634 | ||
| 1635 | expect(response.error).toBeUndefined(); | |
| 1636 | expect(response.result).toEqual({ branch: null }); | |
| 1637 | expect(renameBranch).not.toHaveBeenCalled(); | |
| 1638 | }); | |
| 1639 | ||
| 1640 | 1530 | it("supports git.status over websocket", async () => { |
| 1641 | 1531 | const statusResult = { |
| 1642 | 1532 | branch: "feature/test", |
apps/server/src/wsServer.ts-54| ... | ... | @@ -54,7 +54,6 @@ import { clamp } from "effect/Number"; |
| 54 | 54 | import { Open } from "./open"; |
| 55 | 55 | import { ServerConfig } from "./config"; |
| 56 | 56 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 57 | import { TextGeneration } from "./git/Services/TextGeneration.ts"; | |
| 58 | 57 | import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; |
| 59 | 58 | |
| 60 | 59 | /** |
| ... | ... | @@ -136,31 +135,6 @@ function stripRequestTag<T extends { _tag: string }>(body: T) { |
| 136 | 135 | return Struct.omit(body, ["_tag"]); |
| 137 | 136 | } |
| 138 | 137 | |
| 139 | const WORKTREE_BRANCH_PREFIX = "t3code"; | |
| 140 | ||
| 141 | function buildGeneratedWorktreeBranchName(raw: string): string { | |
| 142 | const normalized = raw | |
| 143 | .trim() | |
| 144 | .toLowerCase() | |
| 145 | .replace(/^refs\/heads\//, "") | |
| 146 | .replace(/['"`]/g, ""); | |
| 147 | ||
| 148 | const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`) | |
| 149 | ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length) | |
| 150 | : normalized; | |
| 151 | ||
| 152 | const branchFragment = withoutPrefix | |
| 153 | .replace(/[^a-z0-9/_-]+/g, "-") | |
| 154 | .replace(/\/+/g, "/") | |
| 155 | .replace(/-+/g, "-") | |
| 156 | .replace(/^[./_-]+|[./_-]+$/g, "") | |
| 157 | .slice(0, 64) | |
| 158 | .replace(/[./_-]+$/g, ""); | |
| 159 | ||
| 160 | const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; | |
| 161 | return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; | |
| 162 | } | |
| 163 | ||
| 164 | 138 | export type ServerCoreRuntimeServices = |
| 165 | 139 | | OrchestrationEngineService |
| 166 | 140 | | ProjectionSnapshotQuery |
| ... | ... | @@ -172,7 +146,6 @@ export type ServerRuntimeServices = |
| 172 | 146 | | ServerCoreRuntimeServices |
| 173 | 147 | | GitManager |
| 174 | 148 | | GitCore |
| 175 | | TextGeneration | |
| 176 | 149 | | TerminalManager |
| 177 | 150 | | Keybindings |
| 178 | 151 | | Open; |
| ... | ... | @@ -211,7 +184,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 211 | 184 | const terminalManager = yield* TerminalManager; |
| 212 | 185 | const keybindingsManager = yield* Keybindings; |
| 213 | 186 | const git = yield* GitCore; |
| 214 | const textGeneration = yield* TextGeneration; | |
| 215 | 187 | const fileSystem = yield* FileSystem.FileSystem; |
| 216 | 188 | const path = yield* Path.Path; |
| 217 | 189 | |
| ... | ... | @@ -600,32 +572,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 600 | 572 | return yield* git.createWorktree(body); |
| 601 | 573 | } |
| 602 | 574 | |
| 603 | case WS_METHODS.gitGenerateBranchName: { | |
| 604 | const body = stripRequestTag(request.body); | |
| 605 | return yield* textGeneration.generateBranchName(body); | |
| 606 | } | |
| 607 | ||
| 608 | case WS_METHODS.gitGenerateAndRenameBranch: { | |
| 609 | const body = stripRequestTag(request.body); | |
| 610 | const generated = yield* textGeneration.generateBranchName({ | |
| 611 | cwd: body.cwd, | |
| 612 | message: body.message, | |
| 613 | ...(body.attachments !== undefined ? { attachments: body.attachments } : {}), | |
| 614 | }); | |
| 615 | if (!generated.branch) { | |
| 616 | return { branch: null }; | |
| 617 | } | |
| 618 | const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); | |
| 619 | if (targetBranch === body.oldBranch) { | |
| 620 | return { branch: body.oldBranch }; | |
| 621 | } | |
| 622 | return yield* git.renameBranch({ | |
| 623 | cwd: body.cwd, | |
| 624 | oldBranch: body.oldBranch, | |
| 625 | newBranch: targetBranch, | |
| 626 | }); | |
| 627 | } | |
| 628 | ||
| 629 | 575 | case WS_METHODS.gitRenameBranch: { |
| 630 | 576 | const body = stripRequestTag(request.body); |
| 631 | 577 | return yield* git.renameBranch(body); |
apps/web/src/components/ChatView.tsx-72| ... | ... | @@ -247,43 +247,6 @@ function buildTemporaryWorktreeBranchName(message: string): string { |
| 247 | 247 | return `${WORKTREE_BRANCH_PREFIX}/${hashBranchSeed(seed)}`; |
| 248 | 248 | } |
| 249 | 249 | |
| 250 | function imageReferenceForBranchNaming(image: ComposerImageAttachment): string { | |
| 251 | const maybePath = (image.file as File & { path?: unknown }).path; | |
| 252 | if (typeof maybePath === "string" && maybePath.trim().length > 0) { | |
| 253 | return maybePath.trim(); | |
| 254 | } | |
| 255 | return image.name; | |
| 256 | } | |
| 257 | ||
| 258 | function buildBranchNameGenerationMessage( | |
| 259 | text: string, | |
| 260 | images: ReadonlyArray<ComposerImageAttachment>, | |
| 261 | ): string { | |
| 262 | const trimmedText = text.trim(); | |
| 263 | const imageReferences = Array.from( | |
| 264 | new Set( | |
| 265 | images | |
| 266 | .map((image) => imageReferenceForBranchNaming(image).trim()) | |
| 267 | .filter((reference) => reference.length > 0), | |
| 268 | ), | |
| 269 | ); | |
| 270 | const attachmentsSection = | |
| 271 | imageReferences.length > 0 | |
| 272 | ? `Attached images:\n${imageReferences.map((reference) => `- ${reference}`).join("\n")}` | |
| 273 | : ""; | |
| 274 | ||
| 275 | if (trimmedText.length > 0 && attachmentsSection.length > 0) { | |
| 276 | return `${trimmedText}\n\n${attachmentsSection}`; | |
| 277 | } | |
| 278 | if (trimmedText.length > 0) { | |
| 279 | return trimmedText; | |
| 280 | } | |
| 281 | if (attachmentsSection.length > 0) { | |
| 282 | return attachmentsSection; | |
| 283 | } | |
| 284 | return "New worktree change"; | |
| 285 | } | |
| 286 | ||
| 287 | 250 | const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { |
| 288 | 251 | pathValue: string; |
| 289 | 252 | kind: "file" | "directory"; |
| ... | ... | @@ -1530,7 +1493,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1530 | 1493 | const composerImagesSnapshot = [...composerImages]; |
| 1531 | 1494 | const messageIdForSend = newMessageId(); |
| 1532 | 1495 | const messageCreatedAt = new Date().toISOString(); |
| 1533 | const branchNameMessage = buildBranchNameGenerationMessage(trimmed, composerImagesSnapshot); | |
| 1534 | 1496 | const turnAttachmentsPromise = Promise.all( |
| 1535 | 1497 | composerImagesSnapshot.map( |
| 1536 | 1498 | async ( |
| ... | ... | @@ -1578,7 +1540,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1578 | 1540 | setComposerHighlightedItemId(null); |
| 1579 | 1541 | |
| 1580 | 1542 | let attemptedTurnStart = false; |
| 1581 | let pendingWorktreeBranchRename: Promise<void> | null = null; | |
| 1582 | 1543 | try { |
| 1583 | 1544 | // On first message: lock in branch + create worktree if needed. |
| 1584 | 1545 | if (baseBranchForWorktree) { |
| ... | ... | @@ -1604,36 +1565,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1604 | 1565 | branch: result.worktree.branch, |
| 1605 | 1566 | worktreePath: result.worktree.path, |
| 1606 | 1567 | }); |
| 1607 | pendingWorktreeBranchRename = (async () => { | |
| 1608 | const turnAttachments = await turnAttachmentsPromise; | |
| 1609 | const renamed = await api.git.generateAndRenameBranch({ | |
| 1610 | cwd: result.worktree.path, | |
| 1611 | oldBranch: result.worktree.branch, | |
| 1612 | message: branchNameMessage, | |
| 1613 | attachments: turnAttachments, | |
| 1614 | }); | |
| 1615 | if (!renamed.branch || renamed.branch === result.worktree.branch) { | |
| 1616 | return; | |
| 1617 | } | |
| 1618 | await api.orchestration.dispatchCommand({ | |
| 1619 | type: "thread.meta.update", | |
| 1620 | commandId: newCommandId(), | |
| 1621 | threadId: threadIdForSend, | |
| 1622 | branch: renamed.branch, | |
| 1623 | worktreePath: result.worktree.path, | |
| 1624 | }); | |
| 1625 | dispatch({ | |
| 1626 | type: "SET_THREAD_BRANCH", | |
| 1627 | threadId: threadIdForSend, | |
| 1628 | branch: renamed.branch, | |
| 1629 | worktreePath: result.worktree.path, | |
| 1630 | }); | |
| 1631 | })().catch((error) => { | |
| 1632 | console.warn("Failed to rename generated worktree branch", { | |
| 1633 | threadId: threadIdForSend, | |
| 1634 | error: error instanceof Error ? error.message : String(error), | |
| 1635 | }); | |
| 1636 | }); | |
| 1637 | 1568 | const setupScript = setupProjectScript(activeProject.scripts); |
| 1638 | 1569 | if (setupScript) { |
| 1639 | 1570 | await runProjectScript(setupScript, { |
| ... | ... | @@ -1683,9 +1614,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1683 | 1614 | sandboxMode, |
| 1684 | 1615 | createdAt: messageCreatedAt, |
| 1685 | 1616 | }); |
| 1686 | if (pendingWorktreeBranchRename) { | |
| 1687 | void pendingWorktreeBranchRename; | |
| 1688 | } | |
| 1689 | 1617 | } catch (err) { |
| 1690 | 1618 | if ( |
| 1691 | 1619 | !attemptedTurnStart && |
apps/web/src/wsNativeApi.ts-3| ... | ... | @@ -164,9 +164,6 @@ export function createWsNativeApi(): NativeApi { |
| 164 | 164 | runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), |
| 165 | 165 | listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), |
| 166 | 166 | createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), |
| 167 | generateBranchName: (input) => transport.request(WS_METHODS.gitGenerateBranchName, input), | |
| 168 | generateAndRenameBranch: (input) => | |
| 169 | transport.request(WS_METHODS.gitGenerateAndRenameBranch, input), | |
| 170 | 167 | renameBranch: (input) => transport.request(WS_METHODS.gitRenameBranch, input), |
| 171 | 168 | removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), |
| 172 | 169 | createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), |
packages/contracts/src/ipc.ts-8| ... | ... | @@ -1,10 +1,6 @@ |
| 1 | 1 | import type { |
| 2 | 2 | GitCheckoutInput, |
| 3 | 3 | GitCreateBranchInput, |
| 4 | GitGenerateAndRenameBranchInput, | |
| 5 | GitGenerateAndRenameBranchResult, | |
| 6 | GitGenerateBranchNameInput, | |
| 7 | GitGenerateBranchNameResult, | |
| 8 | 4 | GitRenameBranchInput, |
| 9 | 5 | GitRenameBranchResult, |
| 10 | 6 | GitCreateWorktreeInput, |
| ... | ... | @@ -80,10 +76,6 @@ export interface NativeApi { |
| 80 | 76 | // Existing branch/worktree API |
| 81 | 77 | listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>; |
| 82 | 78 | createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>; |
| 83 | generateBranchName: (input: GitGenerateBranchNameInput) => Promise<GitGenerateBranchNameResult>; | |
| 84 | generateAndRenameBranch: ( | |
| 85 | input: GitGenerateAndRenameBranchInput, | |
| 86 | ) => Promise<GitGenerateAndRenameBranchResult>; | |
| 87 | 79 | renameBranch: (input: GitRenameBranchInput) => Promise<GitRenameBranchResult>; |
| 88 | 80 | removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>; |
| 89 | 81 | createBranch: (input: GitCreateBranchInput) => Promise<void>; |
packages/contracts/src/ws.ts-6| ... | ... | @@ -11,9 +11,7 @@ import { |
| 11 | 11 | } from "./orchestration"; |
| 12 | 12 | import { |
| 13 | 13 | GitCheckoutInput, |
| 14 | GitGenerateAndRenameBranchInput, | |
| 15 | 14 | GitCreateBranchInput, |
| 16 | GitGenerateBranchNameInput, | |
| 17 | 15 | GitCreateWorktreeInput, |
| 18 | 16 | GitInitInput, |
| 19 | 17 | GitListBranchesInput, |
| ... | ... | @@ -53,8 +51,6 @@ export const WS_METHODS = { |
| 53 | 51 | gitRunStackedAction: "git.runStackedAction", |
| 54 | 52 | gitListBranches: "git.listBranches", |
| 55 | 53 | gitCreateWorktree: "git.createWorktree", |
| 56 | gitGenerateBranchName: "git.generateBranchName", | |
| 57 | gitGenerateAndRenameBranch: "git.generateAndRenameBranch", | |
| 58 | 54 | gitRenameBranch: "git.renameBranch", |
| 59 | 55 | gitRemoveWorktree: "git.removeWorktree", |
| 60 | 56 | gitCreateBranch: "git.createBranch", |
| ... | ... | @@ -117,8 +113,6 @@ export const WebSocketRequestBody = Schema.Union([ |
| 117 | 113 | tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), |
| 118 | 114 | tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), |
| 119 | 115 | tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), |
| 120 | tagRequestBody(WS_METHODS.gitGenerateBranchName, GitGenerateBranchNameInput), | |
| 121 | tagRequestBody(WS_METHODS.gitGenerateAndRenameBranch, GitGenerateAndRenameBranchInput), | |
| 122 | 116 | tagRequestBody(WS_METHODS.gitRenameBranch, GitRenameBranchInput), |
| 123 | 117 | tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), |
| 124 | 118 | tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), |