diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 607c32920464..7142e22da5c1 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -3,6 +3,7 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { actionResultPresentation, @@ -114,8 +115,10 @@ import { } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { useAssetUrl, useAssetUrlState } from "../../state/assets"; +import { useThreadShell } from "../../state/entities"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; +import { resolveThreadStatus } from "./threadPresentation"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", @@ -980,6 +983,7 @@ function renderFeedEntry( readonly onToggleActionFollowUp: (rowId: string) => void; readonly onPressImage: (uri: string, headers?: Record) => void; readonly onMarkdownLinkPress: (href: string) => void; + readonly onOpenSourceThread: (sourceThreadId: ThreadId) => void; readonly renderMarkdownImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; @@ -1074,6 +1078,25 @@ function renderFeedEntry( !assistantTurnStillInProgress && !message.streaming; + if (isUser && message.sourceThreadId !== undefined) { + return ( + + ); + } + if (isUser) { const enterAnimated = isFreshTimestamp(message.createdAt); return ( @@ -1206,6 +1229,117 @@ function renderFeedEntry( ); } +function AgentMessageTimelineRow(props: { + readonly entry: Extract; + readonly environmentId: EnvironmentId; + readonly iconSubtleColor: ColorValue; + readonly markdownStyles: MarkdownStyleSet; + readonly maxWidth: number; + readonly onLinkPress: (href: string) => void; + readonly onOpenSourceThread: (sourceThreadId: ThreadId) => void; + readonly onPressImage: (uri: string, headers?: Record) => void; + readonly renderImage: MarkdownImageRenderer; + readonly reviewCommentColors: ReviewCommentColors; + readonly skills?: ReadonlyArray; + readonly sourceThreadId: ThreadId; +}) { + const source = useThreadShell(scopeThreadRef(props.environmentId, props.sourceThreadId)); + const resolvedStatus = source ? resolveThreadStatus(source) : null; + const status = source + ? (resolvedStatus ?? { + label: "Ready", + iconColor: props.iconSubtleColor, + }) + : null; + const sourceTitle = source?.title ?? "Source thread unavailable"; + const message = props.entry.message; + const attachments = (message.attachments ?? []).filter( + (attachment) => attachment.type === "image", + ); + + return ( + + + + + AGENT MESSAGE + + {status ? ( + + + {status.label} + + ) : null} + {source ? ( + props.onOpenSourceThread(props.sourceThreadId)} + > + + {sourceTitle.toLocaleLowerCase()} + + + + ) : ( + + {sourceTitle.toLocaleLowerCase()} + + )} + + {formatMessageTime(message.createdAt)} + + + {message.text.trim().length > 0 ? ( + + ) : null} + {attachments.map((attachment) => ( + + ))} + + {message.text.trim().length > 0 ? ( + + + + ) : null} + + ); +} + const ActionFollowUpCard = memo(function ActionFollowUpCard(props: { readonly actionName: string; readonly outcome: ActionResultPresentationOutcome; @@ -1731,6 +1865,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, [props.environmentId, props.threadId, props.workspaceRoot, navigation], ); + const onOpenSourceThread = useCallback( + (sourceThreadId: ThreadId) => { + navigation.navigate("Thread", { + environmentId: String(props.environmentId), + threadId: String(sourceThreadId), + }); + }, + [navigation, props.environmentId], + ); const renderMarkdownImage = useCallback( (image) => { const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); @@ -2167,6 +2310,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleActionFollowUp, onPressImage, onMarkdownLinkPress, + onOpenSourceThread, renderMarkdownImage, iconSubtleColor, userBubbleColor, @@ -2190,6 +2334,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { userBubbleMaxWidth, onCopyWorkRow, onMarkdownLinkPress, + onOpenSourceThread, onPressImage, onToggleTurnFold, onToggleActionFollowUp, diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index 7eb2172e8291..dcb4afc1d883 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -622,6 +622,7 @@ it.effect("prepares and dispatches an exact accepted send using the target threa commandId: CommandId.make("command-send"), messageId: MessageId.make("message-send"), createdAt: "2026-08-22T00:00:00.000Z", + sourceThreadId: ThreadId.make("thread-source"), }); assert.deepStrictEqual(result, { @@ -643,12 +644,37 @@ it.effect("prepares and dispatches an exact accepted send using the target threa }, runtimeMode: "approval-required", interactionMode: "plan", + sourceThreadId: "thread-source", createdAt: "2026-08-22T00:00:00.000Z", }, ]); }), ); +it.effect("does not mark a send to the current thread as cross-thread", () => + Effect.gen(function* () { + const { source } = runnerSource(); + const dispatched: unknown[] = []; + yield* sendThreadOutput( + { + descriptor: source.descriptor, + shell: source.shell, + dispatch: (command) => Effect.sync(() => dispatched.push(command)), + }, + { + identifier: "thread-runner", + message: "status", + commandId: CommandId.make("command-self-send"), + messageId: MessageId.make("message-self-send"), + createdAt: "2026-08-22T00:00:00.000Z", + sourceThreadId: ThreadId.make("thread-runner"), + }, + ); + + assert.notProperty(dispatched[0] as object, "sourceThreadId"); + }), +); + it.effect("marks only explicitly tracked sends for wait correlation", () => Effect.gen(function* () { const { source } = runnerSource(); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 8f1dc2026280..e3a413537d57 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -512,6 +512,7 @@ export const sendThreadOutput = Effect.fn("sendThreadOutput")(function* ( readonly commandId: CommandId; readonly messageId: MessageId; readonly createdAt: string; + readonly sourceThreadId?: ThreadId; readonly trackRequestCorrelation?: true; readonly rejectWaitForThreadId?: ThreadId; }, @@ -558,6 +559,9 @@ export const sendThreadOutput = Effect.fn("sendThreadOutput")(function* ( }, runtimeMode: resolution.thread.runtimeMode, interactionMode: resolution.thread.interactionMode, + ...(input.sourceThreadId !== undefined && input.sourceThreadId !== resolution.thread.id + ? { sourceThreadId: input.sourceThreadId } + : {}), ...(input.trackRequestCorrelation === true ? { trackRequestCorrelation: true } : {}), createdAt: input.createdAt, }); @@ -946,6 +950,9 @@ const runThreadSend = Effect.fn("runThreadSend")(function* ( commandId, messageId, createdAt: DateTime.formatIso(yield* DateTime.now), + ...(process.env.T3CODE_THREAD_ID?.trim() + ? { sourceThreadId: ThreadId.make(process.env.T3CODE_THREAD_ID.trim()) } + : {}), ...(waitForCompletion ? { trackRequestCorrelation: true as const } : {}), ...(waitForCompletion && process.env.T3CODE_THREAD_ID?.trim() ? { rejectWaitForThreadId: ThreadId.make(process.env.T3CODE_THREAD_ID.trim()) } diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 4da34eca72ae..e6b05e968485 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1083,6 +1083,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti role: event.payload.role, text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), + ...(event.payload.sourceThreadId !== undefined || + previousMessage?.sourceThreadId !== undefined + ? { sourceThreadId: event.payload.sourceThreadId ?? previousMessage?.sourceThreadId } + : {}), isStreaming: event.payload.streaming, createdAt: previousMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 6c6f8f662c41..2556d88f82ac 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -130,6 +130,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { turn_id, role, text, + source_thread_id, is_streaming, created_at, updated_at @@ -140,6 +141,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'turn-1', 'assistant', 'hello from projection', + 'thread-source', 0, '2026-02-24T00:00:04.000Z', '2026-02-24T00:00:05.000Z' @@ -354,6 +356,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asMessageId("message-1"), role: "assistant", text: "hello from projection", + sourceThreadId: ThreadId.make("thread-source"), turnId: asTurnId("turn-1"), streaming: false, createdAt: "2026-02-24T00:00:04.000Z", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3ab5e3b503db..5c5a2905e63d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -87,6 +87,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + sourceThreadId: Schema.NullOr(ThreadId), }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; @@ -560,6 +561,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { role, text, attachments_json AS "attachments", + source_thread_id AS "sourceThreadId", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -1008,6 +1010,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { role, text, attachments_json AS "attachments", + source_thread_id AS "sourceThreadId", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -1255,6 +1258,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { role, text, attachments_json AS "attachments", + source_thread_id AS "sourceThreadId", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -1597,6 +1601,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { role: row.role, text: row.text, ...(row.attachments !== null ? { attachments: row.attachments } : {}), + ...(row.sourceThreadId !== null ? { sourceThreadId: row.sourceThreadId } : {}), turnId: row.turnId, streaming: row.isStreaming === 1, createdAt: row.createdAt, @@ -2699,6 +2704,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.messageId, role: row.role, text: row.text, + ...(row.sourceThreadId !== null ? { sourceThreadId: row.sourceThreadId } : {}), turnId: row.turnId, streaming: row.isStreaming === 1, createdAt: row.createdAt, diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index bf5c509fa16b..05779b7a8d3b 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -271,7 +271,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { updatedAt: now, }, }); - const readModel = yield* projectEvent(withProject, { + const withTargetThread = yield* projectEvent(withProject, { sequence: 2, eventId: asEventId("evt-thread-create"), aggregateKind: "thread", @@ -298,6 +298,33 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { updatedAt: now, }, }); + const readModel = yield* projectEvent(withTargetThread, { + sequence: 3, + eventId: asEventId("evt-source-thread-create"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-source"), + type: "thread.created", + occurredAt: now, + commandId: CommandId.make("cmd-source-thread-create"), + causationEventId: null, + correlationId: CommandId.make("cmd-source-thread-create"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-source"), + projectId: asProjectId("project-1"), + title: "Source thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); const result = yield* decideOrchestrationCommand({ command: { @@ -316,6 +343,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { ]), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", + sourceThreadId: ThreadId.make("thread-source"), createdAt: now, }, readModel, @@ -325,6 +353,9 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { const events = Array.isArray(result) ? result : [result]; expect(events).toHaveLength(2); expect(events[0]?.type).toBe("thread.message-sent"); + if (events[0]?.type === "thread.message-sent") { + expect(events[0].payload.sourceThreadId).toBe("thread-source"); + } const turnStartEvent = events[1]; expect(turnStartEvent?.type).toBe("thread.turn-start-requested"); expect(turnStartEvent?.causationEventId).toBe(events[0]?.eventId ?? null); @@ -340,6 +371,28 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { ]), runtimeMode: "approval-required", }); + + const selfSourceFailure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-self-source"), + threadId: ThreadId.make("thread-1"), + sourceThreadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-self-source"), + role: "user", + text: "hello", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }, + readModel, + }), + ); + expect(selfSourceFailure.message).toContain("must differ from its target thread"); }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 774d92a64311..9afc8c00c02e 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1367,6 +1367,19 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + if (command.sourceThreadId === command.threadId) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "A cross-thread message source must differ from its target thread.", + }); + } + if (command.sourceThreadId !== undefined) { + yield* requireThread({ + readModel, + command, + threadId: command.sourceThreadId, + }); + } const sourceProposedPlan = command.sourceProposedPlan; const sourceThread = sourceProposedPlan ? yield* requireThread({ @@ -1405,6 +1418,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" role: command.message.role, text: command.message.text, attachments: command.message.attachments, + ...(command.sourceThreadId !== undefined + ? { sourceThreadId: command.sourceThreadId } + : {}), turnId: null, streaming: false, createdAt: command.createdAt, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 984dc93cd1b8..f4a79b0f731b 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -577,6 +577,9 @@ export function projectEvent( role: payload.role, text: payload.text, ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}), + ...(payload.sourceThreadId !== undefined + ? { sourceThreadId: payload.sourceThreadId } + : {}), turnId: payload.turnId, streaming: payload.streaming, createdAt: payload.createdAt, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index b1f394a9e577..d7b6bc11a80c 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -36,6 +36,7 @@ layer("ProjectionThreadMessageRepository", (it) => { role: "user", text: "initial", attachments: persistedAttachments, + sourceThreadId: ThreadId.make("thread-source"), isStreaming: false, createdAt, updatedAt, @@ -62,6 +63,7 @@ layer("ProjectionThreadMessageRepository", (it) => { if (rowById._tag === "Some") { assert.equal(rowById.value.text, "updated"); assert.deepEqual(rowById.value.attachments, persistedAttachments); + assert.equal(rowById.value.sourceThreadId, "thread-source"); } }), ); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 719191668869..4ff6e4483a80 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ChatAttachment } from "@t3tools/contracts"; +import { ChatAttachment, ThreadId } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -21,6 +21,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + sourceThreadId: Schema.NullOr(ThreadId), }), ); @@ -37,6 +38,7 @@ function toProjectionThreadMessage( createdAt: row.createdAt, updatedAt: row.updatedAt, ...(row.attachments !== null ? { attachments: row.attachments } : {}), + ...(row.sourceThreadId !== null ? { sourceThreadId: row.sourceThreadId } : {}), }; } @@ -56,6 +58,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { role, text, attachments_json, + source_thread_id, is_streaming, created_at, updated_at @@ -74,6 +77,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { WHERE message_id = ${row.messageId} ) ), + ${row.sourceThreadId ?? null}, ${row.isStreaming ? 1 : 0}, ${row.createdAt}, ${row.updatedAt} @@ -88,6 +92,10 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { excluded.attachments_json, projection_thread_messages.attachments_json ), + source_thread_id = COALESCE( + excluded.source_thread_id, + projection_thread_messages.source_thread_id + ), is_streaming = excluded.is_streaming, created_at = excluded.created_at, updated_at = excluded.updated_at @@ -107,6 +115,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { role, text, attachments_json AS "attachments", + source_thread_id AS "sourceThreadId", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -128,6 +137,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { role, text, attachments_json AS "attachments", + source_thread_id AS "sourceThreadId", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 02df09d6669d..5447d209ad68 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -63,6 +63,7 @@ import Migration0047 from "./Migrations/047_ProjectionTurnRequestCorrelations.ts import Migration0048 from "./Migrations/048_ProjectionThreadWorktreeCleanup.ts"; import Migration0049 from "./Migrations/049_ProjectionThreadLinkedPullRequest.ts"; import Migration0050 from "./Migrations/050_ProjectionThreadsUnsettledAt.ts"; +import Migration0051 from "./Migrations/051_ProjectionThreadMessageSource.ts"; /** * Migration loader with all migrations defined inline. @@ -125,6 +126,7 @@ export const migrationEntries = [ [48, "ProjectionThreadWorktreeCleanup", Migration0048], [49, "ProjectionThreadLinkedPullRequest", Migration0049], [50, "ProjectionThreadsUnsettledAt", Migration0050], + [51, "ProjectionThreadMessageSource", Migration0051], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageSource.test.ts b/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageSource.test.ts new file mode 100644 index 000000000000..5e27f9f4fa89 --- /dev/null +++ b/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageSource.test.ts @@ -0,0 +1,31 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("051_ProjectionThreadMessageSource", (it) => { + it.effect("adds nullable source thread provenance to projected messages", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 50 }); + const before = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_messages) + `; + assert.isFalse(before.some((column) => column.name === "source_thread_id")); + + const executed = yield* runMigrations({ toMigrationInclusive: 51 }); + assert.deepStrictEqual(executed, [[51, "ProjectionThreadMessageSource"]]); + + const after = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_messages) + `; + assert.equal(after.filter((column) => column.name === "source_thread_id").length, 1); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageSource.ts b/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageSource.ts new file mode 100644 index 000000000000..14410b25199f --- /dev/null +++ b/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageSource.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_messages) + `; + + if (!columns.some((column) => column.name === "source_thread_id")) { + yield* sql` + ALTER TABLE projection_thread_messages + ADD COLUMN source_thread_id TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff3202563..9559629af63e 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -28,6 +28,7 @@ export const ProjectionThreadMessage = Schema.Struct({ role: OrchestrationMessageRole, text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), + sourceThreadId: Schema.optional(ThreadId), isStreaming: Schema.Boolean, createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/web/package.json b/apps/web/package.json index 2e0d334934be..e285634cd091 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -65,6 +65,7 @@ "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", "compression": "^1.8.1", + "happy-dom": "^20.12.0", "msw": "2.12.11", "tailwindcss": "^4.0.0", "vite": "catalog:", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index df2c61a7eae8..3a90a6584530 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -230,7 +230,11 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; -import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; +import { + buildDraftThreadRouteParams, + buildThreadRouteLocation, + buildThreadRouteParams, +} from "../threadRoutes"; import { beginBackgroundDraftSubmissionByRef, clearBackgroundDraftSubmissionByRef, @@ -1863,6 +1867,13 @@ function ChatViewContent(props: ChatViewProps) { : null; const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const clientSettingsHydrated = useClientSettingsHydrated(); + const openAgentMessageSourceThread = useCallback( + (threadId: ThreadId) => { + if (!activeThread) return; + void navigate(buildThreadRouteLocation(scopeThreadRef(activeThread.environmentId, threadId))); + }, + [activeThread, navigate], + ); const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< ReadonlyMap> >(() => new Map()); @@ -7405,6 +7416,7 @@ function ChatViewContent(props: ChatViewProps) { runningTurnId={activeRunningTurnId} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} activeThreadEnvironmentId={activeThread.environmentId} + onOpenSourceThread={openAgentMessageSourceThread} routeThreadKey={routeThreadKey} onOpenTurnDiff={onOpenTurnDiff} revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 9c8a28209a2d..9b43251f0d3c 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -467,7 +467,7 @@ export function ThreadStatusLabel({ status, compact = false, }: { - status: ThreadStatusPill; + status: Pick & { label: string }; compact?: boolean; }) { if (compact) { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0bdccab23133..4c9a0432dcd1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,10 +1,16 @@ -import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; +import { CheckpointRef, EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { formatActionResumeFollowUp } from "@t3tools/shared/actionResume"; -import { createRef, type ReactNode, type Ref } from "react"; +import { act, createRef, type ReactNode, type Ref } from "react"; +import { createRoot } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef } from "@legendapp/list/react"; +import { Window } from "happy-dom"; +import { buildThreadRouteLocation } from "../../threadRoutes"; + +const threadShellMockState = vi.hoisted(() => ({ available: true })); vi.mock("@legendapp/list/react", async () => { const legendListTestId = "legend-list"; @@ -127,6 +133,29 @@ vi.mock("@pierre/diffs/react", () => { return { FileDiff: MockFileDiff }; }); +vi.mock("../../state/entities", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useThreadShell: () => + threadShellMockState.available + ? { + id: ThreadId.make("thread-source"), + title: "Mobile Reconnect Issue", + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, + interactionMode: "default", + latestTurn: null, + session: { status: "running" }, + backgroundLiveness: null, + actionResume: null, + worktreeCleanup: null, + } + : null, + }; +}); + function matchMedia() { return { matches: false, @@ -135,6 +164,45 @@ function matchMedia() { }; } +function installBrowserGlobals(browser: Window) { + const values = { + window: browser, + document: browser.document, + navigator: browser.navigator, + Node: browser.Node, + Element: browser.Element, + HTMLElement: browser.HTMLElement, + HTMLIFrameElement: browser.HTMLIFrameElement, + Event: browser.Event, + MouseEvent: browser.MouseEvent, + MutationObserver: browser.MutationObserver, + ResizeObserver: browser.ResizeObserver, + getComputedStyle: browser.getComputedStyle.bind(browser), + requestAnimationFrame: browser.requestAnimationFrame.bind(browser), + cancelAnimationFrame: browser.cancelAnimationFrame.bind(browser), + IS_REACT_ACT_ENVIRONMENT: true, + } as const; + const descriptors = new Map( + Object.keys(values).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ); + + for (const [key, value] of Object.entries(values)) { + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + } + + return { + restore() { + for (const [key, descriptor] of descriptors) { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + } else { + Reflect.deleteProperty(globalThis, key); + } + } + }, + }; +} + let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; beforeAll(async () => { @@ -238,6 +306,120 @@ function buildAssistantTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("renders cross-thread posts as agent messages with their attachments", () => { + const sourceThreadId = ThreadId.make("thread-source"); + const entry = buildUserTimelineEntry("The reconnect fix is ready for review."); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("AGENT MESSAGE"); + expect(markup).toContain("Mobile Reconnect Issue"); + expect(markup).toContain("Working"); + expect(markup).not.toContain("animate-status-pulse"); + expect(markup).toContain('aria-label="Open source thread: Mobile Reconnect Issue"'); + expect(markup).toContain('aria-label="Revert to this message"'); + expect(markup).toContain('download="handoff.pdf"'); + }); + + it("navigates to the source thread when the rendered title is activated", async () => { + const sourceThreadId = ThreadId.make("thread-source"); + const entry = buildUserTimelineEntry("The reconnect fix is ready for review."); + const navigate = vi.fn(); + const browser = new Window({ url: "https://lastcode.test" }); + const globals = installBrowserGlobals(browser); + const container = browser.document.createElement("div"); + browser.document.body.append(container); + const root = createRoot(container as unknown as Element); + + try { + await act(() => { + root.render( + { + navigate( + buildThreadRouteLocation(scopeThreadRef(ACTIVE_THREAD_ENVIRONMENT_ID, threadId)), + ); + }} + timelineEntries={[ + { + ...entry, + message: { ...entry.message, sourceThreadId }, + }, + ]} + />, + ); + }); + + const sourceLink = container.querySelector( + 'button[aria-label="Open source thread: Mobile Reconnect Issue"]', + ); + expect(sourceLink).not.toBeNull(); + await act(() => + sourceLink?.dispatchEvent(new browser.MouseEvent("click", { bubbles: true })), + ); + expect(navigate).toHaveBeenCalledOnce(); + expect(navigate).toHaveBeenCalledWith({ + to: "/$environmentId/$threadId", + params: { + environmentId: ACTIVE_THREAD_ENVIRONMENT_ID, + threadId: sourceThreadId, + }, + }); + } finally { + await act(() => root.unmount()); + globals.restore(); + await browser.close(); + } + }); + + it("does not link to a source thread that is no longer available", () => { + threadShellMockState.available = false; + try { + const sourceThreadId = ThreadId.make("thread-source"); + const entry = buildUserTimelineEntry("The reconnect fix is ready for review."); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("source thread unavailable"); + expect(markup).not.toContain("Open source thread:"); + } finally { + threadShellMockState.available = true; + } + }); + it("shows compact completed Action results without embedding detailed output", () => { const actionText = formatActionResumeFollowUp({ actionName: "Run Full CI", diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8b9908af56df..b04cac050606 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -5,9 +5,10 @@ import { type ScopedThreadRef, type ServerProviderSkill, type ThreadAnnotation, + type ThreadId, type TurnId, } from "@t3tools/contracts"; -import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; import { emptyAgentPanelModel, @@ -16,6 +17,7 @@ import { const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); const NOOP_OPEN_AGENTS = () => {}; +const NOOP_OPEN_SOURCE_THREAD = (_threadId: ThreadId) => {}; const NOOP_DOWNLOAD_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { actionResultPresentation, parseActionResumeFollowUp } from "@t3tools/shared/actionResume"; @@ -43,6 +45,8 @@ import { workLogEntryIsToolLike, } from "../../session-logic"; import { + type ChatAttachment, + type ChatFileAttachment as WebChatFileAttachment, type ChatImageAttachment, isFileAttachment, isImageAttachment, @@ -55,6 +59,7 @@ import { } from "../../lib/diffRendering"; import ChatMarkdown from "../ChatMarkdown"; import { + ArrowUpRightIcon, BotIcon, CheckIcon, ChevronDownIcon, @@ -135,6 +140,10 @@ import { } from "./userMessageTerminalContexts"; import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; +import { resolveThreadStatusPill } from "../Sidebar.logic"; +import { ThreadStatusLabel } from "../ThreadStatusIndicators"; +import { useClientSettings } from "../../hooks/useSettings"; +import { useThreadShell } from "../../state/entities"; import { buildReviewCommentRenderablePatch, formatReviewCommentFence, @@ -158,6 +167,7 @@ interface TimelineRowSharedState { workspaceRoot: string | undefined; skills: ReadonlyArray>; activeThreadEnvironmentId: EnvironmentId; + onOpenSourceThread: (threadId: ThreadId) => void; onRevertUserMessage: (messageId: MessageId) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onFileDownload: (attachment: ChatFileAttachment) => void; @@ -244,6 +254,7 @@ interface MessagesTimelineProps { onImageExpand: (preview: ExpandedImagePreview) => void; onFileDownload?: (attachment: ChatFileAttachment) => void; activeThreadEnvironmentId: EnvironmentId; + onOpenSourceThread?: (threadId: ThreadId) => void; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; @@ -296,6 +307,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onFileDownload = NOOP_DOWNLOAD_ATTACHMENT, activeThreadEnvironmentId, + onOpenSourceThread = NOOP_OPEN_SOURCE_THREAD, markdownCwd, resolvedTheme, timestampFormat, @@ -576,6 +588,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills, activeThreadEnvironmentId, + onOpenSourceThread, onRevertUserMessage, onImageExpand, onFileDownload, @@ -595,6 +608,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills, activeThreadEnvironmentId, + onOpenSourceThread, onRevertUserMessage, onImageExpand, onFileDownload, @@ -1206,7 +1220,16 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "work-live" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} - {row.kind === "message" && row.message.role === "user" ? : null} + {row.kind === "message" && + row.message.role === "user" && + row.message.sourceThreadId !== undefined ? ( + + ) : null} + {row.kind === "message" && + row.message.role === "user" && + row.message.sourceThreadId === undefined ? ( + + ) : null} {row.kind === "message" && row.message.role === "assistant" ? ( ) : null} @@ -1221,6 +1244,208 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ); }); +function AgentMessageTimelineRow({ + row, + sourceThreadId, +}: { + row: Extract; + sourceThreadId: ThreadId; +}) { + const ctx = use(TimelineRowCtx); + + const source = useThreadShell(scopeThreadRef(ctx.activeThreadEnvironmentId, sourceThreadId)); + const compactStatus = useClientSettings((settings) => settings.compactLegacySidebarStatuses); + const resolvedStatus = source + ? (resolveThreadStatusPill({ thread: source }) ?? + (source.session?.status === "error" + ? { + label: "Failed", + colorClass: "text-red-700 dark:text-red-300", + dotClass: "bg-red-600 dark:bg-red-300", + pulse: false, + } + : { + label: "Ready", + colorClass: "text-muted-foreground", + dotClass: "bg-muted-foreground/55", + pulse: false, + })) + : null; + const status = resolvedStatus ? { ...resolvedStatus, pulse: false } : null; + const canRevertAgentWork = typeof row.revertTurnCount === "number"; + const sourceTitle = source?.title ?? "source thread"; + const images = (row.message.attachments ?? []).filter(isImageAttachment); + const files = (row.message.attachments ?? []).filter(isFileAttachment); + const unknownAttachments = (row.message.attachments ?? []).filter( + (attachment) => !isImageAttachment(attachment) && !isFileAttachment(attachment), + ); + + return ( +
+
+
+ + AGENT MESSAGE + + + {status ? : null} + + + } + > + {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} + + + {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} + + +
+ + +
+ {canRevertAgentWork ? ( +
+ +
+ ) : null} +
+ ); +} + +function AgentMessageSourceTitle({ + sourceThreadId, + sourceTitle, + onOpenSourceThread, +}: { + sourceThreadId: ThreadId; + sourceTitle: string | null; + onOpenSourceThread: (threadId: ThreadId) => void; +}) { + return sourceTitle ? ( + + ) : ( + + source thread unavailable + + ); +} + +function MessageAttachments({ + images, + files, + unknown, + children, +}: { + images: ReadonlyArray; + files: ReadonlyArray; + unknown: ReadonlyArray; + children?: ReactNode; +}) { + const ctx = use(TimelineRowCtx); + + return ( + <> + {images.length > 0 ? ( +
+ {images.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} +
+ ))} +
+ ) : null} + {children} + {files.length > 0 || unknown.length > 0 ? ( +
+ {files.map((file) => { + const content = ( + <> + + {file.name} + {file.downloadable === false ? null : } + + ); + return file.previewUrl ? ( + + {content} + + ) : file.downloadable === false ? ( +
+ {content} +
+ ) : ( + + ); + })} + {unknown.map((attachment) => ( +
+ + {attachment.name} +
+ ))} +
+ ) : null} + + ); +} + function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); // The attachment union has an open member, so guards (not literal type @@ -1252,91 +1477,15 @@ function UserTimelineRow({ row }: { row: Extract
- {regularImages.length > 0 && ( -
- {regularImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} -
- ))} -
- )} - {previewAnnotations.map((annotation, index) => ( - - ))} - {userFiles.length > 0 || unknownAttachments.length > 0 ? ( -
- {userFiles.map((file) => { - const content = ( - <> - - {file.name} - {file.downloadable === false ? null : ( - - )} - - ); - return file.previewUrl ? ( - - {content} - - ) : file.downloadable === false ? ( -
- {content} -
- ) : ( - - ); - })} - {unknownAttachments.map((attachment) => ( -
- - {attachment.name} -
- ))} -
- ) : null} + + {previewAnnotations.map((annotation, index) => ( + + ))} + {elementContexts.length > 0 ? (
{elementContexts.map((context) => ( diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index fd5bc39d836a..273588e05cbb 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -49,6 +49,16 @@ export function buildThreadRouteParams(ref: ScopedThreadRef): { }; } +export function buildThreadRouteLocation(ref: ScopedThreadRef): { + to: "/$environmentId/$threadId"; + params: ReturnType; +} { + return { + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(ref), + }; +} + export function buildDraftThreadRouteParams(draftId: DraftId): { draftId: DraftId; } { diff --git a/docs/user/codex-thread-tools.md b/docs/user/codex-thread-tools.md index 7815e006fb09..3d15e1a751c1 100644 --- a/docs/user/codex-thread-tools.md +++ b/docs/user/codex-thread-tools.md @@ -33,6 +33,10 @@ confirms that LastCode persisted the request, not that the provider finished it. oversized messages, missing or ambiguous targets, authorization failures, and rejected dispatches fail without reporting acceptance. +Messages sent from one LastCode thread to another appear in the target conversation as agent +message cards. The card links back to the source thread and shows its current status. A plain +`send` to the caller's own thread remains a normal user message. + Add `--wait` when the caller needs the exact resulting turn rather than dispatch acceptance. `send --wait` cannot target the caller's current thread because that queued turn cannot begin until the current command returns; use plain `send` for a self-directed follow-up. diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index c990466fa15e..19f069d0a330 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -396,6 +396,7 @@ describe("applyThreadDetailEvent", () => { messageId: MessageId.make("msg-1"), role: "user", text: "Hello, world!", + sourceThreadId: ThreadId.make("thread-source"), turnId: null, streaming: false, createdAt: "2026-04-01T06:00:00.000Z", @@ -407,6 +408,7 @@ describe("applyThreadDetailEvent", () => { if (result.kind === "updated") { expect(result.thread.messages).toHaveLength(1); expect(result.thread.messages[0]?.text).toBe("Hello, world!"); + expect(result.thread.messages[0]?.sourceThreadId).toBe("thread-source"); } }); diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index fa7e1ce6f37e..c4939da298c5 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -312,6 +312,9 @@ export function applyThreadDetailEvent( ...(event.payload.attachments !== undefined ? { attachments: event.payload.attachments } : {}), + ...(event.payload.sourceThreadId !== undefined + ? { sourceThreadId: event.payload.sourceThreadId } + : {}), turnId: event.payload.turnId, streaming: event.payload.streaming, createdAt: event.payload.createdAt, diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 2da7acf3a2b3..87d08459af8a 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -318,6 +318,50 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", () }), ); +it.effect("preserves cross-thread message provenance", () => + Effect.gen(function* () { + const sourceThreadId = "thread-source"; + const command = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-cross-thread", + threadId: "thread-target", + sourceThreadId, + message: { + messageId: "msg-cross-thread", + role: "user", + text: "status update", + attachments: [], + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + const message = yield* decodeOrchestrationMessage({ + id: "msg-cross-thread", + role: "user", + text: "status update", + sourceThreadId, + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const payload = yield* decodeThreadMessageSentPayload({ + threadId: "thread-target", + messageId: "msg-cross-thread", + role: "user", + text: "status update", + sourceThreadId, + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.strictEqual(command.sourceThreadId, sourceThreadId); + assert.strictEqual(message.sourceThreadId, sourceThreadId); + assert.strictEqual(payload.sourceThreadId, sourceThreadId); + }), +); + it.effect("accepts inline images, uploaded images, and uploaded files from clients", () => Effect.gen(function* () { const command = yield* decodeClientOrchestrationCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index d169282fbb9c..22ea1232ef39 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -517,6 +517,7 @@ export const OrchestrationMessage = Schema.Struct({ role: OrchestrationMessageRole, text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), + sourceThreadId: Schema.optional(ThreadId), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, createdAt: IsoDateTime, @@ -1224,6 +1225,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)), ), bootstrap: Schema.optional(ThreadTurnStartBootstrap), + sourceThreadId: Schema.optional(ThreadId), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), trackRequestCorrelation: Schema.optional(Schema.Literal(true)), createdAt: IsoDateTime, @@ -1244,6 +1246,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, bootstrap: Schema.optional(ThreadTurnStartBootstrap), + sourceThreadId: Schema.optional(ThreadId), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), trackRequestCorrelation: Schema.optional(Schema.Literal(true)), createdAt: IsoDateTime, @@ -1691,6 +1694,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ role: OrchestrationMessageRole, text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), + sourceThreadId: Schema.optional(ThreadId), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, createdAt: IsoDateTime, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdcca6f13e1f..6b4288325205 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,7 +118,7 @@ importers: version: 7.0.0-dev.20260604.1 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/desktop: dependencies: @@ -185,7 +185,7 @@ importers: version: 4.3.0 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/marketing: dependencies: @@ -527,7 +527,7 @@ importers: version: link:../../packages/effect-codex-app-server vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/web: dependencies: @@ -685,6 +685,9 @@ importers: compression: specifier: ^1.8.1 version: 1.8.1 + happy-dom: + specifier: ^20.12.0 + version: 20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) msw: specifier: 2.12.11 version: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) @@ -696,7 +699,7 @@ importers: version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) infra/relay: dependencies: @@ -751,7 +754,7 @@ importers: version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-plugin-t3code: dependencies: @@ -770,7 +773,7 @@ importers: version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/client-runtime: dependencies: @@ -789,7 +792,7 @@ importers: version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/contracts: dependencies: @@ -802,7 +805,7 @@ importers: version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-acp: dependencies: @@ -824,7 +827,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-codex-app-server: dependencies: @@ -846,7 +849,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/shared: dependencies: @@ -880,7 +883,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/ssh: dependencies: @@ -905,7 +908,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/tailscale: dependencies: @@ -927,7 +930,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) scripts: dependencies: @@ -967,7 +970,7 @@ importers: version: 6.0.5 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages: @@ -4837,6 +4840,9 @@ packages: '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/whatwg-url@11.0.5': resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==} @@ -5576,6 +5582,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + bufferutil@4.1.0: resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} engines: {node: '>=6.14.2'} @@ -6326,6 +6336,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -7001,6 +7015,10 @@ packages: h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + happy-dom@20.12.0: + resolution: {integrity: sha512-7uMYJu2SEwwL8vVcKp0C0lnt6d2LSGGe+T+oY79PiCJNNSgFpbxW8n5KuzpDQvrU4mt+fYiK1+Jy7Z2v39YR6g==} + engines: {node: '>=20.0.0'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -10185,6 +10203,10 @@ packages: whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-url-minimum@0.1.2: resolution: {integrity: sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==} @@ -14624,6 +14646,8 @@ snapshots: '@types/webidl-conversions@7.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/whatwg-url@11.0.5': dependencies: '@types/webidl-conversions': 7.0.3 @@ -14702,7 +14726,7 @@ snapshots: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) transitivePeerDependencies: - bufferutil - msw @@ -14718,7 +14742,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -15476,6 +15500,10 @@ snapshots: buffer-from@1.1.2: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 24.12.4 + bufferutil@4.1.0: dependencies: node-gyp-build: 4.8.4 @@ -16129,6 +16157,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -17090,6 +17120,19 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 + happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + '@types/node': 24.12.4 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -18876,7 +18919,7 @@ snapshots: outvariant@1.4.3: {} - oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -18899,7 +18942,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.57.0 '@oxfmt/binding-win32-ia32-msvc': 0.57.0 '@oxfmt/binding-win32-x64-msvc': 0.57.0 - vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-tsgolint@0.24.0: optionalDependencies: @@ -18910,7 +18953,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.24.0 '@oxlint-tsgolint/win32-x64': 0.24.0 - oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.72.0 '@oxlint/binding-android-arm64': 1.72.0 @@ -18932,7 +18975,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.72.0 '@oxlint/binding-win32-x64-msvc': 1.72.0 oxlint-tsgolint: 0.24.0 - vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) p-cancelable@2.1.1: {} @@ -20733,7 +20776,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): + vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 @@ -20747,11 +20790,11 @@ snapshots: '@vitest/spy': 4.1.9 '@vitest/utils': 4.1.9 '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) - oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) - oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) oxlint-tsgolint: 0.24.0 vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 @@ -20795,7 +20838,7 @@ snapshots: optionalDependencies: vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): + vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) @@ -20820,6 +20863,7 @@ snapshots: optionalDependencies: '@types/node': 24.12.4 '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) + happy-dom: 20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - msw @@ -20950,6 +20994,8 @@ snapshots: whatwg-fetch@3.6.20: {} + whatwg-mimetype@3.0.0: {} + whatwg-url-minimum@0.1.2: {} whatwg-url@14.2.0: