Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,390 changes: 2,390 additions & 0 deletions .plans/task-tabs-context-fork.html

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(first.environmentId).toBe(second.environmentId);
expect(second.capabilities.repositoryIdentity).toBe(true);
expect(second.capabilities.connectionProbe).toBe(true);
expect(second.capabilities.workspaceTaskTabs).toBe(true);
}),
);

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export const make = Effect.gen(function* () {
connectionProbe: true,
threadSettlement: true,
threadSnooze: true,
workspaceTaskTabs: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
},
};
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type OrchestrationEvent,
type OrchestrationSessionStatus,
ThreadId,
WorkspaceTaskId,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
Expand Down Expand Up @@ -597,6 +598,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
yield* projectionThreadRepository.upsert({
threadId: event.payload.threadId,
projectId: event.payload.projectId,
workspaceTaskId:
event.payload.workspaceTaskId ?? WorkspaceTaskId.make(String(event.payload.threadId)),
tabLabel: event.payload.tabLabel ?? null,
tabPosition: event.payload.tabPosition ?? 0,
tabClosedAt: event.payload.tabClosedAt ?? null,
forkProvenance: event.payload.forkProvenance ?? null,
title: event.payload.title,
modelSelection: event.payload.modelSelection,
runtimeMode: event.payload.runtimeMode,
Expand Down Expand Up @@ -730,6 +737,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
...(event.payload.worktreePath !== undefined
? { worktreePath: event.payload.worktreePath }
: {}),
...(event.payload.tabLabel !== undefined ? { tabLabel: event.payload.tabLabel } : {}),
...(event.payload.tabPosition !== undefined
? { tabPosition: event.payload.tabPosition }
: {}),
...(event.payload.tabClosedAt !== undefined
? { tabClosedAt: event.payload.tabClosedAt }
: {}),
updatedAt: event.payload.updatedAt,
});
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ThreadId,
TurnId,
ProviderInstanceId,
WorkspaceTaskId,
} from "@t3tools/contracts";
import { assert, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
Expand Down Expand Up @@ -284,6 +285,11 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
{
id: ThreadId.make("thread-1"),
projectId: asProjectId("project-1"),
workspaceTaskId: WorkspaceTaskId.make("thread-1"),
tabLabel: null,
tabPosition: 0,
tabClosedAt: null,
forkProvenance: null,
title: "Thread 1",
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
Expand Down Expand Up @@ -398,6 +404,11 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
{
id: ThreadId.make("thread-1"),
projectId: asProjectId("project-1"),
workspaceTaskId: WorkspaceTaskId.make("thread-1"),
tabLabel: null,
tabPosition: 0,
tabClosedAt: null,
forkProvenance: null,
title: "Thread 1",
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
Expand Down Expand Up @@ -447,6 +458,23 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
if (threadDetail._tag === "Some") {
assert.deepEqual(threadDetail.value, snapshot.threads[0]);
}

yield* sql`
UPDATE projection_threads
SET tab_closed_at = '2026-02-24T00:00:09.000Z'
WHERE thread_id = 'thread-1'
`;
const closedShellSnapshot = yield* snapshotQuery.getShellSnapshot();
assert.deepEqual(closedShellSnapshot.threads, []);
const closedThreadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));
assert.equal(closedThreadShell._tag, "None");
const closedThreadDetail = yield* snapshotQuery.getThreadDetailById(
ThreadId.make("thread-1"),
);
assert.equal(closedThreadDetail._tag, "Some");
if (closedThreadDetail._tag === "Some") {
assert.equal(closedThreadDetail.value.tabClosedAt, "2026-02-24T00:00:09.000Z");
}
}),
);

