Skip to content

Commit 739b31c

Browse files
committed
Add external thread import and richer provider/source-control UX
- Import and resume external provider threads, including subagent transcript rendering - Resolve model defaults from configured provider instances - Expose repository root context and improve source-control integrations - Update orchestration, shared contracts, and coverage for the new flows
1 parent 4a94f55 commit 739b31c

70 files changed

Lines changed: 4057 additions & 498 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/server/src/cli/project.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSna
3333
import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts";
3434
import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";
3535
import { RepositoryIdentityResolverLive } from "../project/Layers/RepositoryIdentityResolver.ts";
36-
import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts";
3736
import {
3837
clearPersistedServerRuntimeState,
3938
readPersistedServerRuntimeState,
@@ -350,7 +349,10 @@ const projectAddCommand = Command.make("add", {
350349
projectId,
351350
title,
352351
workspaceRoot,
353-
defaultModelSelection: getAutoBootstrapDefaultModelSelection(),
352+
// No explicit project pin: the web composer resolves the default
353+
// advertised by the configured provider instance when a thread is
354+
// created.
355+
defaultModelSelection: null,
354356
createdAt: DateTime.formatIso(yield* DateTime.now),
355357
});
356358
return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;

apps/server/src/git/GitWorkflowService.test.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,51 @@ function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistry
1919
);
2020
}
2121

