Skip to content

Commit 669f993

Browse files
committed
Merge remote-tracking branch 'refs/remotes/origin/t3code/codex-turn-mapping' into landing/pr-9903
2 parents ce9ecd9 + 26a48b0 commit 669f993

31 files changed

Lines changed: 2318 additions & 136 deletions

‎apps/mobile/src/features/home/useThreadListActions.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
21
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
32
import { canSnooze } from "@t3tools/client-runtime/state/thread-settled";
43
import * as Cause from "effect/Cause";
@@ -122,7 +121,7 @@ function useThreadActionExecutor(
122121
}
123122
// Archive keeps its original, narrower guard: never interrupt a
124123
// thread mid-turn.
125-
if (action === "archive" && threadRuntimeIsActive(thread.runtime)) {
124+
if (action === "archive" && !threadCanArchive(thread.runtime)) {
126125
Alert.alert(
127126
actionFailureTitle(action),
128127
"This thread is working. Interrupt it first, then try again.",

‎apps/mobile/src/features/threads/ThreadFeed.tsx‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1517,9 +1517,7 @@ function renderFeedEntry(
15171517
const renderedText = renderAssistantCitationsAsText(message.text);
15181518
const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
15191519
const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
1520-
const attachments = (message.attachments ?? []).filter(
1521-
(attachment) => attachment.type === "image",
1522-
);
1520+
const attachments = message.attachments ?? [];
15231521
const hasReviewCommentContext = message.text.includes("<review_comment");
15241522
// A bubble that sizes itself from its content cannot lay out a block whose
15251523
// intrinsic width overflows `maxWidth`: Android positions the bubble's

‎apps/server/src/cli/project.test.ts‎

Lines changed: 201 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,33 @@ import * as NodePath from "node:path";
55

66
import * as NodeServices from "@effect/platform-node/NodeServices";
77
import { assert, it } from "@effect/vitest";
8-
import { EnvironmentInternalError } from "@t3tools/contracts";
8+
import {
9+
CommandId,
10+
EnvironmentInternalError,
11+
EventId,
12+
ProviderInstanceId,
13+
ThreadId,
14+
type OrchestrationV2AppThread,
15+
type ProjectId,
16+
} from "@t3tools/contracts";
917
import * as NetService from "@t3tools/shared/Net";
18+
import * as DateTime from "effect/DateTime";
1019
import * as Effect from "effect/Effect";
1120
import * as FileSystem from "effect/FileSystem";
1221
import * as Layer from "effect/Layer";
1322
import * as References from "effect/References";
23+
import * as Stream from "effect/Stream";
1424
import { Command } from "effect/unstable/cli";
1525

1626
import { cli } from "../bin.ts";
1727
import * as ServerConfig from "../config.ts";
18-
import { ProjectServiceLayerLive } from "../orchestration-v2/runtimeLayer.ts";
28+
import { EventSinkV2 } from "../orchestration-v2/EventSink.ts";
29+
import * as EventStore from "../orchestration-v2/EventStore.ts";
30+
import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
31+
import {
32+
OrchestrationV2EventSinkLayerLive,
33+
ProjectServiceLayerLive,
34+
} from "../orchestration-v2/runtimeLayer.ts";
1935
import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";
2036
import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
2137
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
@@ -146,6 +162,189 @@ const makeProjectLookupFixture = Effect.fn("ProjectCliTest.makeProjectLookupFixt
146162
return { baseDir, workspaceRoot, project: project! };
147163
});
148164

165+
const makeThreadPersistenceLayer = Effect.fn("ProjectCliTest.makeThreadPersistenceLayer")(
166+
function* (baseDir: string) {
167+
const config = yield* makeConfig(baseDir);
168+
return Layer.mergeAll(
169+
OrchestrationV2EventSinkLayerLive,
170+
ProjectionStore.layer,
171+
EventStore.layer,
172+
).pipe(
173+
Layer.provideMerge(SqlitePersistenceLayerLive),
174+
Layer.provideMerge(NodeServices.layer),
175+
Layer.provide(ServerConfig.layer(config)),
176+
Layer.provide(Layer.succeed(References.MinimumLogLevel, config.logLevel)),
177+
);
178+
},
179+
);
180+
181+
const seedNativeThreads = Effect.fn("ProjectCliTest.seedNativeThreads")(function* (
182+
baseDir: string,
183+
threads: ReadonlyArray<{
184+
readonly id: ThreadId;
185+
readonly projectId: ProjectId;
186+
readonly archived: boolean;
187+
}>,
188+
) {
189+
const layer = yield* makeThreadPersistenceLayer(baseDir);
190+
const createdAt = DateTime.makeUnsafe("2026-09-04T12:00:00.000Z");
191+
const providerInstanceId = ProviderInstanceId.make("codex");
192+
yield* Effect.gen(function* () {
193+
const eventSink = yield* EventSinkV2;
194+
yield* eventSink.write({
195+
commandId: CommandId.make("project-cli-seed-threads"),
196+
events: threads.map(({ id, projectId, archived }) => {
197+
const payload: OrchestrationV2AppThread = {
198+
createdBy: "user",
199+
creationSource: "web",
200+
id,
201+
projectId,
202+
title: id,
203+
providerInstanceId,
204+
modelSelection: { instanceId: providerInstanceId, model: "gpt-5" },
205+
runtimeMode: "full-access",
206+
interactionMode: "default",
207+
branch: null,
208+
worktreePath: null,
209+
activeProviderThreadId: null,
210+
lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: id },
211+
forkedFrom: null,
212+
createdAt,
213+
updatedAt: createdAt,
214+
archivedAt: archived ? createdAt : null,
215+
settledOverride: null,
216+
settledAt: null,
217+
lastVisitedAt: null,
218+
deletedAt: null,
219+
};
220+
return {
221+
id: EventId.make(`project-cli-create-${id}`),
222+
type: "thread.created" as const,
223+
threadId: id,
224+
providerInstanceId,
225+
occurredAt: createdAt,
226+
payload,
227+
};
228+
}),
229+
});
230+
}).pipe(Effect.provide(layer));
231+
});
232+
233+
const readNativeThreadState = Effect.fn("ProjectCliTest.readNativeThreadState")(function* (
234+
baseDir: string,
235+
threadId: ThreadId,
236+
) {
237+
const layer = yield* makeThreadPersistenceLayer(baseDir);
238+
return yield* Effect.gen(function* () {
239+
const projections = yield* ProjectionStore.ProjectionStoreV2;
240+
const events = yield* EventStore.EventStoreV2;
241+
return {
242+
thread: yield* projections.getThread(threadId),
243+
events: yield* events.read({ threadId }).pipe(Stream.runCollect),
244+
};
245+
}).pipe(Effect.provide(layer));
246+
});
247+
248+
it.layer(NodeServices.layer)("project deletion with native V2 threads", (it) => {
249+
it.effect.each([
250+
{ label: "an active thread", archived: false, missing: false },
251+
{ label: "an archived thread", archived: true, missing: false },
252+
{
253+
label: "an active thread after its workspace disappears",
254+
archived: false,
255+
missing: true,
256+
},
257+
{
258+
label: "an archived thread after its workspace disappears",
259+
archived: true,
260+
missing: true,
261+
},
262+
])("rejects unforced removal of a project with $label", ({ archived, missing }) =>
263+
Effect.gen(function* () {
264+
const fs = yield* FileSystem.FileSystem;
265+
const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture();
266+
const threadId = ThreadId.make("project-cli-preserved-thread");
267+
yield* seedNativeThreads(baseDir, [{ id: threadId, projectId: project.id, archived }]);
268+
const before = yield* readNativeThreadState(baseDir, threadId);
269+
if (missing) yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`);
270+
271+
const error = yield* runCli([
272+
"project",
273+
"remove",
274+
missing ? workspaceRoot : project.id,
275+
"--base-dir",
276+
baseDir,
277+
]).pipe(
278+
Effect.match({
279+
onFailure: (error) => error,
280+
onSuccess: () => assert.fail("Removing a nonempty project must require --force."),
281+
}),
282+
);
283+
284+
assert.include(error.message, "not empty");
285+
assert.deepEqual(
286+
(yield* readProjects(baseDir)).projects.map((entry) => entry.id),
287+
[project.id],
288+
);
289+
assert.deepEqual(yield* readNativeThreadState(baseDir, threadId), before);
290+
assert.equal(yield* fs.exists(workspaceRoot), !missing);
291+
}),
292+
);
293+
294+
it.effect.each(["present", "missing"] as const)(
295+
"force-removes active and archived V2 threads with the workspace %s, preserving unrelated projects",
296+
(workspace) =>
297+
Effect.gen(function* () {
298+
const fs = yield* FileSystem.FileSystem;
299+
const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture();
300+
const otherWorkspace = `${workspaceRoot}-other`;
301+
yield* fs.makeDirectory(otherWorkspace);
302+
yield* runCli(["project", "add", otherWorkspace, "--base-dir", baseDir]);
303+
const otherProject = (yield* readProjects(baseDir)).projects.find(
304+
(entry) => entry.workspaceRoot === otherWorkspace,
305+
);
306+
assert.isDefined(otherProject);
307+
const activeId = ThreadId.make("project-cli-deleted-active");
308+
const archivedId = ThreadId.make("project-cli-deleted-archived");
309+
const unrelatedId = ThreadId.make("project-cli-unrelated-thread");
310+
yield* seedNativeThreads(baseDir, [
311+
{ id: activeId, projectId: project.id, archived: false },
312+
{ id: archivedId, projectId: project.id, archived: true },
313+
{ id: unrelatedId, projectId: otherProject!.id, archived: false },
314+
]);
315+
const unrelatedBefore = yield* readNativeThreadState(baseDir, unrelatedId);
316+
if (workspace === "missing") {
317+
yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`);
318+
}
319+
320+
yield* runCli([
321+
"project",
322+
"remove",
323+
workspace === "missing" ? workspaceRoot : project.id,
324+
"--force",
325+
"--base-dir",
326+
baseDir,
327+
]);
328+
329+
assert.deepEqual(
330+
(yield* readProjects(baseDir)).projects.map((entry) => entry.id),
331+
[otherProject!.id],
332+
);
333+
for (const threadId of [activeId, archivedId]) {
334+
const state = yield* readNativeThreadState(baseDir, threadId);
335+
assert.isNotNull(state.thread.deletedAt);
336+
assert.lengthOf(
337+
state.events.filter((record) => record.event.type === "thread.deleted"),
338+
1,
339+
);
340+
}
341+
assert.deepEqual(yield* readNativeThreadState(baseDir, unrelatedId), unrelatedBefore);
342+
assert.equal(yield* fs.exists(workspaceRoot), workspace === "present");
343+
assert.isTrue(yield* fs.exists(otherWorkspace));
344+
}),
345+
);
346+
});
347+
149348
it.layer(NodeServices.layer)("project lookup with unavailable workspaces", (it) => {
150349
it.effect.each(["id", "stored path"] as const)(
151350
"removes an empty project by %s after its directory is gone",

‎apps/server/src/cli/project.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,11 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* (
455455
})
456456
.pipe(Effect.asVoid)
457457
: projects
458-
.delete({ commandId: command.commandId, projectId: command.projectId })
458+
.delete({
459+
commandId: command.commandId,
460+
projectId: command.projectId,
461+
...(command.force === undefined ? {} : { force: command.force }),
462+
})
459463
.pipe(Effect.asVoid),
460464
mode: "offline",
461465
});