Expand Down
68 changes: 63 additions & 5 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
ModelSelection,
ProjectId,
ThreadId,
ThreadForkProvenance,
} from "@t3tools/contracts";
import * as Arr from "effect/Array";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -78,6 +79,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
Struct.assign({
modelSelection: Schema.fromJsonString(ModelSelection),
forkProvenance: Schema.NullOr(Schema.fromJsonString(ThreadForkProvenance)),
}),
);
const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
Expand Down Expand Up @@ -117,6 +119,10 @@ const ProjectIdLookupInput = Schema.Struct({
const ThreadIdLookupInput = Schema.Struct({
threadId: ThreadId,
});
const ThreadRowByIdLookupInput = Schema.Struct({
threadId: ThreadId,
includeClosed: Schema.Boolean,
});
const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema;
const ProjectionThreadIdLookupRowSchema = Schema.Struct({
threadId: ThreadId,
Expand Down Expand Up @@ -324,6 +330,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
SELECT
thread_id AS "threadId",
project_id AS "projectId",
COALESCE(workspace_task_id, thread_id) AS "workspaceTaskId",
tab_label AS "tabLabel",
tab_position AS "tabPosition",
tab_closed_at AS "tabClosedAt",
fork_provenance_json AS "forkProvenance",
title,
model_selection_json AS "modelSelection",
runtime_mode AS "runtimeMode",
Expand Down Expand Up @@ -356,6 +367,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
SELECT
thread_id AS "threadId",
project_id AS "projectId",
COALESCE(workspace_task_id, thread_id) AS "workspaceTaskId",
tab_label AS "tabLabel",
tab_position AS "tabPosition",
tab_closed_at AS "tabClosedAt",
fork_provenance_json AS "forkProvenance",
title,
model_selection_json AS "modelSelection",
runtime_mode AS "runtimeMode",
Expand All @@ -378,6 +394,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
FROM projection_threads
WHERE deleted_at IS NULL
AND archived_at IS NULL
AND tab_closed_at IS NULL
ORDER BY project_id ASC, created_at ASC, thread_id ASC
`,
});
Expand All @@ -390,6 +407,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
SELECT
thread_id AS "threadId",
project_id AS "projectId",
COALESCE(workspace_task_id, thread_id) AS "workspaceTaskId",
tab_label AS "tabLabel",
tab_position AS "tabPosition",
tab_closed_at AS "tabClosedAt",
fork_provenance_json AS "forkProvenance",
title,
model_selection_json AS "modelSelection",
runtime_mode AS "runtimeMode",
Expand Down Expand Up @@ -748,14 +770,19 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
`,
});

const getActiveThreadRowById = SqlSchema.findOneOption({
Request: ThreadIdLookupInput,
const getThreadRowById = SqlSchema.findOneOption({
Request: ThreadRowByIdLookupInput,
Result: ProjectionThreadDbRowSchema,
execute: ({ threadId }) =>
execute: ({ threadId, includeClosed }) =>
sql`
SELECT
thread_id AS "threadId",
project_id AS "projectId",
COALESCE(workspace_task_id, thread_id) AS "workspaceTaskId",
tab_label AS "tabLabel",
tab_position AS "tabPosition",
tab_closed_at AS "tabClosedAt",
fork_provenance_json AS "forkProvenance",
title,
model_selection_json AS "modelSelection",
runtime_mode AS "runtimeMode",
Expand All @@ -779,6 +806,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
WHERE thread_id = ${threadId}
AND deleted_at IS NULL
AND archived_at IS NULL
AND (${includeClosed ? 1 : 0} = 1 OR tab_closed_at IS NULL)
LIMIT 1
`,
});
Expand Down Expand Up @@ -1192,6 +1220,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({
id: row.threadId,
projectId: row.projectId,
workspaceTaskId: row.workspaceTaskId,
tabLabel: row.tabLabel,
tabPosition: row.tabPosition,
tabClosedAt: row.tabClosedAt,
forkProvenance: row.forkProvenance,
title: row.title,
modelSelection: row.modelSelection,
runtimeMode: row.runtimeMode,
Expand Down Expand Up @@ -1394,6 +1427,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
threads.push({
id: row.threadId,
projectId: row.projectId,
workspaceTaskId: row.workspaceTaskId,
tabLabel: row.tabLabel,
tabPosition: row.tabPosition,
tabClosedAt: row.tabClosedAt,
forkProvenance: row.forkProvenance,
title: row.title,
modelSelection: row.modelSelection,
runtimeMode: row.runtimeMode,
Expand Down Expand Up @@ -1527,6 +1565,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
? Result.succeed({
id: row.threadId,
projectId: row.projectId,
workspaceTaskId: row.workspaceTaskId,
tabLabel: row.tabLabel,
tabPosition: row.tabPosition,
tabClosedAt: row.tabClosedAt,
forkProvenance: row.forkProvenance,
title: row.title,
modelSelection: row.modelSelection,
runtimeMode: row.runtimeMode,
Expand Down Expand Up @@ -1665,6 +1708,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
(row): OrchestrationThreadShell => ({
id: row.threadId,
projectId: row.projectId,
workspaceTaskId: row.workspaceTaskId,
tabLabel: row.tabLabel,
tabPosition: row.tabPosition,
tabClosedAt: row.tabClosedAt,
forkProvenance: row.forkProvenance,
title: row.title,
modelSelection: row.modelSelection,
runtimeMode: row.runtimeMode,
Expand Down Expand Up @@ -1876,7 +1924,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) =>
Effect.gen(function* () {
const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([
getActiveThreadRowById({ threadId }).pipe(
getThreadRowById({ threadId, includeClosed: false }).pipe(
Effect.mapError(
toPersistenceSqlOrDecodeError(
"ProjectionSnapshotQuery.getThreadShellById:getThread:query",
Expand Down Expand Up @@ -1909,6 +1957,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
return Option.some({
id: threadRow.value.threadId,
projectId: threadRow.value.projectId,
workspaceTaskId: threadRow.value.workspaceTaskId,
tabLabel: threadRow.value.tabLabel,
tabPosition: threadRow.value.tabPosition,
tabClosedAt: threadRow.value.tabClosedAt,
forkProvenance: threadRow.value.forkProvenance,
title: threadRow.value.title,
modelSelection: threadRow.value.modelSelection,
runtimeMode: threadRow.value.runtimeMode,
Expand Down Expand Up @@ -1942,7 +1995,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
latestTurnRow,
sessionRow,
] = yield* Effect.all([
getActiveThreadRowById({ threadId }).pipe(
getThreadRowById({ threadId, includeClosed: true }).pipe(
Effect.mapError(
toPersistenceSqlOrDecodeError(
"ProjectionSnapshotQuery.getThreadDetailById:getThread:query",
Expand Down Expand Up @@ -2007,6 +2060,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
const thread = {
id: threadRow.value.threadId,
projectId: threadRow.value.projectId,
workspaceTaskId: threadRow.value.workspaceTaskId,
tabLabel: threadRow.value.tabLabel,
tabPosition: threadRow.value.tabPosition,
tabClosedAt: threadRow.value.tabClosedAt,
forkProvenance: threadRow.value.forkProvenance,
title: threadRow.value.title,
modelSelection: threadRow.value.modelSelection,
runtimeMode: threadRow.value.runtimeMode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ProjectId,
ThreadId,
TurnId,
WorkspaceTaskId,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Deferred from "effect/Deferred";
Expand Down Expand Up @@ -477,6 +478,80 @@ describe("ProviderCommandReactor", () => {
expect(thread?.session?.runtimeMode).toBe("approval-required");
});

effectIt.effect(
"injects a source transcript only into the first provider turn of a portable task fork",
() =>
Effect.gen(function* () {
const harness = yield* Effect.promise(() => createHarness());
const now = "2026-01-01T00:00:00.000Z";

yield* harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-source-turn"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("source-user-message"),
role: "user",
text: "Source tab context",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
});
yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1));

yield* harness.engine.dispatch({
type: "thread.create",
commandId: CommandId.make("cmd-fork-thread-create"),
threadId: ThreadId.make("thread-fork"),
projectId: asProjectId("project-1"),
workspaceTaskId: WorkspaceTaskId.make("thread-1"),
tabPosition: 1,
forkProvenance: {
mode: "portable",
sourceThreadId: ThreadId.make("thread-1"),
createdAt: now,
},
title: "Fork",
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
branch: null,
worktreePath: null,
createdAt: now,
});
yield* harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-fork-turn"),
threadId: ThreadId.make("thread-fork"),
message: {
messageId: asMessageId("fork-user-message"),
role: "user",
text: "Try another approach",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
});

yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 2));
expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({
threadId: ThreadId.make("thread-fork"),
});
const forkInput = harness.sendTurn.mock.calls[1]?.[0] as { input?: string };
expect(forkInput.input).toContain("Source tab context");
expect(forkInput.input?.endsWith("Try another approach")).toBe(true);
const readModel = yield* Effect.promise(() => harness.readModel());
const fork = readModel.threads.find((thread) => thread.id === ThreadId.make("thread-fork"));
expect(fork?.messages.map((message) => message.text)).toEqual(["Try another approach"]);
}),
);

effectIt.effect("projects starting before a slow provider session finishes", () =>
Effect.gen(function* () {
const releaseStart = yield* Deferred.make<void>();
Expand Down
Loading
Loading