22-
function makeGitHandle(): VcsDriverRegistry.VcsDriverHandle {
22+
function makeGitHandle(rootPath = "/repo"): VcsDriverRegistry.VcsDriverHandle {
2323
return {
2424
kind: "git",
25-
repository: {} as VcsDriverRegistry.VcsDriverHandle["repository"],
25+
repository: {
26+
rootPath,
27+
} as VcsDriverRegistry.VcsDriverHandle["repository"],
2628
driver: {} as VcsDriverRegistry.VcsDriverHandle["driver"],
2729
};
2830
}
2931

3032
describe("GitWorkflowService", () => {
33+
it.effect("reports when the detected repository root is above the requested cwd", () => {
34+
const localStatus = vi.fn(() =>
35+
Effect.succeed({
36+
isRepo: true,
37+
hasPrimaryRemote: false,
38+
isDefaultRef: true,
39+
refName: "main",
40+
headSha: null,
41+
hasWorkingTreeChanges: false,
42+
workingTree: { files: [], insertions: 0, deletions: 0 },
43+
}),
44+
);
45+
const testLayer = GitWorkflowService.layer.pipe(
46+
Layer.provide(
47+
Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
48+
detect: () => Effect.succeed(makeGitHandle("/repo")),
49+
}),
50+
),
51+
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
52+
Layer.provide(Layer.mock(GitManager.GitManager)({ localStatus })),
53+
);
54+
55+
return Effect.gen(function* () {
56+
const workflow = yield* GitWorkflowService.GitWorkflowService;
57+
const rootStatus = yield* workflow.localStatus({ cwd: "/repo" });
58+
const nestedStatus = yield* workflow.localStatus({ cwd: "/repo/apps/web" });
59+
60+
assert.equal(rootStatus.repositoryRoot, "/repo");
61+
assert.equal(rootStatus.repositoryRootRelation, "same");
62+
assert.equal(nestedStatus.repositoryRoot, "/repo");
63+
assert.equal(nestedStatus.repositoryRootRelation, "ancestor");
64+
}).pipe(Effect.provide(testLayer));
65+
});
66+
3167
it.effect("returns an empty local status when no VCS repository is detected", () =>
3268
Effect.gen(function* () {
3369
const workflow = yield* GitWorkflowService.GitWorkflowService;

apps/server/src/git/GitWorkflowService.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as nodePath from "node:path";
2+
13
import * as Context from "effect/Context";
24
import * as Effect from "effect/Effect";
35
import * as Layer from "effect/Layer";
@@ -51,7 +53,7 @@ import {
5153

5254
import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts";
5355
import { GitVcsDriver, type GitRemoteStatusOptions } from "../vcs/GitVcsDriver.ts";
54-
import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
56+
import { VcsDriverRegistry, type VcsDriverHandle } from "../vcs/VcsDriverRegistry.ts";
5557

5658
export interface GitWorkflowServiceShape {
5759
readonly status: (
@@ -131,6 +133,20 @@ export class GitWorkflowService extends Context.Service<
131133
GitWorkflowServiceShape
132134
>()("threadlines/git/GitWorkflowService") {}
133135

136+
function withRepositoryContext<T extends VcsStatusLocalResult>(
137+
status: T,
138+
cwd: string,
139+
handle: VcsDriverHandle,
140+
): T & Pick<VcsStatusLocalResult, "repositoryRoot" | "repositoryRootRelation"> {
141+
const repositoryRoot = handle.repository.rootPath;
142+
return {
143+
...status,
144+
repositoryRoot,
145+
repositoryRootRelation:
146+
nodePath.resolve(repositoryRoot) === nodePath.resolve(cwd) ? "same" : "ancestor",
147+
};
148+
}
149+
134150
const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) =>
135151
new GitManagerError({
136152
operation,
@@ -256,7 +272,7 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () {
256272
),
257273
);
258274
if (!handle) {
259-
return false;
275+
return null;
260276
}
261277
if (handle.kind !== "git") {
262278
return yield* unsupportedGitWorkflow(
@@ -265,7 +281,7 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () {
265281
`The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`,
266282
);
267283
}
268-
return true;
284+
return handle;
269285
},
270286
);
271287

@@ -307,22 +323,28 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () {
307323
return GitWorkflowService.of({
308324
status: (input) =>
309325
detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe(
310-
Effect.flatMap((isGitRepository) =>
311-
isGitRepository ? gitManager.status(input) : Effect.succeed(nonRepositoryStatus()),
326+
Effect.flatMap((handle) =>
327+
handle
328+
? gitManager
329+
.status(input)
330+
.pipe(Effect.map((status) => withRepositoryContext(status, input.cwd, handle)))
331+
: Effect.succeed(nonRepositoryStatus()),
312332
),
313333
),
314334
localStatus: (input) =>
315335
detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe(
316-
Effect.flatMap((isGitRepository) =>
317-
isGitRepository
318-
? gitManager.localStatus(input)
336+
Effect.flatMap((handle) =>
337+
handle
338+
? gitManager
339+
.localStatus(input)
340+
.pipe(Effect.map((status) => withRepositoryContext(status, input.cwd, handle)))
319341
: Effect.succeed(nonRepositoryLocalStatus()),
320342
),
321343
),
322344
remoteStatus: (input, options) =>
323345
detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe(
324-
Effect.flatMap((isGitRepository) =>
325-
isGitRepository ? gitManager.remoteStatus(input, options) : Effect.succeed(null),
346+
Effect.flatMap((handle) =>
347+
handle ? gitManager.remoteStatus(input, options) : Effect.succeed(null),
326348
),
327349
),
328350
invalidateLocalStatus: gitManager.invalidateLocalStatus,

apps/server/src/orchestration/Layers/CheckpointReactor.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ function createProviderServiceHarness(
124124
};
125125
const service: ProviderServiceShape = {
126126
startSession: () => unsupported(),
127+
listExternalThreads: () => unsupported(),
128+
readExternalThread: () => unsupported(),
127129
sendTurn: () => unsupported(),
128130
steerTurn: () => unsupported(),
129131
startReview: () => unsupported(),

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import {
5757
providerErrorLabel,
5858
providerErrorLabelFromInstanceHint,
5959
ProviderCommandReactorLive,
60-
resolveForkCutTurnId,
60+
resolveForkTurnBoundary,
6161
} from "./ProviderCommandReactor.ts";
6262
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
6363
import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
@@ -366,6 +366,8 @@ describe("ProviderCommandReactor", () => {
366366
});
367367
const service: ProviderServiceShape = {
368368
startSession: startSession as ProviderServiceShape["startSession"],
369+
listExternalThreads: () => unsupported(),
370+
readExternalThread: () => unsupported(),
369371
sendTurn: sendTurn as ProviderServiceShape["sendTurn"],
370372
steerTurn: steerTurn as ProviderServiceShape["steerTurn"],
371373
startReview: () => unsupported(),
@@ -3318,7 +3320,7 @@ describe("ProviderCommandReactor", () => {
33183320
});
33193321
});
33203322

3321-
describe("resolveForkCutTurnId", () => {
3323+
describe("resolveForkTurnBoundary", () => {
33223324
const message = (id: string, role: "user" | "assistant", turnId: string | null) => ({
33233325
id: asMessageId(id),
33243326
role,
@@ -3332,8 +3334,12 @@ describe("resolveForkCutTurnId", () => {
33323334
message("u2", "user", "turn-2"),
33333335
message("a2", "assistant", "turn-2"),
33343336
];
3335-
expect(resolveForkCutTurnId(messages, asMessageId("a1"))).toBe(asTurnId("turn-1"));
3336-
expect(resolveForkCutTurnId(messages, asMessageId("a2"))).toBe(asTurnId("turn-2"));
3337+
expect(resolveForkTurnBoundary(messages, asMessageId("a1"))).toEqual({
3338+
lastTurnId: asTurnId("turn-1"),
3339+
});
3340+
expect(resolveForkTurnBoundary(messages, asMessageId("a2"))).toEqual({
3341+
lastTurnId: asTurnId("turn-2"),
3342+
});
33373343
});
33383344

33393345
it("cuts before a user anchor so the fork can retry it", () => {
@@ -3343,18 +3349,26 @@ describe("resolveForkCutTurnId", () => {
33433349
message("u2", "user", "turn-2"),
33443350
message("a2", "assistant", "turn-2"),
33453351
];
3346-
expect(resolveForkCutTurnId(messages, asMessageId("u2"))).toBe(asTurnId("turn-1"));
3352+
expect(resolveForkTurnBoundary(messages, asMessageId("u2"))).toEqual({
3353+
beforeTurnId: asTurnId("turn-2"),
3354+
lastTurnId: asTurnId("turn-1"),
3355+
});
3356+
expect(resolveForkTurnBoundary(messages, asMessageId("u1"))).toEqual({
3357+
beforeTurnId: asTurnId("turn-1"),
3358+
});
33473359
});
33483360

33493361
it("skips messages without turn ids when walking back", () => {
33503362
const messages = [message("a1", "assistant", "turn-1"), message("a2", "assistant", null)];
3351-
expect(resolveForkCutTurnId(messages, asMessageId("a2"))).toBe(asTurnId("turn-1"));
3363+
expect(resolveForkTurnBoundary(messages, asMessageId("a2"))).toEqual({
3364+
lastTurnId: asTurnId("turn-1"),
3365+
});
33523366
});
33533367

33543368
it("returns undefined when no prior turn exists or the anchor is unknown", () => {
3355-
const messages = [message("u1", "user", "turn-1"), message("a1", "assistant", null)];
3356-
expect(resolveForkCutTurnId(messages, asMessageId("u1"))).toBeUndefined();
3357-
expect(resolveForkCutTurnId(messages, asMessageId("missing"))).toBeUndefined();
3358-
expect(resolveForkCutTurnId([], asMessageId("u1"))).toBeUndefined();
3369+
const messages = [message("u1", "user", null), message("a1", "assistant", null)];
3370+
expect(resolveForkTurnBoundary(messages, asMessageId("u1"))).toBeUndefined();
3371+
expect(resolveForkTurnBoundary(messages, asMessageId("missing"))).toBeUndefined();
3372+
expect(resolveForkTurnBoundary([], asMessageId("u1"))).toBeUndefined();
33593373
});
33603374
});

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -100,32 +100,60 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined
100100
return normalized && normalized.length > 0 ? normalized : undefined;
101101
}
102102

103-
/**
104-
* Resolves the provider turn a native fork should cut through, inclusive.
105-
*
106-
* Fork anchors on a message; provider forks cut at turn granularity. An
107-
* assistant/tool anchor forks through its own turn. A user-message anchor
108-
* means "retry from before it", so the cut is the previous turn and the
109-
* superseded user message stays out of the forked history. Returns undefined
110-
* when no turn at or before the anchor is known (native fork not applicable).
111-
*/
112-
export function resolveForkCutTurnId(
103+
export interface ForkTurnBoundary {
104+
/** Exact experimental boundary used to exclude a selected user turn. */
105+
readonly beforeTurnId?: TurnId;
106+
/** Stable inclusive boundary used directly or as the compatibility fallback. */
107+
readonly lastTurnId?: TurnId;
108+
}
109+
110+
/** Resolves provider turn boundaries for a message-anchored native fork. */
111+
export function resolveForkTurnBoundary(
113112
messages: ReadonlyArray<{
114113
readonly id: MessageId;
115114
readonly role: string;
116115
readonly turnId: TurnId | null;
117116
}>,
118117
sourceMessageId: MessageId,
119-
): TurnId | undefined {
118+
): ForkTurnBoundary | undefined {
120119
const sourceIndex = messages.findIndex((message) => message.id === sourceMessageId);
121120
if (sourceIndex === -1) {
122121
return undefined;
123122
}
124-
const startIndex = messages[sourceIndex]?.role === "user" ? sourceIndex - 1 : sourceIndex;
125-
for (let index = startIndex; index >= 0; index -= 1) {
123+
124+
if (messages[sourceIndex]?.role === "user") {
125+
let beforeTurnId: TurnId | undefined;
126+
for (let index = sourceIndex; index < messages.length; index += 1) {
127+
const message = messages[index];
128+
if (index > sourceIndex && message?.role === "user") {
129+
break;
130+
}
131+
if (message?.turnId !== null && message?.turnId !== undefined) {
132+
beforeTurnId = message.turnId;
133+
break;
134+
}
135+
}
136+
137+
let lastTurnId: TurnId | undefined;
138+
for (let index = sourceIndex - 1; index >= 0; index -= 1) {
139+
const turnId = messages[index]?.turnId;
140+
if (turnId !== null && turnId !== undefined) {
141+
lastTurnId = turnId;
142+
break;
143+
}
144+
}
145+
return beforeTurnId !== undefined || lastTurnId !== undefined
146+
? {
147+
...(beforeTurnId !== undefined ? { beforeTurnId } : {}),
148+
...(lastTurnId !== undefined ? { lastTurnId } : {}),
149+
}
150+
: undefined;
151+
}
152+
153+
for (let index = sourceIndex; index >= 0; index -= 1) {
126154
const turnId = messages[index]?.turnId;
127155
if (turnId !== null && turnId !== undefined) {
128-
return turnId;
156+
return { lastTurnId: turnId };
129157
}
130158
}
131159
return undefined;
@@ -874,9 +902,12 @@ const make = Effect.gen(function* () {
874902
const capabilities = yield* providerService
875903
.getCapabilities(desiredInstanceId)
876904
.pipe(Effect.option, Effect.map(Option.getOrUndefined));
877-
const lastTurnId = resolveForkCutTurnId(sourceThread.messages, forkContext.sourceMessageId);
878-
if (capabilities?.nativeThreadFork === "supported" && lastTurnId !== undefined) {
879-
forkFrom = { providerThreadId: sourceProviderThreadId, lastTurnId };
905+
const boundary = resolveForkTurnBoundary(
906+
sourceThread.messages,
907+
forkContext.sourceMessageId,
908+
);
909+
if (capabilities?.nativeThreadFork === "supported" && boundary !== undefined) {
910+
forkFrom = { providerThreadId: sourceProviderThreadId, ...boundary };
880911
}
881912
}
882913
}
@@ -929,6 +960,9 @@ const make = Effect.gen(function* () {
929960
...(forkFrom !== undefined
930961
? {
931962
sourceProviderThreadId: forkFrom.providerThreadId,
963+
...(forkFrom.beforeTurnId !== undefined
964+
? { beforeTurnId: forkFrom.beforeTurnId }
965+
: {}),
932966
...(forkFrom.lastTurnId !== undefined ? { lastTurnId: forkFrom.lastTurnId } : {}),
933967
}
934968
: {}),

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ function createProviderServiceHarness() {
9999
const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
100100
const service: ProviderServiceShape = {
101101
startSession: () => unsupported(),
102+
listExternalThreads: () => unsupported(),
103+
readExternalThread: () => unsupported(),
102104
sendTurn: () => unsupported(),
103105
steerTurn: () => unsupported(),
104106
startReview: () => unsupported(),

0 commit comments

Comments
 (0)