‎apps/server/src/mcp/McpHttpServer.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab
1212
import packageJson from "../../package.json" with { type: "json" };
1313
import * as McpInvocationContext from "./McpInvocationContext.ts";
1414
import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
15+
import * as ThreadMetadataMcpService from "./ThreadMetadataMcpService.ts";
1516
import * as McpSessionRegistry from "./McpSessionRegistry.ts";
1617
import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
1718
import { OrchestratorToolkitHandlersLive } from "./toolkits/orchestrator/handlers.ts";
@@ -224,6 +225,7 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll(
224225
export const OrchestratorToolkitRegistrationLive = McpServer.toolkit(OrchestratorToolkit).pipe(
225226
Layer.provide(OrchestratorToolkitHandlersLive),
226227
Layer.provide(OrchestratorMcpService.layer),
228+
Layer.provide(ThreadMetadataMcpService.layer),
227229
);
228230

229231
export const WorktreeToolkitRegistrationLive = McpServer.toolkit(WorktreeToolkit).pipe(

‎apps/server/src/mcp/OrchestratorMcpService.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,7 @@ function listItemFromShell(shell: OrchestrationV2ThreadShell): OrchestratorMcpTh
549549
model: shell.modelSelection.model,
550550
runtimeMode: shell.runtimeMode,
551551
interactionMode: shell.interactionMode,
552+
linkedPullRequest: shell.linkedPullRequest ?? null,
552553
parentThreadId: shell.lineage.parentThreadId,
553554
relationshipToParent: shell.lineage.relationshipToParent,
554555
itemCount: shell.visibleItemCount,
@@ -573,6 +574,15 @@ function threadDetail(projection: OrchestrationV2ThreadProjection): Orchestrator
573574
model: projection.thread.modelSelection.model,
574575
runtimeMode: projection.thread.runtimeMode,
575576
interactionMode: projection.thread.interactionMode,
577+
linkedPullRequest: projection.thread.linkedPullRequest ?? null,
578+
titleRegeneration:
579+
projection.thread.titleRegeneration === undefined ||
580+
projection.thread.titleRegeneration === null
581+
? null
582+
: {
583+
requestId: projection.thread.titleRegeneration.requestId,
584+
startedAt: DateTime.formatIso(projection.thread.titleRegeneration.startedAt),
585+
},
576586
branch: projection.thread.branch,
577587
worktreePath: projection.thread.worktreePath,
578588
parentThreadId: projection.thread.lineage.parentThreadId,

0 commit comments

Comments
 (0)