diff --git a/apps/web/e2e/channel-archive.spec.ts b/apps/web/e2e/channel-archive.spec.ts index 85c9ff356..d238c012e 100644 --- a/apps/web/e2e/channel-archive.spec.ts +++ b/apps/web/e2e/channel-archive.spec.ts @@ -71,7 +71,7 @@ test("channel branch list shows real branches without default branch chrome", as const quickSplitRootThreadId = isRecord(quickSplitBranch) && typeof quickSplitBranch.rootThreadId === "string" ? quickSplitBranch.rootThreadId : ""; await channelComposerTextbox(page).fill("Branch-scoped message"); await page.locator(".channel-composer").getByRole("button", { name: "Send" }).click(); - await expect.poll(() => state.lastChannelMessageRequest?.threadId).toBe(quickSplitRootThreadId); + await expect.poll(() => state.lastChannelTurnRequest?.surface).toEqual({ kind: "thread", threadId: quickSplitRootThreadId }); await openArchivedItemsSettings(page); const archivedSettings = page.locator(".archived-items-settings-panel"); diff --git a/apps/web/e2e/channel.spec.ts b/apps/web/e2e/channel.spec.ts index c3b7f4b52..db03e1af3 100644 --- a/apps/web/e2e/channel.spec.ts +++ b/apps/web/e2e/channel.spec.ts @@ -103,14 +103,32 @@ function configureConcurrentChannelReplyRuns(state: MockState): { ]; state.tasks = []; state.approvals = []; + state.channelMessages = [{ + id: "message_source", + workspaceId: "workspace_default", + channelId: "channel_engineering", + kind: "message", + intent: "discuss", + authorType: "local-user", + authorId: "local-user", + body: "Start the active channel replies.", + mentions: [], + attachmentIds: [], + linkPreviewIds: [], + fileRefs: [], + status: "ready", + createdAt: "2026-05-22T00:00:59.000Z", + updatedAt: "2026-05-22T00:00:59.000Z", + }]; state.runs = fixtures.map((fixture) => ({ ...seed, id: fixture.runId, status: "running", taskId: "message_source", - threadId: "thread_channel_reply", + threadId: "thread_channel_channel_engineering", teammateId: fixture.teammateId, workUnitId: fixture.workUnitId, + requestSnapshot: { sourceMessageId: "message_source" }, errorMessage: undefined, createdAt: fixture.createdAt, startedAt: fixture.createdAt, @@ -142,13 +160,13 @@ function configureConcurrentChannelReplyRuns(state: MockState): { state.turns = [{ id: "turn_channel_reply", workspaceId: "workspace_default", - threadId: "thread_channel_reply", + threadId: "thread_channel_channel_engineering", kind: "initial_request", status: "running", title: "Ordinary channel replies", userInstruction: "Reply to the channel message.", channelId: "channel_engineering", - taskId: "message_source", + sourceItemId: "message_source", activeRunId: fixtures[1].runId, createdByActor: "local-user", createdAt: fixtures[0].createdAt, @@ -2189,8 +2207,8 @@ test("multi-role channel messages create workflow proposals instead of AI replie await channelComposerTextbox(page).fill("Architect 先拆,Engineer 实现,QA 验证,可以吗?"); await page.locator(".channel-composer").getByRole("button", { name: "Send" }).click(); - await expect.poll(() => state.lastChannelMessageRequest?.requestAiReply).toBe(true); - expect(state.lastChannelMessageRequest).not.toHaveProperty("providerPresetId"); + await expect.poll(() => state.lastChannelTurnRequest?.requestAiReply).toBe(true); + expect(state.lastChannelTurnRequest).not.toHaveProperty("providerPresetId"); await expect.poll(() => state.channelExecutionProposals.length).toBe(1); await expect.poll(() => state.channelMessages.filter((message) => isRecord(message) && message.kind === "ai_reply").length).toBe(0); const proposal = page.getByLabel("Task confirmation"); @@ -2325,7 +2343,7 @@ test("Ling Agent session acceptance matrix auto-starts executable messages", asy await channelComposerTextbox(page).fill("在 /tmp/example 下创建 README.md"); await page.locator(".channel-composer").getByRole("button", { name: "Send" }).click(); - await expect.poll(() => state.lastChannelMessageRequest?.requestAiReply).toBe(true); + await expect.poll(() => state.lastChannelTurnRequest?.requestAiReply).toBe(true); await expect.poll(() => state.channelExecutionProposals.length).toBe(1); await expect.poll(() => state.channelWorkflows.length).toBe(1); await expect.poll(() => state.channelMessages.filter((message) => isRecord(message) && message.kind === "ai_reply").length).toBe(0); @@ -2508,6 +2526,238 @@ test("ordinary channel feed hides routing and intent decision internals", async expect(state.channelIntentDecisions).toHaveLength(1); }); +test("channel context inspector exposes safe metadata and recovers from a stale segment correction", async ({ page }) => { + await useEnglishLocale(page); + const state = createBaseState({ approvalStatus: "pending" }); + const channelId = "channel_engineering"; + const threadId = "thread_context_inspect"; + const turnId = "turn_msg_context_inspect"; + const sourceItemId = "msg_context_inspect"; + const engineeringSegmentId = "segment_engineering"; + const researchSegmentId = "segment_research"; + const operationsSegmentId = "segment_operations"; + state.channelMessages = [ + { + id: sourceItemId, + workspaceId: "workspace_default", + channelId, + threadId, + kind: "message", + intent: "discuss", + authorType: "local-user", + authorId: "tian", + body: "Continue the deployment architecture review.", + mentions: [], + attachmentIds: [], + linkPreviewIds: [], + fileRefs: [], + status: "ready", + createdAt: "2026-08-10T08:00:00.000Z", + updatedAt: "2026-08-10T08:00:00.000Z", + }, + ]; + state.channelIntentDecisions = [ + { + schemaVersion: 2, + id: "intent_context_inspect", + workspaceId: "workspace_default", + channelId, + sourceTurnId: turnId, + sourceItemId, + contextSegmentId: engineeringSegmentId, + contextProjectionVersion: "ctxv2_context_inspect_r1", + action: "reply", + selectedTeammateIds: ["engineer"], + resultItemIds: [], + reason: "Continue the current deployment architecture discussion.", + status: "accepted", + createdBy: "model_policy", + createdAt: "2026-08-10T08:00:00.500Z", + updatedAt: "2026-08-10T08:00:00.500Z", + }, + ]; + const thread = { + id: threadId, + workspaceId: "workspace_default", + kind: "conversation", + channelId, + title: "Engineering channel root", + objective: "Coordinate engineering work", + status: "active", + conversationMode: { mode: "chat" }, + createdAt: "2026-08-10T08:00:00.000Z", + updatedAt: "2026-08-10T08:00:00.000Z", + }; + const turn = { + id: turnId, + workspaceId: "workspace_default", + threadId, + channelId, + kind: "manual_followup", + status: "succeeded", + title: "Continue the deployment architecture review.", + userInstruction: "Continue the deployment architecture review.", + sourceItemId, + createdByActor: "local-user", + createdAt: "2026-08-10T08:00:00.000Z", + updatedAt: "2026-08-10T08:00:01.000Z", + completedAt: "2026-08-10T08:00:01.000Z", + }; + const segments = [ + { + id: engineeringSegmentId, + threadId, + state: "active", + label: "Engineering", + createdFromTurnId: turnId, + lastActiveTurnId: turnId, + revision: 1, + createdAt: "2026-08-10T07:00:00.000Z", + updatedAt: "2026-08-10T08:00:00.000Z", + }, + { + id: researchSegmentId, + threadId, + state: "active", + label: "Research", + createdFromTurnId: "turn_research", + lastActiveTurnId: "turn_research", + revision: 1, + createdAt: "2026-08-10T07:10:00.000Z", + updatedAt: "2026-08-10T07:10:00.000Z", + }, + { + id: operationsSegmentId, + threadId, + state: "active", + label: "Operations", + createdFromTurnId: "turn_operations", + lastActiveTurnId: "turn_operations", + revision: 1, + createdAt: "2026-08-10T07:20:00.000Z", + updatedAt: "2026-08-10T07:20:00.000Z", + }, + ]; + state.channelTurnContexts[turnId] = { + thread, + turn, + sourceItemId, + segment: segments[0], + segments, + assignment: { + id: "assignment_context_inspect_r1", + turnId, + segmentId: engineeringSegmentId, + status: "committed", + revision: 1, + decisionSource: "model_policy", + confidence: 0.93, + evidenceRefs: [{ type: "conversation_item", id: sourceItemId }], + policyVersion: "continuity-v2-segmentation-v1", + createdAt: "2026-08-10T08:00:00.100Z", + }, + projection: { + projectionVersion: "ctxv2_context_inspect_r1", + sourceStateFingerprint: "source_context_inspect_r1", + threadId, + turnId, + segmentId: engineeringSegmentId, + throughItemSequence: 8, + summaryIds: ["summary_context_inspect"], + sourceRefs: [ + { type: "conversation_item", id: sourceItemId }, + { type: "context_summary", id: "summary_context_inspect" }, + ], + policyVersion: "continuity-v2-projector-v1", + visibilityFingerprint: "visibility_context_inspect", + }, + summaries: [ + { + id: "summary_context_inspect", + threadId, + segmentId: engineeringSegmentId, + scope: "segment", + status: "active", + version: 1, + coveredFromSequence: 1, + coveredThroughSequence: 6, + decisions: [], + constraints: [], + openQuestions: [], + activeWorkRefs: [], + sourceRefs: [{ type: "conversation_item", id: sourceItemId }], + policyVersion: "continuity-v2-summary-v1", + createdAt: "2026-08-10T07:50:00.000Z", + }, + ], + sourceRefs: [ + { type: "conversation_item", id: sourceItemId }, + { type: "context_summary", id: "summary_context_inspect" }, + ], + droppedItemCount: 2, + redactedItemCount: 1, + safety: { + rawPromptTextIncluded: false, + hiddenReasoningIncluded: false, + privateMemoryBodiesIncluded: false, + secretValuesIncluded: false, + }, + }; + state.staleNextChannelTurnContextCorrection = { segmentId: researchSegmentId }; + + await mockDaemon(page, state); + await page.goto("/"); + await openEngineeringChannel(page); + await page.getByRole("button", { name: "Show details" }).click(); + const dock = page.locator(".channel-side-panel.right-dock"); + await expect(dock).toBeVisible(); + await dock.getByRole("button", { name: "Activity" }).click(); + await dock.getByRole("button", { name: "Routing" }).click(); + const intentRow = dock.locator(".routing-evidence-row").filter({ hasText: "Reply" }); + await expect(intentRow).toBeVisible(); + await intentRow.getByRole("button", { name: "Inspect" }).click(); + const detail = intentRow.locator(".routing-evidence-detail"); + const contextSection = detail.locator(".channel-route-inspect-section").filter({ hasText: "Context inspect summary" }); + await expect(contextSection).toContainText(threadId); + await expect(contextSection).toContainText(turnId); + await expect(contextSection).toContainText(engineeringSegmentId); + await expect(contextSection).toContainText("ctxv2_context_inspect_r1"); + await expect(contextSection).toContainText("Item watermark8"); + await expect(contextSection).toContainText("Summary coverage1-6"); + await expect(contextSection.getByText("conversation_item:msg_context_inspect", { exact: true })).toBeVisible(); + await expect(contextSection.getByText("context_summary:summary_context_inspect", { exact: true })).toBeVisible(); + await expect(contextSection).toContainText("Dropped items2"); + await expect(contextSection).toContainText("Redacted items1"); + await expect(detail).not.toContainText("RAW_PROMPT_SENTINEL"); + await expect(detail).not.toContainText("HIDDEN_REASONING_SENTINEL"); + await expect(detail).not.toContainText("PRIVATE_MEMORY_SENTINEL"); + await expect(detail).not.toContainText("SECRET_VALUE_SENTINEL"); + + const segmentSelect = contextSection.getByLabel("Correct context segment"); + await segmentSelect.selectOption(operationsSegmentId); + await contextSection.getByRole("button", { name: "Apply context correction" }).click(); + await expect(contextSection.getByRole("alert")).toContainText("Context changed while you were editing"); + await expect(segmentSelect).toHaveValue(researchSegmentId); + expect(state.channelTurnContextCorrectionRequests[0]).toEqual({ + channelId, + turnId, + segmentId: operationsSegmentId, + expectedAssignmentRevision: 1, + }); + + await segmentSelect.selectOption(operationsSegmentId); + await contextSection.getByRole("button", { name: "Apply context correction" }).click(); + await expect(contextSection.getByRole("alert")).toHaveCount(0); + await expect(segmentSelect).toHaveValue(operationsSegmentId); + await expect(contextSection.getByText(operationsSegmentId, { exact: true })).toBeVisible(); + expect(state.channelTurnContextCorrectionRequests[1]).toEqual({ + channelId, + turnId, + segmentId: operationsSegmentId, + expectedAssignmentRevision: 2, + }); +}); + test("channel settings can add and remove teammates without preferred specialist controls", async ({ page }) => { await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); @@ -3352,7 +3602,7 @@ test("channel activity supports mentions, messages, files, links, and topic repl await expect(page.locator(".channel-composer .composer-status-row.loading")).toHaveCount(0); await expect(page.getByText("收到,我会按频道上下文推进。")).toBeVisible(); await expect(page.getByLabel("Pending message assets")).toHaveCount(0); - await expect.poll(() => state.lastChannelMessageRequest?.providerPresetId).toBe("provider_local"); + await expect.poll(() => state.lastChannelTurnRequest?.providerPresetId).toBe("provider_local"); const sentMessage = page.locator(".message-bubble").filter({ hasText: "@Engineer please refine the channel requirements" }); await expect(sentMessage.getByRole("link", { name: /brief\.txt/ })).toBeVisible(); await expect(sentMessage.getByRole("link", { name: /shot\.png/ })).toBeVisible(); @@ -3439,7 +3689,10 @@ test("channel activity supports mentions, messages, files, links, and topic repl await expect(topicPanel).toContainText("收到,我会按频道上下文推进。"); await topicPanel.getByLabel("Topic reply input").fill("Thread follow-up from the topic panel"); await topicPanel.getByRole("button", { name: "Send reply" }).click(); - await expect.poll(() => state.lastChannelMessageRequest?.threadId).toBe("msg_1"); + await expect.poll(() => state.lastChannelTurnRequest?.surface).toEqual({ + kind: "thread", + threadId: "thread_topic_channel_engineering_msg_1", + }); await expect(topicPanel).toContainText("Thread follow-up from the topic panel"); }); @@ -3602,6 +3855,12 @@ test("latest channel prompt edits in place and hides its superseded reply", asyn await expect(feed).not.toContainText("Old channel response should disappear."); await expect.poll(() => state.lastConversationMessageEditRequest?.surface).toBe("channel_root"); await expect.poll(() => state.lastConversationMessageEditRequest?.messageId).toBe("msg_channel_edit_source"); + await expect.poll(() => state.channelMessages.find((message) => + isRecord(message) && message.body === "Edited reply: Revised channel prompt" + )).toMatchObject({ + threadId: "thread_channel_channel_engineering", + sourceItemId: "msg_channel_edit_source", + }); }); test("pending channel replacement survives refresh and retries with the original idempotency key", async ({ page }) => { @@ -3726,6 +3985,12 @@ test("latest topic reply edits in place without creating a second topic", async await expect(page.getByLabel("Topic replies")).toHaveCount(1); await expect.poll(() => state.lastConversationMessageEditRequest?.surface).toBe("topic_reply"); await expect.poll(() => state.lastConversationMessageEditRequest?.messageId).toBe("msg_topic_edit_user"); + await expect.poll(() => state.channelMessages.find((message) => + isRecord(message) && message.body === "Edited reply: Revised topic follow-up" + )).toMatchObject({ + threadId: "thread_topic_channel_engineering_msg_topic_edit_root", + sourceItemId: "msg_topic_edit_user", + }); }); test("latest channel branch prompt reruns inside the same branch lane", async ({ page }) => { @@ -3798,6 +4063,12 @@ test("latest channel branch prompt reruns inside the same branch lane", async ({ await expect(navigation.locator(".sidebar-branch-row-shell.selected")).toContainText("Implementation branch"); await expect.poll(() => state.lastConversationMessageEditRequest?.surface).toBe("channel_branch"); await expect.poll(() => state.lastConversationMessageEditRequest?.messageId).toBe("msg_branch_edit_source"); + await expect.poll(() => state.channelMessages.find((message) => + isRecord(message) && message.body === "Edited reply: Revised branch prompt" + )).toMatchObject({ + threadId: rootThreadId, + sourceItemId: "msg_branch_edit_source", + }); }); test("channel message body renders safe markdown, highlighted code, folded logs, and structured diffs", async ({ page }) => { @@ -4151,7 +4422,7 @@ test("channel composer sends workspace file refs as native message payload", asy await expect(page.locator(".channel-composer .composer-inline-token.file-reference").filter({ hasText: "apps/web/src/features/workspace/WorkspaceClientView.tsx" })).toHaveCount(1); await page.locator(".channel-composer").getByRole("button", { name: "Send" }).click(); - await expect.poll(() => state.lastChannelMessageRequest?.fileRefs).toEqual([ + await expect.poll(() => state.lastChannelTurnRequest?.fileRefs).toEqual([ { path: "apps/web/src/features/workspace/WorkspaceClientView.tsx", label: "apps/web/src/features/workspace/WorkspaceClientView.tsx", @@ -4262,7 +4533,8 @@ test("channel composer applies plan mode session commands without creating slash const planChip = composer.locator(".conversation-mode-chip.plan"); const planButton = planChip.getByRole("button", { name: "Plan Mode" }); await expect(planChip).toHaveCount(0); - await expect.poll(() => channelRoots().length).toBe(0); + await expect.poll(() => channelRoots().length).toBe(1); + const rootThreadId = channelRoots()[0]?.id; await composer.getByRole("button", { name: "Add" }).click(); await expect(page.getByRole("menu", { name: "Add" })).toBeVisible(); @@ -4272,6 +4544,7 @@ test("channel composer applies plan mode session commands without creating slash await composer.getByRole("button", { name: "Add" }).click(); await page.getByRole("menu", { name: "Add" }).getByRole("menuitem", { name: "Plan Mode" }).click(); await expect.poll(() => channelRoots().length).toBe(1); + expect(channelRoots()[0]?.id).toBe(rootThreadId); await expect.poll(planMode).toBe("plan"); await expect(planChip).toBeVisible(); await expect(planChip).toHaveClass(/active/); @@ -4379,16 +4652,16 @@ test("channel composer manages goal lifecycle from the floating panel", async ({ await goalPanel.getByRole("button", { name: "Collapse goal details" }).click(); await goalPanel.getByRole("button", { name: "Run goal" }).click(); - await expect.poll(() => String(state.lastChannelMessageRequest?.body ?? "")).toBe("Continue working toward goal: Finish PR4 with a visible goal panel"); + await expect.poll(() => String(state.lastChannelTurnRequest?.body ?? "")).toBe("Continue working toward goal: Finish PR4 with a visible goal panel"); await expect(page.getByLabel("Channel message feed").getByText("Continue working toward goal: Finish PR4 with a visible goal panel")).toBeVisible(); await composer.getByRole("button", { name: "Add" }).click(); await page.getByRole("menu", { name: "Add" }).getByRole("menuitem", { name: "Plan Mode" }).click(); await expect.poll(planMode).toBe("plan"); - const previousGoalContinuation = state.lastChannelMessageRequest; + const previousGoalContinuation = state.lastChannelTurnRequest; await goalPanel.getByRole("button", { name: "Run goal" }).click(); await expect(composer).toContainText("Exit Plan Mode before continuing a goal."); - await expect.poll(() => state.lastChannelMessageRequest).toBe(previousGoalContinuation); + await expect.poll(() => state.lastChannelTurnRequest).toBe(previousGoalContinuation); await composerInput.fill("/plan off"); await composer.getByRole("button", { name: "Apply command" }).click(); @@ -4577,12 +4850,12 @@ test("channel feed renders proposed_plan blocks as proposed plan cards", async ( await plan.getByRole("button", { name: "Refine plan" }).click(); await expect.poll(planMode).toBe("plan"); - await expect.poll(() => String(state.lastChannelMessageRequest?.body ?? "")).toContain("Refine this plan before implementation: PR1 + PR2 implementation"); + await expect.poll(() => String(state.lastChannelTurnRequest?.body ?? "")).toContain("Refine this plan before implementation: PR1 + PR2 implementation"); await expect(feed.getByText("Refine this plan before implementation: PR1 + PR2 implementation")).toBeVisible(); await plan.getByRole("button", { name: "Implement" }).click(); await expect.poll(planMode).toBe("default"); - await expect.poll(() => String(state.lastChannelMessageRequest?.body ?? "")).toContain("Implement accepted plan: PR1 + PR2 implementation"); + await expect.poll(() => String(state.lastChannelTurnRequest?.body ?? "")).toContain("Implement accepted plan: PR1 + PR2 implementation"); await expect.poll(() => state.tasks.find((task) => task.title === "Implement accepted plan: PR1 + PR2 implementation")?.requestedSkillId).toBe("plan-acceptance"); await expect.poll(() => state.tasks.find((task) => task.title === "Implement accepted plan: PR1 + PR2 implementation")?.prompt ?? "").toContain("Source proposed plan: proposed_plan_msg_proposed_plan"); await expect(feed.getByText("Implement accepted plan: PR1 + PR2 implementation")).toBeVisible(); @@ -4718,11 +4991,29 @@ test("plan history groups lineage versions across thread and channel scopes", as }, ]; state.channelMessages = [ + { + id: "msg_lineage_source", + workspaceId: "workspace_default", + channelId: "channel_engineering", + threadId: "thread_channel_main_channel_engineering", + kind: "message", + intent: "discuss", + authorType: "local-user", + authorId: "local-user", + body: "Prepare the accepted lineage plan.", + mentions: [], + attachmentIds: [], + linkPreviewIds: [], + fileRefs: [], + status: "ready", + createdAt: "2026-05-25T00:41:00.000Z", + updatedAt: "2026-05-25T00:41:00.000Z", + }, { id: "msg_lineage_v3", workspaceId: "workspace_default", channelId: "channel_engineering", - threadId: "thread_channel_plan_sibling", + threadId: "thread_channel_main_channel_engineering", kind: "ai_reply", intent: "discuss", authorType: "ai-teammate", @@ -4810,7 +5101,7 @@ test("plan history groups lineage versions across thread and channel scopes", as await expect(dock).toContainText("Plan v3 accepted"); const acceptedRow = dock.locator(".right-dock-plan-row").filter({ hasText: "Plan v3 accepted" }); await acceptedRow.getByRole("button", { name: "Open source" }).click(); - const locatedSourceMessage = page.locator('[data-feed-item-id="message:msg_lineage_v3"]'); + const locatedSourceMessage = page.locator('[data-feed-item-id="conversation-turn:msg_lineage_source"]'); await expect(locatedSourceMessage).toHaveClass(/conversation-work-cell-locate-pulse/); await expect.poll(() => locatedSourceMessage.evaluate((element) => ({ duration: getComputedStyle(element).animationDuration, @@ -4957,6 +5248,12 @@ test("channel screenshot request shows materialization clarify reply instead of await expect.poll(() => state.channelIntentDecisions.some((decision) => isRecord(decision) && decision.action === "clarify" && /Mock attachment content is not materialized/.test(String(decision.reason)) )).toBe(true); + await expect.poll(() => state.channelMessages.find((message) => + isRecord(message) && message.authorType === "ai-teammate" && String(message.body).includes("could not read image content") + )).toMatchObject({ + threadId: "thread_channel_channel_engineering", + sourceItemId: "msg_1", + }); }); test("channel local link request shows natural clarify copy without debug vocabulary", async ({ page }) => { @@ -8345,7 +8642,8 @@ test("channel activity paginates long message history and virtualizes visible ro expect(state.channelMessageQueries?.[0]).toMatchObject({ channelId: "channel_engineering", limit: 200, - beforeMessageId: undefined, + beforeSequence: undefined, + threadId: undefined, }); const renderedBefore = await page.locator(".activity-shell .message-feed .message-bubble").count(); @@ -8359,7 +8657,7 @@ test("channel activity paginates long message history and virtualizes visible ro element.scrollTop = 0; element.dispatchEvent(new Event("scroll")); }); - await expect.poll(() => state.channelMessageQueries?.some((query) => query.beforeMessageId === "msg_long_2801")).toBe(true); + await expect.poll(() => state.channelMessageQueries?.some((query) => query.beforeSequence === 2801 && query.threadId === undefined)).toBe(true); await expect(page.getByLabel("Channel message feed").getByText("Long channel message 2801")).toBeVisible(); const renderedAfter = await page.locator(".activity-shell .message-feed .message-bubble").count(); expect(renderedAfter).toBeLessThan(120); @@ -9325,7 +9623,7 @@ test("local channel work-cell demo fixture renders a realistic multi-agent opera await page.mouse.wheel(0, 120); await expect.poll(() => hoverPreviewBody.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); await expect(hoverPreview).toBeVisible(); - await changedFiles.nth(1).click(); + await changedFiles.nth(1).focus(); await expect(hoverPreview).toContainText("components.css"); await expect(hoverPreview.locator(".diff-line.add")).toHaveCount(4); await expect(hoverPreview.locator(".diff-line.delete")).toHaveCount(0); @@ -10006,7 +10304,49 @@ test("Ling Agent session active run guidance queues follow-up from composer and run.errorMessage = undefined; run.startedAt = "2026-05-22T00:00:30.000Z"; run.updatedAt = "2026-05-22T00:01:00.000Z"; + run.threadId = "thread_channel_channel_engineering"; + run.teammateId = "ling_agent"; + run.workUnitId = "wu_active_channel_reply"; + run.requestSnapshot = { sourceMessageId: "message_active_reply_source" }; state.approvals = []; + state.channelMessages = [{ + id: "message_active_reply_source", + workspaceId: "workspace_default", + channelId: "channel_engineering", + kind: "message", + intent: "discuss", + authorType: "local-user", + authorId: "local-user", + body: "Start the active Ling Agent reply.", + mentions: [], + attachmentIds: [], + linkPreviewIds: [], + fileRefs: [], + status: "ready", + createdAt: "2026-05-22T00:00:29.000Z", + updatedAt: "2026-05-22T00:00:29.000Z", + }]; + state.workUnits = [{ + id: "wu_active_channel_reply", + workspaceId: "workspace_default", + kind: "channel_ai_reply", + ownerType: "channel_message", + ownerId: "message_active_reply_source", + channelId: "channel_engineering", + teammateId: "ling_agent", + providerPresetId: "provider_local", + idempotencyKey: "channel-reply:message_active_reply_source", + status: "running", + priority: 0, + attemptCount: 1, + createdByActor: "system", + request: { sourceMessageId: "message_active_reply_source" }, + timeoutPolicy: { claimTtlMs: 60_000, heartbeatTtlMs: 60_000, cancelGraceMs: 15_000 }, + linkedRunId: run.id, + createdAt: "2026-05-22T00:00:30.000Z", + updatedAt: "2026-05-22T00:01:00.000Z", + startedAt: "2026-05-22T00:00:30.000Z", + }]; state.tasks = state.tasks.map((task) => isRecord(task) && task.id === "task_inspect" ? { ...task, status: "running", currentRunId: run.id, updatedAt: run.updatedAt } : task); @@ -10026,20 +10366,22 @@ test("Ling Agent session active run guidance queues follow-up from composer and await expect.poll(() => state.runInterventions.length).toBe(1); await expect(page.locator('[data-ling-overlay="toast"][data-scope="channel:channel_engineering:composer"]')).toHaveCount(0); await expect(composer.locator(".composer-status-row.success")).toHaveCount(0); - await expect(page.getByLabel("Run guidance")).toHaveCount(0); + await expect(page.getByLabel("Run guidance")).toHaveCount(1); expect(state.runInterventions[0]).toMatchObject({ sourceRunId: run.id, mode: "queue_followup", status: "queued", instruction: "完成后也更新 docs", }); - const firstGuidanceMessage = page.locator(".channel-message-row").filter({ hasText: "完成后也更新 docs" }); + const firstGuidanceMessage = page.locator(".conversation-guidance-row").filter({ hasText: "完成后也更新 docs" }); await expect(firstGuidanceMessage).toBeVisible(); await expect(firstGuidanceMessage).not.toContainText("Guidance"); - await firstGuidanceMessage.hover(); - await expect(firstGuidanceMessage.getByRole("button", { name: "Edit message" })).toBeVisible(); + await expect(page.locator(".channel-message-row").filter({ hasText: "完成后也更新 docs" })).toHaveCount(0); await expect(page.locator(".conversation-work-cell")).toHaveCount(0); - expect(state.channelMessages).toHaveLength(1); + expect(state.channelMessages.map((message) => message.body)).toEqual([ + "Start the active Ling Agent reply.", + "完成后也更新 docs", + ]); expect(state.channelExecutionProposals).toHaveLength(0); await expectComposerValue(composerInput, ""); @@ -10051,15 +10393,20 @@ test("Ling Agent session active run guidance queues follow-up from composer and "完成后也更新 docs", "停,改用 daemon API", ]); - const redirectedGuidanceMessage = page.locator(".channel-message-row").filter({ hasText: "停,改用 daemon API" }); + const redirectedGuidanceMessage = page.locator(".conversation-guidance-row").filter({ hasText: "停,改用 daemon API" }); await expect(redirectedGuidanceMessage).toBeVisible(); await expect(redirectedGuidanceMessage).not.toContainText("Guidance"); + await expect(page.locator(".channel-message-row").filter({ hasText: "停,改用 daemon API" })).toHaveCount(0); await expectComposerValue(composerInput, ""); await composer.getByRole("button", { name: "Stop reply" }).click(); await expect.poll(() => state.runs.find((item) => item.id === run.id)?.status).toBe("cancelled"); expect(state.runInterventions).toHaveLength(2); - expect(state.channelMessages).toHaveLength(2); + expect(state.channelMessages.map((message) => message.body)).toEqual([ + "Start the active Ling Agent reply.", + "完成后也更新 docs", + "停,改用 daemon API", + ]); expect(state.channelExecutionProposals).toHaveLength(0); }); @@ -10082,7 +10429,7 @@ test("channel composer accepts a second queued guidance while the first request const input = channelComposerTextbox(composer); await input.fill("First queued guidance"); await input.press("Enter"); - await expect.poll(() => state.lastChannelMessageRequest?.body).toBe("First queued guidance"); + await expect.poll(() => state.lastChannelTurnRequest?.body).toBe("First queued guidance"); await expectComposerValue(input, ""); await input.fill("Second queued guidance"); @@ -10132,7 +10479,7 @@ test("ordinary channel send unlocks the composer at run linkage before the reply globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { const request = new Request(input, init); const url = new URL(request.url); - if (request.method !== "POST" || !url.pathname.endsWith("/channels/channel_engineering/messages/stream")) { + if (request.method !== "POST" || !url.pathname.endsWith("/channels/channel_engineering/turns/stream")) { return originalFetch(input, init); } if (streamCalls > 0) { @@ -10141,43 +10488,91 @@ test("ordinary channel send unlocks the composer at run linkage before the reply streamCalls += 1; const body = await request.clone().json() as Record; const createdAt = "2026-08-05T08:00:00.000Z"; - const message = { + const thread = { + id: "thread_channel_channel_engineering", + workspaceId: "workspace_default", + kind: "channel_discussion", + channelId: "channel_engineering", + title: "Engineering conversation", + objective: "Root conversation for Engineering.", + status: "open", + conversationMode: { mode: "default" }, + surfaceKind: "channel_root", + relationKind: "root", + nextItemSequence: 3, + createdAt, + updatedAt: createdAt, + }; + const turn = { + id: "turn_chunked_channel_reply", + workspaceId: "workspace_default", + threadId: thread.id, + kind: "manual_followup", + status: "running", + title: String(body.body ?? ""), + userInstruction: String(body.body ?? ""), + channelId: "channel_engineering", + sourceItemId: "message_chunked_channel_reply", + createdByActor: "local-user", + createdAt, + updatedAt: createdAt, + }; + const userItem = { + schemaVersion: 1, id: "message_chunked_channel_reply", workspaceId: "workspace_default", channelId: "channel_engineering", - kind: "message", - intent: "discuss", - authorType: "local-user", - authorId: "local-user", - body: String(body.body ?? ""), - mentions: Array.isArray(body.mentions) ? body.mentions : [], - attachmentIds: [], - linkPreviewIds: [], - fileRefs: [], - status: "ready", + threadId: thread.id, + turnId: turn.id, + sequence: 1, + kind: "user_message", + status: "committed", + actorType: "local_user", + actorId: "local-user", + visibility: "channel", + payload: { + body: String(body.body ?? ""), + format: "plain_text", + mentions: Array.isArray(body.mentions) ? body.mentions : [], + attachmentIds: [], + linkPreviewIds: [], + fileRefs: [], + }, + domainRefs: [], + revision: 1, createdAt, updatedAt: createdAt, + committedAt: createdAt, }; - const pendingReply = { + const pendingItem = { + schemaVersion: 1, id: "reply_chunked_channel_reply", workspaceId: "workspace_default", channelId: "channel_engineering", - threadId: message.id, - kind: "ai_reply", - intent: "discuss", - authorType: "ai-teammate", - authorId: "engineer", - body: "", - mentions: [], - attachmentIds: [], - linkPreviewIds: [], - fileRefs: [], - linkedRunId: "run_chunked_channel_reply", + threadId: thread.id, + turnId: turn.id, + sequence: 2, + kind: "assistant_message", status: "pending", + actorType: "teammate", + actorId: "engineer", + visibility: "channel", + payload: { body: "", format: "markdown", streaming: true, deliveryStatus: "pending" }, + domainRefs: [ + { type: "conversation_item", id: userItem.id }, + { type: "run", id: "run_chunked_channel_reply" }, + ], + revision: 1, createdAt, updatedAt: createdAt, }; - const readyReply = { ...pendingReply, body: "First reply completed.", status: "ready" }; + const readyItem = { + ...pendingItem, + status: "committed", + payload: { body: "First reply completed.", format: "markdown", streaming: false, deliveryStatus: "ready" }, + committedAt: createdAt, + }; + const decorations = [{ itemId: pendingItem.id, linkedRunId: "run_chunked_channel_reply" }]; const encoder = new TextEncoder(); const timers: number[] = []; const stream = new ReadableStream({ @@ -10185,17 +10580,18 @@ test("ordinary channel send unlocks the composer at run linkage before the reply const emit = (event: string, data: unknown): void => { controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); }; - emit("ready", { message }); - timers.push(globalThis.setTimeout(() => emit("ready", { message, aiReply: pendingReply, aiReplies: [pendingReply] }), 100)); + emit("ready", { thread, turn, userItem, decorations: [] }); + timers.push(globalThis.setTimeout(() => emit("ready", { thread, turn, userItem, assistantItems: [pendingItem], decorations }), 100)); timers.push(globalThis.setTimeout(() => emit("run_linked", { - messageId: pendingReply.id, + itemId: pendingItem.id, linkedRunId: "run_chunked_channel_reply", activeReply: { runId: "run_chunked_channel_reply", sourceRunId: "run_chunked_channel_reply", workUnitId: "wu_chunked_channel_reply", channelId: "channel_engineering", - threadId: message.id, + threadId: thread.id, + sourceMessageId: userItem.id, teammateId: "engineer", status: "running", createdAt, @@ -10203,12 +10599,12 @@ test("ordinary channel send unlocks the composer at run linkage before the reply }, }), 400)); timers.push(globalThis.setTimeout(() => emit("delta", { - messageId: pendingReply.id, - delta: readyReply.body, - body: readyReply.body, + itemId: pendingItem.id, + delta: readyItem.payload.body, + body: readyItem.payload.body, }), 1_400)); timers.push(globalThis.setTimeout(() => { - emit("done", { message, aiReply: readyReply, aiReplies: [readyReply] }); + emit("done", { thread, turn: { ...turn, status: "succeeded", completedAt: createdAt }, userItem, assistantItems: [readyItem], decorations }); controller.close(); }, 1_800)); }, @@ -10309,8 +10705,8 @@ test("every message joins the channel FIFO while any teammate reply is active", const input = channelComposerTextbox(composer); await input.fill("只补充错误处理"); await input.press("Enter"); - await expect.poll(() => state.lastChannelMessageRequest?.body).toBe("只补充错误处理"); - expect(state.lastChannelMessageRequest?.teammateId).toBeUndefined(); + await expect.poll(() => state.lastChannelTurnRequest?.body).toBe("只补充错误处理"); + expect(state.lastChannelTurnRequest?.teammateId).toBeUndefined(); await expect.poll(() => state.runInterventions.length).toBe(1); expect(state.runInterventions[0]).toMatchObject({ sourceRunId: "run_channel_reply_designer", @@ -10337,8 +10733,8 @@ test("every message joins the channel FIFO while any teammate reply is active", await expect(guidanceMessage).toBeVisible(); await expect(page.locator(".channel-message-row").filter({ hasText: "只补充 Engineer 的错误处理" })).toHaveCount(0); expect(state.channelMessages.some((message) => message.authorType === "local-user" && message.body === "@Engineer 只补充 Engineer 的错误处理")).toBe(true); - expect(state.lastChannelMessageRequest?.teammateId).toBeUndefined(); - expect(state.lastChannelMessageRequest?.mentions).toEqual([ + expect(state.lastChannelTurnRequest?.teammateId).toBeUndefined(); + expect(state.lastChannelTurnRequest?.mentions).toEqual([ expect.objectContaining({ id: "engineer", kind: "ai-teammate" }), ]); diff --git a/apps/web/e2e/support/mock-daemon.ts b/apps/web/e2e/support/mock-daemon.ts index 2d00d64c9..c20a29ceb 100644 --- a/apps/web/e2e/support/mock-daemon.ts +++ b/apps/web/e2e/support/mock-daemon.ts @@ -71,6 +71,14 @@ export interface MockState { channelMessages: unknown[]; channelMessageRouteDecisions: unknown[]; channelIntentDecisions: unknown[]; + channelTurnContexts: Record>; + channelTurnContextCorrectionRequests: Array<{ + readonly channelId: string; + readonly turnId: string; + readonly segmentId: string; + readonly expectedAssignmentRevision: number; + }>; + staleNextChannelTurnContextCorrection?: { readonly segmentId: string }; channelExecutionProposals: unknown[]; channelAttachments: unknown[]; channelLinkPreviews: unknown[]; @@ -142,7 +150,7 @@ export interface MockState { channelAiReplyBody?: string; keepChannelAiReplyPending?: boolean; failNextChannelMessage?: boolean; - lastChannelMessageRequest?: Record; + lastChannelTurnRequest?: Record; lastTeammateDirectMessageRequest?: Record; lastConversationMessageEditRequest?: Record; channelMessageQueries?: Record[]; @@ -233,6 +241,11 @@ export interface MockLingThread { pinnedAt?: string; archivedAt?: string; archivedReason?: string; + surfaceKind?: "channel_root" | "topic" | "branch" | "direct" | "work"; + relationKind?: "root" | "topic" | "branch"; + anchorItemId?: string; + forkContextSnapshotId?: string; + nextItemSequence?: number; } export interface MockLingTurn { @@ -249,6 +262,7 @@ export interface MockLingTurn { workflowStepId?: string; externalWorkId?: string; automationId?: string; + sourceItemId?: string; sourceMessageId?: string; sourceTurnId?: string; revision?: number; @@ -1180,6 +1194,8 @@ export function createBaseState(options: { approvalStatus: "pending" | "expired" channelMessages: [], channelMessageRouteDecisions: [], channelIntentDecisions: [], + channelTurnContexts: {}, + channelTurnContextCorrectionRequests: [], channelMessageQueries: [], channelExecutionProposals: [], channelAttachments: [], @@ -5137,6 +5153,10 @@ function createMockChannelBranchThread( objective: `Conversation branch "${branchName}" for ${channelName}.`, status: "open", conversationMode: { mode: "default" }, + surfaceKind: "branch", + relationKind: "branch", + parentThreadId: `thread_channel_${String(branch.channelId ?? "channel")}`, + nextItemSequence: 1, createdAt: typeof branch.createdAt === "string" ? branch.createdAt : "2026-05-25T00:17:00.000Z", updatedAt: typeof branch.updatedAt === "string" ? branch.updatedAt : "2026-05-25T00:17:00.000Z", archivedAt, @@ -5166,6 +5186,282 @@ function syncMockChannelBranchThreadArchive(state: MockState, branch: Record + thread.channelId === channelId && thread.surfaceKind === "channel_root") + ?? state.threads.find((thread) => thread.id === id) + ?? state.threads.find((thread) => + thread.channelId === channelId && thread.kind === "channel_discussion" && !thread.parentThreadId && !thread.anchorItemId); + if (existing) { + existing.surfaceKind = "channel_root"; + existing.relationKind = "root"; + existing.nextItemSequence = mockChannelConversationEntries(state, channelId).filter((entry) => entry.item.threadId === id).length + 1; + return existing; + } + const channel = state.channels.find((candidate) => isRecord(candidate) && candidate.id === channelId); + const name = isRecord(channel) && typeof channel.name === "string" ? channel.name : channelId; + const thread: MockLingThread = { + id, + workspaceId: "workspace_default", + kind: "channel_discussion", + channelId, + title: `${name} conversation`, + objective: `Root conversation for ${name}.`, + status: "open", + conversationMode: { mode: "default" }, + surfaceKind: "channel_root", + relationKind: "root", + nextItemSequence: 1, + createdAt: "2026-05-22T00:00:00.000Z", + updatedAt: "2026-05-25T00:18:00.000Z", + }; + state.threads.unshift(thread); + return thread; +} + +function mockChannelConversationThread(state: MockState, channelId: string, requestedThreadId?: string): MockLingThread { + if (requestedThreadId) { + const existing = state.threads.find((thread) => thread.id === requestedThreadId && thread.channelId === channelId); + if (existing) { + existing.surfaceKind ??= existing.parentThreadId ? "branch" : "channel_root"; + existing.relationKind ??= existing.parentThreadId ? "branch" : "root"; + existing.nextItemSequence ??= 1; + return existing; + } + throw new Error(`Mock conversation thread not found: ${requestedThreadId}`); + } + return ensureMockChannelRootThread(state, channelId); +} + +function mockChannelTurnThread( + state: MockState, + channelId: string, + body: Record, +): MockLingThread { + const surface = isRecord(body.surface) ? body.surface : undefined; + if (!surface || surface.kind === "channel_root") { + return ensureMockChannelRootThread(state, channelId); + } + if (surface.kind === "thread" && typeof surface.threadId === "string") { + return mockChannelConversationThread(state, channelId, surface.threadId); + } + if (surface.kind === "topic" && typeof surface.anchorItemId === "string") { + return ensureMockChannelTopicThread(state, channelId, surface.anchorItemId); + } + return ensureMockChannelRootThread(state, channelId); +} + +function ensureMockChannelTopicThread(state: MockState, channelId: string, anchorItemId: string): MockLingThread { + const existing = state.threads.find((thread) => + thread.channelId === channelId && thread.surfaceKind === "topic" && thread.anchorItemId === anchorItemId); + if (existing) return existing; + const id = `thread_topic_${channelId}_${anchorItemId}`; + const thread: MockLingThread = { + id, + workspaceId: "workspace_default", + kind: "channel_discussion", + channelId, + title: `Topic ${anchorItemId}`, + objective: `Topic anchored at ${anchorItemId}.`, + status: "open", + conversationMode: { mode: "default" }, + surfaceKind: "topic", + relationKind: "topic", + anchorItemId, + nextItemSequence: 1, + createdAt: "2026-05-22T00:00:00.000Z", + updatedAt: "2026-05-25T00:18:00.000Z", + }; + state.threads.unshift(thread); + return thread; +} + +function mockChannelConversationEntries( + state: MockState, + channelId: string, +): Array<{ readonly message: Record; readonly item: Record; readonly turn: MockLingTurn }> { + const messages = state.channelMessages + .filter((message): message is Record => isRecord(message) && message.channelId === channelId && message.status !== "superseded") + .sort((left, right) => String(left.createdAt ?? "").localeCompare(String(right.createdAt ?? "")) || String(left.id ?? "").localeCompare(String(right.id ?? ""))); + const rootThread = state.threads.find((thread) => thread.channelId === channelId && thread.surfaceKind === "channel_root") + ?? state.threads.find((thread) => thread.id === `thread_channel_${channelId}`) + ?? { + id: `thread_channel_${channelId}`, + channelId, + }; + const sequenceByThread = new Map(); + const messageById = new Map(messages.map((message) => [String(message.id), message])); + const userMessageById = new Map(messages.filter((message) => message.kind !== "ai_reply").map((message) => [String(message.id), message])); + const latestUserBySurfaceThreadId = new Map>(); + const surfaceThreadForUser = (userMessage: Record | undefined): string => { + if (!userMessage || typeof userMessage.threadId !== "string") return rootThread.id; + const explicitThread = state.threads.find((thread) => thread.id === userMessage.threadId && thread.channelId === channelId); + if (explicitThread) return explicitThread.id; + if (messageById.has(userMessage.threadId)) { + return ensureMockChannelTopicThread(state, channelId, userMessage.threadId).id; + } + return rootThread.id; + }; + return messages.map((message) => { + const isAssistant = message.kind === "ai_reply"; + const explicitAssistantThreadId = isAssistant && typeof message.threadId === "string" + && state.threads.some((thread) => thread.id === message.threadId && thread.channelId === channelId) + ? message.threadId + : undefined; + const anchoredAssistantThreadId = isAssistant && typeof message.threadId === "string" && messageById.has(message.threadId) + ? ensureMockChannelTopicThread(state, channelId, message.threadId).id + : undefined; + const explicitSourceUser = isAssistant && typeof message.sourceItemId === "string" + ? userMessageById.get(message.sourceItemId) + : undefined; + const sourceUser = isAssistant + ? explicitSourceUser ?? (typeof message.threadId === "string" + ? userMessageById.get(message.threadId) + ?? ((explicitAssistantThreadId ?? anchoredAssistantThreadId) + ? latestUserBySurfaceThreadId.get(explicitAssistantThreadId ?? anchoredAssistantThreadId ?? "") + : undefined) + : undefined) + : message; + const threadId = isAssistant + ? sourceUser ? surfaceThreadForUser(sourceUser) : explicitAssistantThreadId ?? anchoredAssistantThreadId ?? rootThread.id + : surfaceThreadForUser(message); + if (!isAssistant) latestUserBySurfaceThreadId.set(threadId, message); + const sequence = (sequenceByThread.get(threadId) ?? 0) + 1; + sequenceByThread.set(threadId, sequence); + const sourceId = sourceUser ? String(sourceUser.id ?? message.id) : undefined; + const turnId = sourceId ? `turn_${sourceId}` : undefined; + const createdAt = String(message.createdAt ?? "2026-05-25T00:18:00.000Z"); + const updatedAt = String(message.updatedAt ?? createdAt); + const deliveryStatus = message.status === "pending" || message.status === "blocked" || message.status === "failed" + ? message.status + : "ready"; + const item = isAssistant + ? { + schemaVersion: 1, + id: String(message.id), + workspaceId: "workspace_default", + channelId, + threadId, + ...(turnId ? { turnId } : {}), + sequence, + kind: "assistant_message", + status: deliveryStatus === "pending" ? "pending" : deliveryStatus === "failed" ? "failed" : "committed", + actorType: message.authorId === "ling_agent" ? "ling_agent" : "teammate", + ...(typeof message.authorId === "string" ? { actorId: message.authorId } : {}), + visibility: "channel", + payload: { + body: String(message.body ?? ""), + format: "markdown", + streaming: deliveryStatus === "pending", + deliveryStatus, + ...(typeof message.errorMessage === "string" ? { errorMessage: message.errorMessage } : {}), + }, + domainRefs: [ + ...(sourceId ? [{ type: "conversation_item", id: sourceId }] : []), + ...(typeof message.linkedRunId === "string" ? [{ type: "run", id: message.linkedRunId }] : []), + ], + revision: 1, + createdAt, + updatedAt, + ...(deliveryStatus === "pending" ? {} : { committedAt: updatedAt }), + } + : { + schemaVersion: 1, + id: String(message.id), + workspaceId: "workspace_default", + channelId, + threadId, + ...(turnId ? { turnId } : {}), + sequence, + kind: "user_message", + status: "committed", + actorType: "local_user", + ...(typeof message.authorId === "string" ? { actorId: message.authorId } : {}), + visibility: "channel", + payload: { + body: String(message.body ?? ""), + format: "plain_text", + mentions: Array.isArray(message.mentions) + ? message.mentions.filter(isRecord).map((mention) => ({ + id: String(mention.id ?? ""), + label: String(mention.label ?? mention.id ?? ""), + kind: mention.kind === "ai-teammate" ? "ai_teammate" : mention.kind === "channel" ? "channel" : "local_user", + })) + : [], + attachmentIds: Array.isArray(message.attachmentIds) ? message.attachmentIds : [], + linkPreviewIds: Array.isArray(message.linkPreviewIds) ? message.linkPreviewIds : [], + fileRefs: Array.isArray(message.fileRefs) ? message.fileRefs : [], + ...(typeof message.threadId === "string" && messageById.has(message.threadId) + ? { replyToItemId: message.threadId } + : {}), + }, + domainRefs: [ + ...(typeof message.linkedTaskId === "string" ? [{ type: "task", id: message.linkedTaskId }] : []), + ...(typeof message.linkedRunId === "string" ? [{ type: "run", id: message.linkedRunId }] : []), + ], + revision: typeof message.revision === "number" ? message.revision : 1, + createdAt, + updatedAt, + committedAt: updatedAt, + }; + const turn: MockLingTurn = { + id: turnId ?? `turn_${String(message.id)}`, + workspaceId: "workspace_default", + threadId, + kind: "manual_followup", + status: deliveryStatus === "pending" ? "running" : deliveryStatus === "blocked" ? "blocked" : deliveryStatus === "failed" ? "failed" : "succeeded", + title: String(sourceUser?.body ?? message.body ?? "Channel turn").slice(0, 160), + userInstruction: String(sourceUser?.body ?? message.body ?? ""), + channelId, + ...(sourceId ? { sourceItemId: sourceId } : {}), + createdByActor: "local-user", + createdAt: String(sourceUser?.createdAt ?? createdAt), + updatedAt, + ...(deliveryStatus === "pending" ? {} : { completedAt: updatedAt }), + }; + return { message, item, turn }; + }); +} + +function mockChannelConversationDecorations(entries: readonly { readonly message: Record; readonly item: Record }[]): readonly Record[] { + return entries.flatMap(({ message, item }) => { + const decoration = { + itemId: item.id, + ...(typeof message.linkedRunId === "string" ? { linkedRunId: message.linkedRunId } : {}), + ...(typeof message.editedAt === "string" ? { editedAt: message.editedAt } : {}), + ...(isRecord(message.pendingReplacement) ? { pendingReplacement: message.pendingReplacement } : {}), + ...(isRecord(message.webEvidence) ? { webEvidence: message.webEvidence } : {}), + }; + return Object.keys(decoration).length > 1 ? [decoration] : []; + }); +} + +function buildMockChannelTurnResponse( + state: MockState, + channelId: string, + message: Record, + aiReplies: readonly Record[] = [], + extra: Record = {}, +): Record { + const entries = mockChannelConversationEntries(state, channelId); + const userEntry = entries.find((entry) => entry.item.id === message.id); + if (!userEntry) throw new Error(`Mock channel user item not found: ${String(message.id)}`); + const assistantEntries = aiReplies + .map((reply) => entries.find((entry) => entry.item.id === reply.id)) + .filter((entry): entry is NonNullable => Boolean(entry)); + const thread = mockChannelConversationThread(state, channelId, String(userEntry.item.threadId)); + thread.nextItemSequence = entries.filter((entry) => entry.item.threadId === thread.id).length + 1; + return { + thread, + turn: userEntry.turn, + userItem: userEntry.item, + ...(assistantEntries.length > 0 ? { assistantItems: assistantEntries.map((entry) => entry.item) } : {}), + decorations: mockChannelConversationDecorations([userEntry, ...assistantEntries]), + ...extra, + }; +} + function readChannelAutoReplyMode(value: unknown): string { return value === "default_teammate" || value === "auto_route" ? value : "mentions_only"; } @@ -5896,7 +6192,8 @@ function maybeCreateMockMaterializationClarifyReply( id: `msg_${state.channelMessages.length + 1}`, workspaceId: "workspace_default", channelId, - threadId: typeof message.id === "string" ? message.id : undefined, + threadId: typeof message.threadId === "string" ? message.threadId : ensureMockChannelRootThread(state, channelId).id, + sourceItemId: typeof message.id === "string" ? message.id : undefined, kind: "ai_reply", intent: "discuss", authorType: "ai-teammate", @@ -6601,12 +6898,19 @@ async function handleMockConversationMessageEdit( pendingReplacement: undefined, updatedAt: now, }; + const currentEntry = mockChannelConversationEntries(state, ownerId) + .find((entry) => entry.item.id === messageId); + const currentThreadId = typeof currentEntry?.item.threadId === "string" + ? currentEntry.item.threadId + : ensureMockChannelRootThread(state, ownerId).id; for (let index = 0; index < state.channelMessages.length; index += 1) { const candidate = state.channelMessages[index]; const matchesSupersededReply = isRecord(candidate) && candidate.authorType === "ai-teammate" && candidate.status !== "superseded" && ( - surface === "topic_reply" || surface === "channel_branch" - ? candidate.threadId === current.threadId && String(candidate.createdAt ?? "") >= String(current.createdAt ?? "") - : candidate.threadId === messageId + candidate.sourceItemId === messageId || ( + surface === "topic_reply" || surface === "channel_branch" + ? candidate.threadId === current.threadId && String(candidate.createdAt ?? "") >= String(current.createdAt ?? "") + : candidate.threadId === messageId + ) ); if (matchesSupersededReply && isRecord(candidate)) { supersededResponseMessageIds.push(String(candidate.id)); @@ -6617,7 +6921,8 @@ async function handleMockConversationMessageEdit( id: `msg_edit_reply_${state.channelMessages.length + 1}`, workspaceId: "workspace_default", channelId: ownerId, - threadId: typeof current.threadId === "string" && (surface === "topic_reply" || surface === "channel_branch") ? current.threadId : messageId, + threadId: currentThreadId, + sourceItemId: messageId, kind: "ai_reply", intent: "discuss", authorType: "ai-teammate", @@ -10660,11 +10965,11 @@ export async function handleDaemonRoute( return; } } - const channelMessageStreamMatch = /^\/channels\/([^/]+)\/messages\/stream$/.exec(path); - if (channelMessageStreamMatch && request.method() === "POST") { - const channelId = decodeURIComponent(channelMessageStreamMatch[1]); + const channelTurnStreamMatch = /^\/channels\/([^/]+)\/turns\/stream$/.exec(path); + if (channelTurnStreamMatch && request.method() === "POST") { + const channelId = decodeURIComponent(channelTurnStreamMatch[1]); const body = await request.postDataJSON() as Record; - state.lastChannelMessageRequest = body; + state.lastChannelTurnRequest = body; if (state.channelMessageDelayMs) { await new Promise((resolve) => setTimeout(resolve, state.channelMessageDelayMs)); } @@ -10673,11 +10978,15 @@ export async function handleDaemonRoute( await route.fulfill({ status: 500, json: { error: "mock_send_failed", message: "Mock message send failed." } }); return; } + const conversationThread = mockChannelTurnThread(state, channelId, body); const message = { id: `msg_${state.channelMessages.length + 1}`, workspaceId: "workspace_default", channelId, - threadId: typeof body.threadId === "string" ? body.threadId : undefined, + threadId: conversationThread.id, + threadSurfaceKind: conversationThread.surfaceKind, + threadRelationKind: conversationThread.relationKind, + threadAnchorItemId: conversationThread.anchorItemId, kind: typeof body.kind === "string" ? body.kind : "message", intent: typeof body.intent === "string" ? body.intent : "discuss", authorType: typeof body.authorType === "string" ? body.authorType : "local-user", @@ -10703,7 +11012,7 @@ export async function handleDaemonRoute( ? body.teammateId : "ling_agent"; const replyPending = { - sourceMessageId: message.id, + sourceItemId: message.id, targetTeammateId: requestedTeammateId, startedAt: message.createdAt, }; @@ -10742,8 +11051,8 @@ export async function handleDaemonRoute( intervention, }; const events = [ - { event: "ready", data: { message } }, - { event: "done", data: { message, replyDispatch } }, + { event: "ready", data: buildMockChannelTurnResponse(state, channelId, message) }, + { event: "done", data: buildMockChannelTurnResponse(state, channelId, message, [], { replyDispatch }) }, ]; await route.fulfill({ status: 200, @@ -10755,10 +11064,10 @@ export async function handleDaemonRoute( const materializationClarifyReply = maybeCreateMockMaterializationClarifyReply(state, channelId, message); if (materializationClarifyReply) { const events = [ - { event: "ready", data: { message } }, + { event: "ready", data: buildMockChannelTurnResponse(state, channelId, message) }, { event: "reply_pending", data: replyPending }, - { event: "ready", data: { message, aiReply: materializationClarifyReply, aiReplies: [materializationClarifyReply] } }, - { event: "done", data: { message, aiReply: materializationClarifyReply, aiReplies: [materializationClarifyReply] } }, + { event: "ready", data: buildMockChannelTurnResponse(state, channelId, message, [materializationClarifyReply]) }, + { event: "done", data: buildMockChannelTurnResponse(state, channelId, message, [materializationClarifyReply]) }, ]; await route.fulfill({ status: 200, @@ -10774,9 +11083,9 @@ export async function handleDaemonRoute( : undefined; if (mockWorkflowProposal) { const events = [ - { event: "ready", data: { message } }, + { event: "ready", data: buildMockChannelTurnResponse(state, channelId, message) }, { event: "reply_pending", data: replyPending }, - { event: "done", data: { message, ...mockWorkflowProposal } }, + { event: "done", data: buildMockChannelTurnResponse(state, channelId, message, [], mockWorkflowProposal) }, ]; await route.fulfill({ status: 200, @@ -10791,7 +11100,7 @@ export async function handleDaemonRoute( message.authorType === "local-user" && message.kind === "message" && message.intent === "discuss" && - !message.threadId && + conversationThread.surfaceKind === "channel_root" && (message.body.includes("?") || message.body.includes("?") || isMockContextualFollowUp(message.body)) && (autoReplyMode === "default_teammate" || autoReplyMode === "auto_route") ); @@ -10839,7 +11148,8 @@ export async function handleDaemonRoute( id: `msg_${state.channelMessages.length + 1}`, workspaceId: "workspace_default", channelId, - threadId: message.threadId ?? message.id, + threadId: conversationThread.id, + sourceItemId: message.id, kind: "ai_reply", intent: "discuss", authorType: "ai-teammate", @@ -10864,16 +11174,17 @@ export async function handleDaemonRoute( } } const events = [ - { event: "ready", data: { message } }, + { event: "ready", data: buildMockChannelTurnResponse(state, channelId, message) }, { event: "reply_pending", data: { ...replyPending, targetTeammateId: routedTeammateId ?? requestedTeammateId } }, - ...(pendingReply ? [{ event: "ready", data: { message, aiReply: pendingReply, aiReplies: [pendingReply] } }] : []), - ...(aiReply && !state.keepChannelAiReplyPending ? [{ event: "delta", data: { messageId: aiReply.id, delta: aiReply.body, body: aiReply.body } }] : []), - { event: "done", data: storedAiReply ? { + ...(pendingReply ? [{ event: "ready", data: buildMockChannelTurnResponse(state, channelId, message, [pendingReply]) }] : []), + ...(aiReply && !state.keepChannelAiReplyPending ? [{ event: "delta", data: { itemId: aiReply.id, delta: aiReply.body, body: aiReply.body } }] : []), + { event: "done", data: buildMockChannelTurnResponse( + state, + channelId, message, - aiReply: storedAiReply, - aiReplies: [storedAiReply], - replyDispatch: { targetTeammateId: routedTeammateId, disposition: "reply_requested" }, - } : { message } }, + storedAiReply ? [storedAiReply] : [], + storedAiReply ? { replyDispatch: { targetTeammateId: routedTeammateId, disposition: "reply_requested" } } : {}, + ) }, ]; await route.fulfill({ status: 200, @@ -10883,18 +11194,21 @@ export async function handleDaemonRoute( return; } - const channelMessagesMatch = /^\/channels\/([^/]+)\/messages$/.exec(path); - if (channelMessagesMatch && request.method() === "GET") { - const channelId = decodeURIComponent(channelMessagesMatch[1]); + const channelItemsMatch = /^\/channels\/([^/]+)\/items$/.exec(path); + if (channelItemsMatch && request.method() === "GET") { + const channelId = decodeURIComponent(channelItemsMatch[1]); const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "200", 10); const limit = Math.min(Math.max(Number.isInteger(rawLimit) ? rawLimit : 200, 1), 500); - const beforeMessageId = url.searchParams.get("beforeMessageId"); - const threadId = url.searchParams.get("threadId"); + const rawBeforeSequence = url.searchParams.get("beforeSequence"); + const beforeSequence = rawBeforeSequence === null ? undefined : Number.parseInt(rawBeforeSequence, 10); + const requestedThreadId = url.searchParams.get("threadId") ?? undefined; + const requestedAnchorItemId = url.searchParams.get("anchorItemId") ?? undefined; state.channelMessageQueries?.push({ channelId, limit, - beforeMessageId: beforeMessageId ?? undefined, - threadId: threadId ?? undefined, + beforeSequence, + threadId: requestedThreadId, + anchorItemId: requestedAnchorItemId, }); const hiddenQueuedMessageIds = new Set(state.runInterventions.flatMap((intervention) => { if (!isRecord(intervention) || typeof intervention.startedAt === "string" || !isRecord(intervention.messagePayload)) { @@ -10904,24 +11218,8 @@ export async function handleDaemonRoute( ? [intervention.messagePayload.sourceMessageId] : []; })); - const messages = state.channelMessages - .filter((message) => - isRecord(message) && - message.channelId === channelId && - message.status !== "superseded" && - !hiddenQueuedMessageIds.has(typeof message.id === "string" ? message.id : "") && - (!threadId || message.threadId === threadId) - ) - .sort((left, right) => { - if (!isRecord(left) || !isRecord(right)) { - return 0; - } - const leftCreatedAt = typeof left.createdAt === "string" ? left.createdAt : ""; - const rightCreatedAt = typeof right.createdAt === "string" ? right.createdAt : ""; - return leftCreatedAt.localeCompare(rightCreatedAt); - }); const pendingReplyById = state.pendingChannelAiReplies ?? {}; - for (const message of messages) { + for (const message of state.channelMessages) { if (!isRecord(message) || message.status !== "pending") { continue; } @@ -10933,27 +11231,172 @@ export async function handleDaemonRoute( delete pendingReplyById[message.id]; } } - const beforeIndex = beforeMessageId - ? messages.findIndex((message) => isRecord(message) && message.id === beforeMessageId) - : messages.length; - if (beforeMessageId && beforeIndex < 0) { - await route.fulfill({ status: 400, json: { error: "bad_request", message: `Invalid channel message cursor: ${beforeMessageId}` } }); + if (beforeSequence !== undefined && (!Number.isInteger(beforeSequence) || beforeSequence < 1)) { + await route.fulfill({ status: 400, json: { error: "bad_request", message: `Invalid conversation item sequence cursor: ${rawBeforeSequence}` } }); return; } - const pageEnd = beforeIndex; - const pageStart = Math.max(0, pageEnd - limit); - const pageMessages = messages.slice(pageStart, pageEnd); + if (requestedThreadId && requestedAnchorItemId) { + await route.fulfill({ status: 400, json: { error: "bad_request", message: "threadId and anchorItemId cannot be used together" } }); + return; + } + const thread = requestedAnchorItemId + ? ensureMockChannelTopicThread(state, channelId, requestedAnchorItemId) + : mockChannelConversationThread(state, channelId, requestedThreadId); + const visibleEntries = mockChannelConversationEntries(state, channelId) + .filter((entry) => entry.item.threadId === thread.id) + .filter((entry) => !hiddenQueuedMessageIds.has(String(entry.item.id))) + .filter((entry) => beforeSequence === undefined || Number(entry.item.sequence) < beforeSequence); + const pageStart = Math.max(0, visibleEntries.length - limit); + const pageEntries = visibleEntries.slice(pageStart); + const turnById = new Map(pageEntries.map((entry) => [entry.turn.id, entry.turn])); + thread.nextItemSequence = mockChannelConversationEntries(state, channelId) + .filter((entry) => entry.item.threadId === thread.id).length + 1; await route.fulfill({ json: { cwd, channelId, - messages: pageMessages, + thread, + turns: [...turnById.values()], + items: pageEntries.map((entry) => entry.item), + decorations: mockChannelConversationDecorations(pageEntries), hasMore: pageStart > 0, - nextBeforeMessageId: pageStart > 0 && isRecord(pageMessages[0]) && typeof pageMessages[0].id === "string" ? pageMessages[0].id : undefined, + nextBeforeSequence: pageStart > 0 ? pageEntries[0]?.item.sequence : undefined, }, }); return; } + const channelTurnContextCorrectionMatch = /^\/channels\/([^/]+)\/turns\/([^/]+)\/context\/corrections$/.exec(path); + if (channelTurnContextCorrectionMatch && request.method() === "POST") { + const channelId = decodeURIComponent(channelTurnContextCorrectionMatch[1]); + const turnId = decodeURIComponent(channelTurnContextCorrectionMatch[2]); + const context = state.channelTurnContexts[turnId]; + if (!isRecord(context)) { + await route.fulfill({ status: 404, json: { error: "channel_turn_not_found", message: `Channel turn not found: ${turnId}` } }); + return; + } + const body = await request.postDataJSON() as Record; + const segmentId = typeof body.segmentId === "string" ? body.segmentId : ""; + const expectedAssignmentRevision = typeof body.expectedAssignmentRevision === "number" + ? body.expectedAssignmentRevision + : Number.NaN; + state.channelTurnContextCorrectionRequests.push({ + channelId, + turnId, + segmentId, + expectedAssignmentRevision, + }); + const assignment = isRecord(context.assignment) ? context.assignment : undefined; + const projection = isRecord(context.projection) ? context.projection : undefined; + const segments = Array.isArray(context.segments) ? context.segments.filter(isRecord) : []; + const currentRevision = assignment && typeof assignment.revision === "number" ? assignment.revision : undefined; + if (!assignment || !projection || currentRevision === undefined) { + await route.fulfill({ status: 500, json: { error: "mock_context_invalid", message: `Mock context is incomplete: ${turnId}` } }); + return; + } + + const applyAssignment = (targetSegmentId: string, decisionSource: "user" | "model_policy"): { + readonly previousAssignment: Record; + readonly assignment: Record; + readonly segment: Record; + readonly projection: Record; + readonly invalidatedSummaryIds: readonly string[]; + } | undefined => { + const segment = segments.find((candidate) => candidate.id === targetSegmentId); + if (!segment) return undefined; + const previousAssignment = { ...assignment }; + const revision = currentRevision + 1; + const nextAssignment = { + ...assignment, + id: `${String(assignment.id ?? `assignment_${turnId}`)}_r${revision}`, + segmentId: targetSegmentId, + revision, + decisionSource, + supersedesAssignmentId: assignment.id, + createdAt: "2026-08-10T08:05:00.000Z", + }; + const invalidatedSummaryIds = Array.isArray(context.summaries) + ? context.summaries.filter(isRecord).map((summary) => String(summary.id ?? "")).filter(Boolean) + : []; + const nextProjection = { + ...projection, + projectionVersion: `${String(projection.projectionVersion ?? "ctxv2_mock")}_r${revision}`, + sourceStateFingerprint: `${String(projection.sourceStateFingerprint ?? "source_mock")}_r${revision}`, + segmentId: targetSegmentId, + summaryIds: [], + }; + context.assignment = nextAssignment; + context.segment = segment; + context.projection = nextProjection; + context.summaries = []; + return { + previousAssignment, + assignment: nextAssignment, + segment, + projection: nextProjection, + invalidatedSummaryIds, + }; + }; + + if (state.staleNextChannelTurnContextCorrection) { + const staleSegmentId = state.staleNextChannelTurnContextCorrection.segmentId; + delete state.staleNextChannelTurnContextCorrection; + const staleUpdate = applyAssignment(staleSegmentId, "model_policy"); + if (!staleUpdate) { + await route.fulfill({ status: 500, json: { error: "mock_context_segment_missing", message: `Mock segment not found: ${staleSegmentId}` } }); + return; + } + await route.fulfill({ + status: 409, + json: { + error: "context_assignment_revision_conflict", + message: `Context assignment revision conflict: expected ${expectedAssignmentRevision}, current ${staleUpdate.assignment.revision}`, + }, + }); + return; + } + if (!Number.isSafeInteger(expectedAssignmentRevision) || expectedAssignmentRevision !== currentRevision) { + await route.fulfill({ + status: 409, + json: { + error: "context_assignment_revision_conflict", + message: `Context assignment revision conflict: expected ${expectedAssignmentRevision}, current ${currentRevision}`, + }, + }); + return; + } + const corrected = applyAssignment(segmentId, "user"); + if (!corrected) { + await route.fulfill({ status: 400, json: { error: "context_segment_unavailable", message: `Context segment is not available to this turn: ${segmentId}` } }); + return; + } + await route.fulfill({ + json: { + cwd, + channelId, + thread: context.thread, + turn: context.turn, + segment: corrected.segment, + assignment: corrected.assignment, + previousAssignment: corrected.previousAssignment, + invalidatedSummaryIds: corrected.invalidatedSummaryIds, + projection: corrected.projection, + idempotentReplay: false, + }, + }); + return; + } + const channelTurnContextMatch = /^\/channels\/([^/]+)\/turns\/([^/]+)\/context$/.exec(path); + if (channelTurnContextMatch && request.method() === "GET") { + const channelId = decodeURIComponent(channelTurnContextMatch[1]); + const turnId = decodeURIComponent(channelTurnContextMatch[2]); + const context = state.channelTurnContexts[turnId]; + if (!isRecord(context)) { + await route.fulfill({ status: 404, json: { error: "channel_turn_not_found", message: `Channel turn not found: ${turnId}` } }); + return; + } + await route.fulfill({ json: { ...context, cwd, channelId } }); + return; + } const channelRouteDecisionsMatch = /^\/channels\/([^/]+)\/message-route-decisions$/.exec(path); if (channelRouteDecisionsMatch && request.method() === "GET") { const channelId = decodeURIComponent(channelRouteDecisionsMatch[1]); @@ -10986,10 +11429,11 @@ export async function handleDaemonRoute( }); return; } - if (channelMessagesMatch && request.method() === "POST") { - const channelId = decodeURIComponent(channelMessagesMatch[1]); + const channelTurnsMatch = /^\/channels\/([^/]+)\/turns$/.exec(path); + if (channelTurnsMatch && request.method() === "POST") { + const channelId = decodeURIComponent(channelTurnsMatch[1]); const body = await request.postDataJSON() as Record; - state.lastChannelMessageRequest = body; + state.lastChannelTurnRequest = body; if (state.channelMessageDelayMs) { await new Promise((resolve) => setTimeout(resolve, state.channelMessageDelayMs)); } @@ -10998,11 +11442,15 @@ export async function handleDaemonRoute( await route.fulfill({ status: 500, json: { error: "mock_send_failed", message: "Mock message send failed." } }); return; } + const conversationThread = mockChannelTurnThread(state, channelId, body); const message = { id: `msg_${state.channelMessages.length + 1}`, workspaceId: "workspace_default", channelId, - threadId: typeof body.threadId === "string" ? body.threadId : undefined, + threadId: conversationThread.id, + threadSurfaceKind: conversationThread.surfaceKind, + threadRelationKind: conversationThread.relationKind, + threadAnchorItemId: conversationThread.anchorItemId, kind: typeof body.kind === "string" ? body.kind : "message", intent: typeof body.intent === "string" ? body.intent : "discuss", authorType: typeof body.authorType === "string" ? body.authorType : "local-user", @@ -11021,7 +11469,7 @@ export async function handleDaemonRoute( state.channelMessages.push(message); const materializationClarifyReply = maybeCreateMockMaterializationClarifyReply(state, channelId, message); if (materializationClarifyReply) { - await route.fulfill({ status: 201, json: { message, aiReply: materializationClarifyReply } }); + await route.fulfill({ status: 201, json: buildMockChannelTurnResponse(state, channelId, message, [materializationClarifyReply]) }); return; } const channel = state.channels.find((item) => isRecord(item) && item.id === channelId); @@ -11030,7 +11478,7 @@ export async function handleDaemonRoute( ? maybeCreateMockWorkflowProposal(state, channelId, message, autoReplyMode) : undefined; if (mockWorkflowProposal) { - await route.fulfill({ status: 201, json: { message, ...mockWorkflowProposal } }); + await route.fulfill({ status: 201, json: buildMockChannelTurnResponse(state, channelId, message, [], mockWorkflowProposal) }); return; } const shouldAutoReply = body.requestAiReply === true || @@ -11039,7 +11487,7 @@ export async function handleDaemonRoute( message.authorType === "local-user" && message.kind === "message" && message.intent === "discuss" && - !message.threadId && + conversationThread.surfaceKind === "channel_root" && (message.body.includes("?") || message.body.includes("?") || isMockContextualFollowUp(message.body)) && (autoReplyMode === "default_teammate" || autoReplyMode === "auto_route") ); @@ -11084,7 +11532,8 @@ export async function handleDaemonRoute( id: `msg_${state.channelMessages.length + 1}`, workspaceId: "workspace_default", channelId, - threadId: message.threadId ?? message.id, + threadId: conversationThread.id, + sourceItemId: message.id, kind: "ai_reply", intent: "discuss", authorType: "ai-teammate", @@ -11108,7 +11557,10 @@ export async function handleDaemonRoute( state.pendingChannelAiReplies ??= {}; state.pendingChannelAiReplies[aiReply.id] = "收到,我会按频道上下文推进。"; } - await route.fulfill({ status: 201, json: aiReply ? { message, aiReply } : { message } }); + await route.fulfill({ + status: 201, + json: buildMockChannelTurnResponse(state, channelId, message, aiReply ? [aiReply] : []), + }); return; } const channelProposalsMatch = /^\/channels\/([^/]+)\/proposals$/.exec(path); diff --git a/apps/web/e2e/ui2-channel-error-ownership.spec.ts b/apps/web/e2e/ui2-channel-error-ownership.spec.ts index 2b567b6ce..24e734ce4 100644 --- a/apps/web/e2e/ui2-channel-error-ownership.spec.ts +++ b/apps/web/e2e/ui2-channel-error-ownership.spec.ts @@ -66,7 +66,7 @@ test("T13-P.9D a late Engineering send failure cannot leak into Research", async const requestStarted = createDeferred(); const releaseFailure = createDeferred(); const failureMessage = "Late Engineering message failed."; - await page.route(/\/channels\/channel_engineering\/messages\/stream(?:\?.*)?$/u, async (route) => { + await page.route(/\/channels\/channel_engineering\/turns\/stream(?:\?.*)?$/u, async (route) => { if (route.request().method() !== "POST") { await route.fallback(); return; @@ -95,7 +95,7 @@ test("T13-P.9D a late Engineering send failure cannot leak into Research", async const failedResponse = page.waitForResponse((response) => response.status() === 503 && - /\/channels\/channel_engineering\/messages\/stream(?:\?.*)?$/u.test(response.url()) + /\/channels\/channel_engineering\/turns\/stream(?:\?.*)?$/u.test(response.url()) ); releaseFailure.resolve(); await failedResponse; diff --git a/apps/web/e2e/ui2-channel-header-search.spec.ts b/apps/web/e2e/ui2-channel-header-search.spec.ts index 300c4b952..7d091887c 100644 --- a/apps/web/e2e/ui2-channel-header-search.spec.ts +++ b/apps/web/e2e/ui2-channel-header-search.spec.ts @@ -145,7 +145,7 @@ test("channel header refresh and search keep local state and overlays independen await expect(searchInput).toHaveCount(0); await expect(page.getByLabel("Channel message feed")).toContainText("Refresh source message."); - await page.route("**/channels/channel_engineering/messages?*", async (route) => { + await page.route("**/channels/channel_engineering/items?*", async (route) => { await new Promise((resolve) => setTimeout(resolve, 450)); await route.fallback(); }); diff --git a/apps/web/e2e/ui2-channel-utility-input-migration.spec.ts b/apps/web/e2e/ui2-channel-utility-input-migration.spec.ts index fd51d654e..f74c040ff 100644 --- a/apps/web/e2e/ui2-channel-utility-input-migration.spec.ts +++ b/apps/web/e2e/ui2-channel-utility-input-migration.spec.ts @@ -138,7 +138,10 @@ test("dark reduced-motion Channel terminal and topic reply keep command and keyb await expectNoOverflow(topicPanel); await captureSurface(page, "1280x800-ling-dark-channel-topic-reply-input.png"); await replyInput.press("Enter"); - await expect.poll(() => state.lastChannelMessageRequest?.threadId).toBe("message_shared_topic_root"); + await expect.poll(() => state.lastChannelTurnRequest?.surface).toEqual({ + kind: "thread", + threadId: "thread_topic_channel_engineering_message_shared_topic_root", + }); await expect(topicPanel).toContainText("First reply line"); await expect(topicPanel).toContainText("Second reply line"); await topicPanel.getByRole("button", { name: "Close topic" }).click(); diff --git a/apps/web/e2e/ui2-conversation-work-cell-shell-artifacts.spec.ts b/apps/web/e2e/ui2-conversation-work-cell-shell-artifacts.spec.ts index ca8f25b9f..55a167cf9 100644 --- a/apps/web/e2e/ui2-conversation-work-cell-shell-artifacts.spec.ts +++ b/apps/web/e2e/ui2-conversation-work-cell-shell-artifacts.spec.ts @@ -65,7 +65,7 @@ test("Channel WorkCell keeps artifacts, changed files, references, and hover pre const composer = page.getByRole("textbox", { name: "Message this channel or @ an AI teammate", exact: true }); await composer.fill("Please inspect apps/web/src/features/workspace/WorkspaceClientView.tsx"); await page.locator(".channel-composer").getByRole("button", { name: "Send" }).click(); - await expect.poll(() => state.lastChannelMessageRequest?.fileRefs?.[0]?.path) + await expect.poll(() => state.lastChannelTurnRequest?.fileRefs?.[0]?.path) .toBe("apps/web/src/features/workspace/WorkspaceClientView.tsx"); const references = feed.getByLabel("References").last(); const fileReference = references.getByRole("button", { name: /WorkspaceClientView\.tsx/u }); diff --git a/apps/web/src/api/channels.ts b/apps/web/src/api/channels.ts index 0588b16bd..095eaa897 100644 --- a/apps/web/src/api/channels.ts +++ b/apps/web/src/api/channels.ts @@ -21,10 +21,11 @@ import type { ChannelLinkPreviewListResponse, ChannelLinkPreviewMutationResponse, ChannelListResponse, - ChannelMessageListResponse, + ChannelConversationContextInspectResponse, + ChannelConversationItemPageResponse, + ChannelConversationTurnResponse, ChannelIntentDecisionListResponse, ChannelMessageRouteDecisionListResponse, - ChannelMessageMutationResponse, ChannelMutationResponse, ChannelPlaybookCandidateListResponse, ChannelPlaybookCandidateMutationResponse, @@ -63,13 +64,14 @@ import type { CreateChannelExecutionProposalRequest, CreateChannelHandoffRequest, CreateChannelLinkPreviewRequest, - CreateChannelMessageRequest, + CreateChannelTurnRequest, CreateTaskRequest, CreateChannelWorkflowFromHandoffRequest, CreateChannelWorkflowRequest, CreateChannelWorkflowStepRequest, CreateChannelWorkflowTemplateCompileRequest, StartChannelWorkflowStepRequest, + ListChannelConversationItemsOptions, Task, TaskEditFormState, TaskListResponse, @@ -87,6 +89,8 @@ import type { UpdateChannelWorkflowRequest, UpdateChannelWorkflowStepRequest, UpdateChannelRequest, + CorrectChannelTurnContextRequest, + CorrectChannelTurnContextResponse, } from "../types.js"; import { channelFormToPayload, channelFormToUpdatePayload, taskEditFormToPayload } from "./channel-payloads.js"; import { daemonClient } from "./client.js"; @@ -280,28 +284,45 @@ export async function openChannelWorkspace(cwd: string, channelId: string): Prom return daemonClient.openChannelWorkspace(cwd, channelId); } -export async function listChannelMessages( +export async function listChannelConversationItems( cwd: string, channelId: string, - options: { readonly limit?: number; readonly beforeMessageId?: string; readonly threadId?: string } = {}, -): Promise { - return daemonClient.listChannelMessages(cwd, channelId, options); + options: ListChannelConversationItemsOptions = {}, +): Promise { + return daemonClient.listChannelConversationItems(cwd, channelId, options); } -export async function createChannelMessage( +export async function createChannelTurn( cwd: string, channelId: string, - input: CreateChannelMessageRequest, -): Promise { - return daemonClient.createChannelMessage(cwd, channelId, input); + input: CreateChannelTurnRequest, +): Promise { + return daemonClient.createChannelTurn(cwd, channelId, input); } -export async function streamChannelMessage( +export async function streamChannelTurn( cwd: string, channelId: string, - input: CreateChannelMessageRequest, + input: CreateChannelTurnRequest, ): Promise { - return daemonClient.streamChannelMessage(cwd, channelId, input); + return daemonClient.streamChannelTurn(cwd, channelId, input); +} + +export async function getChannelTurnContext( + cwd: string, + channelId: string, + turnId: string, +): Promise { + return daemonClient.getChannelTurnContext(cwd, channelId, turnId); +} + +export async function correctChannelTurnContext( + cwd: string, + channelId: string, + turnId: string, + input: CorrectChannelTurnContextRequest, +): Promise { + return daemonClient.correctChannelTurnContext(cwd, channelId, turnId, input); } export async function listChannelMessageRouteDecisions( diff --git a/apps/web/src/app/channel-message-stream-projection.ts b/apps/web/src/app/channel-message-stream-projection.ts index 9aaf526a9..377e64ef4 100644 --- a/apps/web/src/app/channel-message-stream-projection.ts +++ b/apps/web/src/app/channel-message-stream-projection.ts @@ -1,14 +1,31 @@ import type { + ChannelConversationTurnResponse, ChannelMessage, - ChannelMessageStreamBlockedEvent, - ChannelMessageStreamDoneEvent, - ChannelMessageStreamErrorEvent, - ChannelMessageStreamReadyEvent, + ChannelReplyDispatch, } from "../types.js"; +export interface ChannelPresentationStreamReadyEvent { + readonly message: ChannelMessage; + readonly aiReply?: ChannelMessage; + readonly aiReplies?: readonly ChannelMessage[]; +} + +export interface ChannelPresentationStreamTerminalEvent { + readonly message?: ChannelMessage; + readonly aiReply?: ChannelMessage; + readonly aiReplies?: readonly ChannelMessage[]; + readonly replyDispatch?: ChannelReplyDispatch; + readonly autoStartedWorkflow?: ChannelConversationTurnResponse["autoStartedWorkflow"]; + readonly error?: string; +} + +export interface ChannelPresentationStreamErrorEvent extends ChannelPresentationStreamTerminalEvent { + readonly error: string; +} + export function projectChannelStreamReady( current: readonly ChannelMessage[], - event: ChannelMessageStreamReadyEvent, + event: ChannelPresentationStreamReadyEvent, ): readonly ChannelMessage[] { const replies = channelStreamReplies(event); return replies.length > 0 @@ -18,7 +35,7 @@ export function projectChannelStreamReady( export function projectChannelStreamTerminal( current: readonly ChannelMessage[], - event: ChannelMessageStreamDoneEvent | ChannelMessageStreamBlockedEvent | ChannelMessageStreamErrorEvent, + event: ChannelPresentationStreamTerminalEvent, ): readonly ChannelMessage[] { const replies = channelStreamReplies(event); if (event.replyDispatch?.disposition === "followup_queued") { diff --git a/apps/web/src/app/use-channel-actions-controller.ts b/apps/web/src/app/use-channel-actions-controller.ts index 16de1dc1e..dbfd808a6 100644 --- a/apps/web/src/app/use-channel-actions-controller.ts +++ b/apps/web/src/app/use-channel-actions-controller.ts @@ -8,7 +8,7 @@ import { createChannelWorkflowFromHandoff, createTask, deleteChannelAttachment, - streamChannelMessage, + streamChannelTurn, updateChannel, updateChannelBrief, updateChannelCollaborationProtocol, @@ -48,13 +48,14 @@ import type { ChannelReplyDisposition, ConversationActiveReplyTarget, ChannelMessageFileRef, - ChannelMessageStreamBlockedEvent, - ChannelMessageStreamDeltaEvent, - ChannelMessageStreamDoneEvent, - ChannelMessageStreamErrorEvent, - ChannelMessageStreamReplyPendingEvent, - ChannelMessageStreamReadyEvent, - ChannelMessageStreamRunLinkedEvent, + ChannelConversationStreamBlockedEvent, + ChannelConversationStreamDeltaEvent, + ChannelConversationStreamDoneEvent, + ChannelConversationStreamErrorEvent, + ChannelReplyPendingEvent, + ChannelConversationStreamReplyPendingEvent, + ChannelConversationStreamReadyEvent, + ChannelConversationStreamRunLinkedEvent, ChannelWorkflow, ChannelWorkflowStep, ProviderPreset, @@ -80,7 +81,13 @@ import { import { projectChannelStreamReady, projectChannelStreamTerminal, + type ChannelPresentationStreamErrorEvent, } from "./channel-message-stream-projection.js"; +import { + projectChannelConversationTurn, + projectMessageItem, + projectMessageItems, +} from "../lib/channel-conversation-items.js"; export interface ChannelActionsController { readonly saveChannel: () => Promise; @@ -134,7 +141,7 @@ interface SendChannelMessageInput { readonly requestAiReply: boolean; readonly replyDisposition?: ChannelReplyDisposition; readonly onMessageAccepted?: (message: ChannelMessage) => void; - readonly onReplyPending?: (pending: ChannelMessageStreamReplyPendingEvent) => void; + readonly onReplyPending?: (pending: ChannelReplyPendingEvent) => void; readonly onReplyLinked?: (target: ConversationActiveReplyTarget) => void; readonly onReplyDispatch?: (dispatch: ChannelReplyDispatch) => void; } @@ -488,8 +495,10 @@ export function useChannelActionsController(input: { let messageAccepted = false; let autoStartedWorkflow = false; let streamErrorMessage = ""; - const response = await streamChannelMessage(input.form.cwd.trim(), channelId, { - threadId: messageInput.threadId, + const response = await streamChannelTurn(input.form.cwd.trim(), channelId, { + surface: messageInput.threadId + ? { kind: "thread", threadId: messageInput.threadId } + : { kind: "channel_root" }, body: messageInput.body, mentions: messageInput.mentions, attachmentIds: messageInput.attachmentIds, @@ -501,7 +510,8 @@ export function useChannelActionsController(input: { }); await readSseResponse(response, (event) => { if (event.event === "ready") { - const data = JSON.parse(event.data) as ChannelMessageStreamReadyEvent; + const raw = JSON.parse(event.data) as ChannelConversationStreamReadyEvent; + const data = projectChannelConversationTurn(raw); const aiReplies = data.aiReplies?.length ? data.aiReplies : data.aiReply ? [data.aiReply] : []; receivedAiReply = receivedAiReply || aiReplies.length > 0; input.setChannelMessages((current) => projectChannelStreamReady(current, data)); @@ -512,19 +522,23 @@ export function useChannelActionsController(input: { return; } if (event.event === "reply_pending") { - const data = JSON.parse(event.data) as ChannelMessageStreamReplyPendingEvent; - messageInput.onReplyPending?.(data); + const data = JSON.parse(event.data) as ChannelConversationStreamReplyPendingEvent; + messageInput.onReplyPending?.({ + sourceMessageId: data.sourceItemId, + targetTeammateId: data.targetTeammateId, + startedAt: data.startedAt, + }); return; } if (event.event === "delta") { - const data = JSON.parse(event.data) as ChannelMessageStreamDeltaEvent; - input.setChannelMessages((current) => current.map((message) => message.id === data.messageId ? { ...message, body: data.body, status: "pending" } : message)); + const data = JSON.parse(event.data) as ChannelConversationStreamDeltaEvent; + input.setChannelMessages((current) => current.map((message) => message.id === data.itemId ? { ...message, body: data.body, status: "pending" } : message)); return; } if (event.event === "run_linked") { - const data = JSON.parse(event.data) as ChannelMessageStreamRunLinkedEvent; + const data = JSON.parse(event.data) as ChannelConversationStreamRunLinkedEvent; input.setChannelMessages((current) => current.map((message) => - message.id === data.messageId ? { ...message, linkedRunId: data.linkedRunId } : message + message.id === data.itemId ? { ...message, linkedRunId: data.linkedRunId } : message )); if (data.activeReply) { messageInput.onReplyLinked?.(data.activeReply); @@ -537,7 +551,8 @@ export function useChannelActionsController(input: { return; } if (event.event === "done") { - const data = JSON.parse(event.data) as ChannelMessageStreamDoneEvent; + const raw = JSON.parse(event.data) as ChannelConversationStreamDoneEvent; + const data = projectChannelConversationTurn(raw); const aiReplies = data.aiReplies?.length ? data.aiReplies : data.aiReply ? [data.aiReply] : []; receivedAiReply = receivedAiReply || aiReplies.length > 0; autoStartedWorkflow = Boolean(data.autoStartedWorkflow); @@ -546,7 +561,8 @@ export function useChannelActionsController(input: { return; } if (event.event === "blocked") { - const data = JSON.parse(event.data) as ChannelMessageStreamBlockedEvent; + const raw = JSON.parse(event.data) as ChannelConversationStreamBlockedEvent; + const data = { ...projectChannelConversationTurn(raw), ...(raw.error ? { error: raw.error } : {}) }; const aiReplies = data.aiReplies?.length ? data.aiReplies : data.aiReply ? [data.aiReply] : []; receivedAiReply = receivedAiReply || aiReplies.length > 0; input.setChannelMessages((current) => projectChannelStreamTerminal(current, data)); @@ -557,7 +573,29 @@ export function useChannelActionsController(input: { return; } if (event.event === "error") { - const data = JSON.parse(event.data) as ChannelMessageStreamErrorEvent; + const raw = JSON.parse(event.data) as ChannelConversationStreamErrorEvent; + const data: ChannelPresentationStreamErrorEvent = raw.userItem && raw.turn && raw.thread + ? { + ...projectChannelConversationTurn({ + thread: raw.thread, + turn: raw.turn, + userItem: raw.userItem, + ...(raw.assistantItems ? { assistantItems: raw.assistantItems } : {}), + decorations: raw.decorations ?? [], + ...(raw.replyDispatch ? { replyDispatch: raw.replyDispatch } : {}), + }), + error: raw.error, + } + : { + ...(raw.userItem + ? { message: projectMessageItem(raw.userItem, raw.decorations ?? []) } + : {}), + ...(raw.assistantItems + ? { aiReplies: projectMessageItems(raw.assistantItems, raw.decorations ?? []) } + : {}), + ...(raw.replyDispatch ? { replyDispatch: raw.replyDispatch } : {}), + error: raw.error, + }; const messages = [data.message, ...(data.aiReplies?.length ? data.aiReplies : data.aiReply ? [data.aiReply] : [])] .filter((message): message is ChannelMessage => Boolean(message)); receivedAiReply = receivedAiReply || messages.some((message) => message.kind === "ai_reply"); diff --git a/apps/web/src/domains/workspace/use-workspace-command-controller.ts b/apps/web/src/domains/workspace/use-workspace-command-controller.ts index 181e00583..ddea2aa35 100644 --- a/apps/web/src/domains/workspace/use-workspace-command-controller.ts +++ b/apps/web/src/domains/workspace/use-workspace-command-controller.ts @@ -9,7 +9,7 @@ import { createChannelDecision, createChannelHandoff, createChannelLinkPreview, - createChannelMessage, + createChannelTurn, createChannelWorkflowFromHandoff, createChannelWorkflowFromTemplate, createTask, @@ -21,7 +21,7 @@ import { patchChannel, retryChannelWorkflowStep, startChannelWorkflowStep, - streamChannelMessage, + streamChannelTurn, updateChannel, updateChannelBranch, updateChannelBrief, @@ -48,7 +48,7 @@ export function useWorkspaceCommandController() { createChannelDecision, createChannelHandoff, createChannelLinkPreview, - createChannelMessage, + createChannelTurn, createChannelWorkflowFromHandoff, createChannelWorkflowFromTemplate, createTask, @@ -60,7 +60,7 @@ export function useWorkspaceCommandController() { patchChannel, retryChannelWorkflowStep, startChannelWorkflowStep, - streamChannelMessage, + streamChannelTurn, updateChannel, updateChannelBranch, updateChannelBrief, diff --git a/apps/web/src/domains/workspace/use-workspace-domain.ts b/apps/web/src/domains/workspace/use-workspace-domain.ts index 6c4cb9a85..f9b88c90e 100644 --- a/apps/web/src/domains/workspace/use-workspace-domain.ts +++ b/apps/web/src/domains/workspace/use-workspace-domain.ts @@ -10,7 +10,7 @@ import { listChannelExecutionProposals, listChannelHandoffs, listChannelLinkPreviews, - listChannelMessages, + listChannelConversationItems, listChannelMessageRouteDecisions, listChannelIntentDecisions, listChannelWorkflowSteps, @@ -31,6 +31,7 @@ import { } from "../../api.js"; import type { DomainController, DomainError } from "../../app/domain-controller.js"; import { deriveChannelTasks } from "../../lib/channel-artifacts.js"; +import { projectChannelConversationItemPage } from "../../lib/channel-conversation-items.js"; import { defaultChannelCreateForm, defaultChannelEditForm, @@ -233,6 +234,7 @@ export function useWorkspaceDomain(input: { const [taskEditForm, setTaskEditForm] = useState(defaultTaskEditForm); const [taskRunProviderId, setTaskRunProviderId] = useState(""); const [channelMessages, setChannelMessages] = useState([]); + const [channelItemsBeforeSequence, setChannelItemsBeforeSequence] = useState(); const [memoryObjects, setMemoryObjects] = useState([]); const [usedMemoryEvents, setUsedMemoryEvents] = useState([]); const [channelMessagesHasOlder, setChannelMessagesHasOlder] = useState(false); @@ -451,6 +453,7 @@ export function useWorkspaceDomain(input: { if (!cwd || !channelId) { setChannelMessages([]); setChannelMessagesHasOlder(false); + setChannelItemsBeforeSequence(undefined); return; } @@ -460,13 +463,18 @@ export function useWorkspaceDomain(input: { try { clearScopedFeedback("composer", channelId); setError(undefined); - const response = await listChannelMessages(cwd, channelId, { limit: channelMessageInitialLimit, threadId }); + const response = await listChannelConversationItems(cwd, channelId, { + limit: channelMessageInitialLimit, + surface: threadId ? { kind: "thread", threadId } : { kind: "channel_root" }, + }); if (!loadGuard.isCurrent(guardKey, token)) { return; } - setChannelMessages(response.messages); - setChannelMessagesHasOlder(response.hasMore === true); - input.onChannelMessagesLoaded?.(channelId, response.messages); + const page = projectChannelConversationItemPage(response); + setChannelMessages(page.messages); + setChannelMessagesHasOlder(page.hasMore); + setChannelItemsBeforeSequence(page.nextBeforeSequence); + input.onChannelMessagesLoaded?.(channelId, page.messages); } catch (caught) { if (!loadGuard.isCurrent(guardKey, token)) return; reportScopedFeedback("composer", channelId, "load", caught); @@ -479,8 +487,7 @@ export function useWorkspaceDomain(input: { async function loadOlderChannelMessages(channelId = selectedChannelId, threadId?: string): Promise { const cwd = input.cwd.trim(); - const beforeMessageId = channelMessages[0]?.id; - if (!cwd || !channelId || !beforeMessageId || channelMessagesLoadingOlder || !channelMessagesHasOlder) { + if (!cwd || !channelId || channelItemsBeforeSequence === undefined || channelMessagesLoadingOlder || !channelMessagesHasOlder) { return; } @@ -490,16 +497,18 @@ export function useWorkspaceDomain(input: { try { clearScopedFeedback("composer", channelId); setError(undefined); - const response = await listChannelMessages(cwd, channelId, { + const response = await listChannelConversationItems(cwd, channelId, { limit: channelMessageOlderLimit, - beforeMessageId, - threadId, + beforeSequence: channelItemsBeforeSequence, + surface: threadId ? { kind: "thread", threadId } : { kind: "channel_root" }, }); if (!loadGuard.isCurrent(guardKey, token)) { return; } - setChannelMessages((current) => mergeChannelMessages(response.messages, current)); - setChannelMessagesHasOlder(response.hasMore === true); + const page = projectChannelConversationItemPage(response); + setChannelMessages((current) => mergeChannelMessages(page.messages, current)); + setChannelMessagesHasOlder(page.hasMore); + setChannelItemsBeforeSequence(page.nextBeforeSequence); } catch (caught) { if (!loadGuard.isCurrent(guardKey, token)) return; reportScopedFeedback("composer", channelId, "load", caught); @@ -939,6 +948,7 @@ export function useWorkspaceDomain(input: { setTasks([]); setChannelMessages([]); setChannelMessagesHasOlder(false); + setChannelItemsBeforeSequence(undefined); setChannelMessagesLoadingOlder(false); setChannelExecutionProposals([]); setChannelAttachments([]); @@ -988,6 +998,7 @@ export function useWorkspaceDomain(input: { setMemoryObjects([]); setUsedMemoryEvents([]); setChannelMessagesHasOlder(false); + setChannelItemsBeforeSequence(undefined); setChannelMessagesLoadingOlder(false); setChannelExecutionProposals([]); setChannelAttachments([]); diff --git a/apps/web/src/features/workspace/ChannelInspectorPanels.tsx b/apps/web/src/features/workspace/ChannelInspectorPanels.tsx index 2c5e19b24..141aca98d 100644 --- a/apps/web/src/features/workspace/ChannelInspectorPanels.tsx +++ b/apps/web/src/features/workspace/ChannelInspectorPanels.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { LingDaemonError } from "@ling-stack/api-client"; import { Check, CircleAlert, @@ -12,6 +13,7 @@ import { } from "lucide-react"; import { useI18n, type Translate } from "../../i18n.js"; import { formatDateTime } from "../../lib/formatting.js"; +import { correctChannelTurnContext, getChannelTurnContext } from "../../api.js"; import { InlineFeedback, Select, useLingToast } from "../../shared/ui/index.js"; import { RuntimeDiagnosticsPanel } from "./RuntimeDiagnosticsPanel.js"; import { ChannelBriefEditor, type ChannelBriefDraft } from "./ChannelBriefEditor.js"; @@ -26,6 +28,7 @@ import type { ChannelBrief, ChannelCollaborationProtocolResponse, ChannelContextSummary, + ChannelConversationContextInspectResponse, ChannelDecision, ChannelHandoff, ChannelHandoffStatus, @@ -222,6 +225,8 @@ export function ChannelInspector({ ) : null} {mode === "routing" ? ( ) : ( - + )} ) : null} @@ -762,10 +777,14 @@ function ChannelRouteInspectContent({ } function ChannelIntentInspectContent({ + cwd, + channelId, decision, teammates, t, }: { + readonly cwd: string; + readonly channelId: string; readonly decision: ChannelIntentDecision; readonly teammates: readonly ChannelMention[]; readonly t: Translate; @@ -849,10 +868,140 @@ function ChannelIntentInspectContent({ ) : null} + ); } +function ChannelContextInspectDetails({ + cwd, + channelId, + decision, + t, +}: { + readonly cwd: string; + readonly channelId: string; + readonly decision: ChannelIntentDecision; + readonly t: Translate; +}): JSX.Element { + const [inspect, setInspect] = useState(); + const [selectedSegmentId, setSelectedSegmentId] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + let current = true; + setInspect(undefined); + setSelectedSegmentId(""); + setError(""); + if (!cwd || !channelId || !decision.sourceTurnId) return () => { current = false; }; + setLoading(true); + void getChannelTurnContext(cwd, channelId, decision.sourceTurnId) + .then((response) => { + if (!current) return; + setInspect(response); + setSelectedSegmentId(response.assignment.segmentId); + }) + .catch((caught) => { + if (current) setError(caught instanceof Error ? caught.message : String(caught)); + }) + .finally(() => { + if (current) setLoading(false); + }); + return () => { current = false; }; + }, [channelId, cwd, decision.sourceTurnId]); + + async function saveCorrection(): Promise { + if (!inspect || !selectedSegmentId || selectedSegmentId === inspect.assignment.segmentId || saving) return; + setSaving(true); + setError(""); + try { + await correctChannelTurnContext(cwd, channelId, decision.sourceTurnId, { + segmentId: selectedSegmentId, + expectedAssignmentRevision: inspect.assignment.revision, + }); + const refreshed = await getChannelTurnContext(cwd, channelId, decision.sourceTurnId); + setInspect(refreshed); + setSelectedSegmentId(refreshed.assignment.segmentId); + } catch (caught) { + if (caught instanceof LingDaemonError && caught.code === "context_assignment_revision_conflict") { + try { + const refreshed = await getChannelTurnContext(cwd, channelId, decision.sourceTurnId); + setInspect(refreshed); + setSelectedSegmentId(refreshed.assignment.segmentId); + setError(t("Context changed while you were editing. Review the latest segment and try again.")); + } catch (refreshError) { + setError(refreshError instanceof Error ? refreshError.message : String(refreshError)); + } + } else { + setError(caught instanceof Error ? caught.message : String(caught)); + } + } finally { + setSaving(false); + } + } + + if (loading) { + return

{t("Loading context inspect...")}

; + } + if (!inspect) { + return error + ? {error} + :

{t("Context inspect metadata is unavailable.")}

; + } + const coverage = inspect.summaries.length > 0 + ? inspect.summaries.map((summary) => `${summary.coveredFromSequence}-${summary.coveredThroughSequence}`).join(", ") + : t("None"); + return ( +
+ {t("Context inspect summary")} +
+
{t("Thread")}
{inspect.thread.id}
+
{t("Turn")}
{inspect.turn.id}
+
{t("Context segment")}
{inspect.assignment.segmentId}
+
{t("Projection version")}
{inspect.projection.projectionVersion}
+
{t("Item watermark")}
{inspect.projection.throughItemSequence}
+
{t("Summary coverage")}
{coverage}
+
{t("Source refs")}
{inspect.sourceRefs.length}
+
{t("Dropped items")}
{inspect.droppedItemCount}
+
{t("Redacted items")}
{inspect.redactedItemCount}
+
+ {inspect.sourceRefs.length > 0 ? ( +
    + {inspect.sourceRefs.slice(0, 20).map((ref) => ( +
  • {ref.type}:{ref.id}
  • + ))} +
+ ) : null} + + {error ? {error} : null} + + {t("Context diagnostics never include raw prompts, hidden reasoning, private memory bodies, or secret values.")} +
+ ); +} + function channelRouteDecisionSummaryLabel(decision: ChannelMessageRouteDecision, t: Translate): string { if (decision.status === "failed") { return t("Auto reply failed"); diff --git a/apps/web/src/features/workspace/ChannelMessageRenderers.tsx b/apps/web/src/features/workspace/ChannelMessageRenderers.tsx index f6a40a66c..ddf4dcbb4 100644 --- a/apps/web/src/features/workspace/ChannelMessageRenderers.tsx +++ b/apps/web/src/features/workspace/ChannelMessageRenderers.tsx @@ -389,6 +389,11 @@ export function createChannelMessageRenderers(input: ChannelMessageRenderersInpu }): ReactNode { const pendingCoordinatorMessage = block.message.status === "pending"; const body = conversationCoordinatorDisplayBody(turn, block.message, block.phase, input.t).trim(); + const proposedPlanBlock = readChannelProposedPlanMessage(body, block.message); + const proposedPlan = proposedPlanBlock + ? input.proposedPlanArtifactsById.get(proposedPlanBlock.plan.id) ?? proposedPlanBlock.plan + : undefined; + const visibleBody = proposedPlanBlock?.displayBody.trim() ?? body; const evidenceNode = (block.memoryCitationEvents.length > 0 || block.message.webEvidence) ? ( 0 ? ( @@ -402,7 +407,7 @@ export function createChannelMessageRenderers(input: ChannelMessageRenderersInpu webEvidence={block.message.webEvidence} /> ) : null; - if (!body) { + if (!visibleBody && !proposedPlanBlock) { return evidenceNode ?
{evidenceNode}
: null; } return ( @@ -414,7 +419,19 @@ export function createChannelMessageRenderers(input: ChannelMessageRenderersInpu key={block.id} > {pendingCoordinatorMessage ? : null} - + {visibleBody ? : null} + {proposedPlanBlock ? ( + + ) : null} {evidenceNode} ); diff --git a/apps/web/src/features/workspace/WorkspaceClientView.tsx b/apps/web/src/features/workspace/WorkspaceClientView.tsx index 1ec57a765..91e35abe9 100644 --- a/apps/web/src/features/workspace/WorkspaceClientView.tsx +++ b/apps/web/src/features/workspace/WorkspaceClientView.tsx @@ -184,7 +184,7 @@ import type { ChannelMention, ChannelMessage, ChannelMessageFileRef, - ChannelMessageStreamReplyPendingEvent, + ChannelReplyPendingEvent, ChannelMessageRouteDecision, ChannelReplyDispatch, ChannelReplyDisposition, @@ -662,7 +662,7 @@ export function WorkspaceClientView({ readonly requestAiReply: boolean; readonly replyDisposition?: ChannelReplyDisposition; readonly onMessageAccepted?: (message: ChannelMessage) => void; - readonly onReplyPending?: (pending: ChannelMessageStreamReplyPendingEvent) => void; + readonly onReplyPending?: (pending: ChannelReplyPendingEvent) => void; readonly onReplyLinked?: (target: ConversationActiveReplyTarget) => void; readonly onReplyDispatch?: (dispatch: ChannelReplyDispatch) => void; }) => Promise<{ readonly errorMessage?: string }>; @@ -983,9 +983,17 @@ export function WorkspaceClientView({ ); const latestEditableChannelMessageId = useMemo(() => [...channelMessagesForDisplay] .filter((message) => message.authorType === "local-user" && !message.pendingReplacement) - .filter((message) => selectedChannelBranchRootThreadId - ? message.threadId === selectedChannelBranchRootThreadId - : !message.threadId) + .filter((message) => { + if (message.threadSurfaceKind) { + return selectedChannelBranchRootThreadId + ? message.threadSurfaceKind === "branch" && message.threadId === selectedChannelBranchRootThreadId + : message.threadSurfaceKind === "channel_root"; + } + // Data-only legacy fixtures have no canonical topology metadata. + return selectedChannelBranchRootThreadId + ? message.threadId === selectedChannelBranchRootThreadId + : !message.threadId; + }) .at(-1)?.id, [channelMessagesForDisplay, selectedChannelBranchRootThreadId]); const memoryByIdForDisplay = useMemo(() => { if (!channelWorkCellDemoFixture) { @@ -1816,6 +1824,7 @@ export function WorkspaceClientView({ const guidanceModeActive = channelGuidanceTarget.guidanceModeActive; const activeConversationSourceRunId = channelGuidanceTarget.sourceRunId; const threadReplyController = useChannelThreadReplyController({ + cwd, selectedChannelId, channelMessages, channelRuns, @@ -3079,7 +3088,12 @@ export function WorkspaceClientView({ showComposerFeedback("info", t("Source unavailable")); return; } - const targetFeedItemId = `message:${plan.sourceMessageId}`; + const sourceTurn = conversationTurnProjection.turns.find((turn) => + turn.sourceMessageId === plan.sourceMessageId || + turn.coordinatorStartMessages.some((message) => message.id === plan.sourceMessageId) || + turn.coordinatorTerminalMessages.some((message) => message.id === plan.sourceMessageId) + ); + const targetFeedItemId = sourceTurn?.id ?? `message:${plan.sourceMessageId}`; const target = Array.from(document.querySelectorAll(".message-feed-virtual-row")) .find((element) => element.dataset.feedItemId === targetFeedItemId); if (!target) { @@ -3197,9 +3211,12 @@ export function WorkspaceClientView({ const channelComposerPermissionPreset = permissionPreset ?? inferPermissionPreset(permissionMode); function channelMessageEditSurface(message: ChannelMessage): "channel_root" | "channel_branch" | "topic_reply" { - if (selectedChannelBranchRootThreadId && message.threadId === selectedChannelBranchRootThreadId) { - return "channel_branch"; + if (message.threadSurfaceKind) { + if (message.threadSurfaceKind === "branch") return "channel_branch"; + if (message.threadSurfaceKind === "topic") return "topic_reply"; + return "channel_root"; } + if (selectedChannelBranchRootThreadId && message.threadId === selectedChannelBranchRootThreadId) return "channel_branch"; return message.threadId ? "topic_reply" : "channel_root"; } @@ -3227,6 +3244,7 @@ export function WorkspaceClientView({ }); setEditingChannelMessageId(undefined); await Promise.all([onRefreshChannelMessages(), onRefreshChannelActivity()]); + await threadReplyController.refreshThread(); } catch (error) { setChannelMessageEditError(error instanceof Error ? error.message : String(error)); } finally { @@ -3260,6 +3278,7 @@ export function WorkspaceClientView({ setChannelReplacementRetryError(result.failureReason ?? pending.failureReason ?? ""); } await Promise.all([onRefreshChannelMessages(), onRefreshChannelActivity()]); + await threadReplyController.refreshThread(); } catch (error) { setChannelReplacementRetryError(error instanceof Error ? error.message : String(error)); } finally { diff --git a/apps/web/src/features/workspace/channel-guidance-targets.ts b/apps/web/src/features/workspace/channel-guidance-targets.ts index 66e3f9223..d96c5073b 100644 --- a/apps/web/src/features/workspace/channel-guidance-targets.ts +++ b/apps/web/src/features/workspace/channel-guidance-targets.ts @@ -101,6 +101,13 @@ function activeReplyBelongsToConversationLine( if (!sourceMessage || sourceMessage.channelId !== input.channelId) { return false; } + if (sourceMessage.threadSurfaceKind) { + return input.conversationLineThreadId + ? sourceMessage.threadId === input.conversationLineThreadId + : sourceMessage.threadSurfaceKind === "channel_root"; + } + // Data-only legacy fixtures identify Root with an absent threadId and Topic with an + // anchor item id. Canonical runtime messages always take the branch above. return (sourceMessage.threadId ?? undefined) === input.conversationLineThreadId; } diff --git a/apps/web/src/features/workspace/channel-pending-send-preview.ts b/apps/web/src/features/workspace/channel-pending-send-preview.ts index 26714ddc9..c80eeca74 100644 --- a/apps/web/src/features/workspace/channel-pending-send-preview.ts +++ b/apps/web/src/features/workspace/channel-pending-send-preview.ts @@ -1,4 +1,4 @@ -import type { ChannelMessageStreamReplyPendingEvent } from "../../types.js"; +import type { ChannelReplyPendingEvent } from "../../types.js"; import type { PendingChannelSendPreview } from "./ChannelMessageFeed.js"; export function acknowledgePendingChannelSendPreview( @@ -13,7 +13,7 @@ export function acknowledgePendingChannelSendPreview( export function projectPendingChannelReply( current: PendingChannelSendPreview | undefined, previewId: string, - pending: ChannelMessageStreamReplyPendingEvent, + pending: ChannelReplyPendingEvent, ): PendingChannelSendPreview | undefined { if (!current || current.id !== previewId) return current; if (current.sourceMessageId && current.sourceMessageId !== pending.sourceMessageId) return current; diff --git a/apps/web/src/features/workspace/useChannelComposerActionsController.ts b/apps/web/src/features/workspace/useChannelComposerActionsController.ts index 1f15dbb68..61afeef54 100644 --- a/apps/web/src/features/workspace/useChannelComposerActionsController.ts +++ b/apps/web/src/features/workspace/useChannelComposerActionsController.ts @@ -27,7 +27,7 @@ import type { Channel, ChannelAttachment, ChannelMessage, - ChannelMessageStreamReplyPendingEvent, + ChannelReplyPendingEvent, ChannelReplyDispatch, ChannelReplyDisposition, ConversationActiveReplyTarget, @@ -129,7 +129,7 @@ interface ChannelComposerActionsInput { readonly requestAiReply: boolean; readonly replyDisposition?: ChannelReplyDisposition; readonly onMessageAccepted?: (message: ChannelMessage) => void; - readonly onReplyPending?: (pending: ChannelMessageStreamReplyPendingEvent) => void; + readonly onReplyPending?: (pending: ChannelReplyPendingEvent) => void; readonly onReplyLinked?: (target: ConversationActiveReplyTarget) => void; readonly onReplyDispatch?: (dispatch: ChannelReplyDispatch) => void; }) => Promise<{ readonly errorMessage?: string }>; diff --git a/apps/web/src/features/workspace/useChannelMessageFeedAssembly.ts b/apps/web/src/features/workspace/useChannelMessageFeedAssembly.ts index 9a39fdbad..9f09ce584 100644 --- a/apps/web/src/features/workspace/useChannelMessageFeedAssembly.ts +++ b/apps/web/src/features/workspace/useChannelMessageFeedAssembly.ts @@ -19,7 +19,7 @@ interface ChannelMessageFeedAssemblyInput { readonly selectedChannelId: string; } -interface PendingChannelSendState { +export interface PendingChannelSendState { readonly acknowledged: boolean; readonly acknowledgedMessageId?: string; readonly aiReplyStarted: boolean; @@ -127,7 +127,7 @@ export function useChannelMessageFeedAssembly({ }; } -function derivePendingChannelSendState( +export function derivePendingChannelSendState( channelMessages: readonly ChannelMessage[], pendingChannelSendPreview: PendingChannelSendPreview | undefined, ): PendingChannelSendState { @@ -140,7 +140,7 @@ function derivePendingChannelSendState( .reverse() .find((message) => message.channelId === pendingChannelSendPreview.channelId && - message.threadId === pendingChannelSendPreview.threadId && + messageMatchesPendingSurface(message, pendingChannelSendPreview.threadId) && message.kind === "message" && message.authorType === "local-user" && message.body === pendingChannelSendPreview.body && @@ -148,13 +148,14 @@ function derivePendingChannelSendState( sameStringList(message.linkPreviewIds, pendingChannelSendPreview.linkPreviewIds) && sameFileRefList(message.fileRefs ?? [], pendingChannelSendPreview.fileRefs) ); - const replyThreadId = acknowledgedMessage ? acknowledgedMessage.threadId ?? acknowledgedMessage.id : pendingChannelSendPreview.threadId; + const replyThreadId = acknowledgedMessage?.threadId ?? pendingChannelSendPreview.threadId; + const replyTurnId = acknowledgedMessage?.turnId; const previewCreatedAt = Date.parse(pendingChannelSendPreview.createdAt); const aiReplyStarted = channelMessages.some((message) => { if (message.channelId !== pendingChannelSendPreview.channelId || message.kind !== "ai_reply") { return false; } - if (replyThreadId && message.threadId !== replyThreadId) { + if (replyTurnId ? message.turnId !== replyTurnId : replyThreadId && message.threadId !== replyThreadId) { return false; } const messageCreatedAt = Date.parse(message.createdAt); @@ -167,6 +168,15 @@ function derivePendingChannelSendState( }; } +function messageMatchesPendingSurface(message: ChannelMessage, requestedThreadId: string | undefined): boolean { + if (message.threadSurfaceKind) { + return requestedThreadId + ? message.threadId === requestedThreadId + : message.threadSurfaceKind === "channel_root"; + } + return message.threadId === requestedThreadId; +} + function sameStringList(left: readonly string[], right: readonly string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); } diff --git a/apps/web/src/features/workspace/useChannelThreadReplyController.ts b/apps/web/src/features/workspace/useChannelThreadReplyController.ts index 495be5119..d76963cde 100644 --- a/apps/web/src/features/workspace/useChannelThreadReplyController.ts +++ b/apps/web/src/features/workspace/useChannelThreadReplyController.ts @@ -1,6 +1,8 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { listChannelConversationItems } from "../../api.js"; import type { ChannelMention, + ChannelConversationSurface, ChannelMessage, ChannelMessageFileRef, ChannelReplyDisposition, @@ -8,6 +10,7 @@ import type { RunSession, WorkUnit, } from "../../types.js"; +import { projectChannelConversationItemPage } from "../../lib/channel-conversation-items.js"; import { deriveChannelReplyGuidanceTargets } from "./channel-guidance-targets.js"; import { findMentionsInText } from "./channelMentionText.js"; @@ -18,6 +21,7 @@ export interface ChannelThreadReplySnapshot { } interface ChannelThreadReplyControllerInput { + readonly cwd: string; readonly selectedChannelId: string; readonly channelMessages: readonly ChannelMessage[]; readonly channelRuns: readonly RunSession[]; @@ -46,11 +50,13 @@ export interface ChannelThreadReplyController { readonly openThreadForMessage: (message: ChannelMessage) => void; readonly changeDraft: (value: string) => void; readonly sendReply: () => Promise; + readonly refreshThread: () => Promise; readonly closeThread: () => void; readonly restoreSnapshot: (snapshot: ChannelThreadReplySnapshot) => void; } export function useChannelThreadReplyController({ + cwd, selectedChannelId, channelMessages, channelRuns, @@ -59,36 +65,94 @@ export function useChannelThreadReplyController({ teammateOptions, onSendMessage, }: ChannelThreadReplyControllerInput): ChannelThreadReplyController { + // activeThreadId is the durable parent-item anchor saved in composer snapshots. + // resolvedThreadId is the canonical Topic LingThread returned by the Items API. const [activeThreadId, setActiveThreadId] = useState(""); + const [resolvedThreadId, setResolvedThreadId] = useState(""); + const [topicMessages, setTopicMessages] = useState([]); + const [loading, setLoading] = useState(false); const [draft, setDraft] = useState(""); const [pending, setPending] = useState(false); const [error, setError] = useState(""); + const loadVersionRef = useRef(0); const activeThreadRoot = useMemo( () => channelMessages.find((message) => message.id === activeThreadId), [activeThreadId, channelMessages], ); - const activeThreadMessages = useMemo( - () => activeThreadId ? channelMessages.filter((message) => message.threadId === activeThreadId) : [], - [activeThreadId, channelMessages], - ); + const activeThreadMessages = useMemo(() => { + const contextualParentReplies = activeThreadRoot + ? channelMessages.filter((message) => + message.id !== activeThreadRoot.id && ( + activeThreadRoot.turnId + ? message.turnId === activeThreadRoot.turnId + : message.threadId === activeThreadRoot.id + ) + ) + : []; + return [...new Map( + [...contextualParentReplies, ...topicMessages] + .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)) + .map((message) => [message.id, message]), + ).values()]; + }, [activeThreadRoot, channelMessages, topicMessages]); + const loadTopicMessages = useCallback(async (surface: ChannelConversationSurface): Promise => { + const workspaceCwd = cwd.trim(); + if (!workspaceCwd || !selectedChannelId || surface.kind === "channel_root") { + setResolvedThreadId(""); + setTopicMessages([]); + return; + } + const version = ++loadVersionRef.current; + setLoading(true); + setError(""); + try { + const response = await listChannelConversationItems(workspaceCwd, selectedChannelId, { + limit: 200, + surface, + }); + if (loadVersionRef.current !== version) return; + const page = projectChannelConversationItemPage(response); + setResolvedThreadId(response.thread.id); + setTopicMessages(page.messages); + } catch (loadError) { + if (loadVersionRef.current !== version) return; + setResolvedThreadId(""); + setTopicMessages([]); + setError(loadError instanceof Error ? loadError.message : String(loadError)); + } finally { + if (loadVersionRef.current === version) setLoading(false); + } + }, [cwd, selectedChannelId]); + + useEffect(() => { + if (!activeThreadId) { + ++loadVersionRef.current; + setResolvedThreadId(""); + setTopicMessages([]); + setLoading(false); + return; + } + void loadTopicMessages({ kind: "topic", anchorItemId: activeThreadId }); + }, [activeThreadId, loadTopicMessages]); + const activeReplyTarget = useMemo( - () => activeThreadId + () => resolvedThreadId ? deriveChannelReplyGuidanceTargets({ channelId: selectedChannelId, - conversationLineThreadId: activeThreadId, - channelMessages, + conversationLineThreadId: resolvedThreadId, + channelMessages: activeThreadMessages, runs: channelRuns, workUnits, transientReplies, teammateLabel: (teammateId) => teammateId ?? "", })[0]?.activeReply : undefined, - [activeThreadId, channelMessages, channelRuns, selectedChannelId, transientReplies, workUnits], + [activeThreadMessages, channelRuns, resolvedThreadId, selectedChannelId, transientReplies, workUnits], ); const openThreadForMessage = useCallback((message: ChannelMessage): void => { - setActiveThreadId(message.threadId ?? message.id); + setActiveThreadId(message.threadAnchorItemId ?? message.id); setError(""); }, []); @@ -98,20 +162,28 @@ export function useChannelThreadReplyController({ }, []); const closeThread = useCallback((): void => { + ++loadVersionRef.current; setActiveThreadId(""); + setResolvedThreadId(""); + setTopicMessages([]); + setLoading(false); setDraft(""); setError(""); }, []); const restoreSnapshot = useCallback((snapshot: ChannelThreadReplySnapshot): void => { + ++loadVersionRef.current; setActiveThreadId(snapshot.activeThreadId); + setResolvedThreadId(""); + setTopicMessages([]); + setLoading(Boolean(snapshot.activeThreadId)); setDraft(snapshot.draft); setError(snapshot.error); }, []); const sendReply = useCallback(async (): Promise => { const body = draft.trim(); - if (!activeThreadId || !body || pending) { + if (!activeThreadId || !resolvedThreadId || !body || pending || loading) { return; } const mentions = findMentionsInText(body, teammateOptions); @@ -120,7 +192,7 @@ export function useChannelThreadReplyController({ setError(""); try { const result = await onSendMessage({ - threadId: activeThreadId, + threadId: resolvedThreadId, body, mentions, attachmentIds: [], @@ -141,12 +213,21 @@ export function useChannelThreadReplyController({ return; } setDraft(""); + await loadTopicMessages({ kind: "thread", threadId: resolvedThreadId }); } catch (sendError) { setError(sendError instanceof Error ? sendError.message : String(sendError)); } finally { setPending(false); } - }, [activeReplyTarget, activeThreadId, draft, onSendMessage, pending, teammateOptions]); + }, [activeReplyTarget, activeThreadId, draft, loadTopicMessages, loading, onSendMessage, pending, resolvedThreadId, teammateOptions]); + + const refreshThread = useCallback(async (): Promise => { + if (resolvedThreadId) { + await loadTopicMessages({ kind: "thread", threadId: resolvedThreadId }); + return; + } + if (activeThreadId) await loadTopicMessages({ kind: "topic", anchorItemId: activeThreadId }); + }, [activeThreadId, loadTopicMessages, resolvedThreadId]); const snapshot = useMemo( () => ({ activeThreadId, draft, error }), @@ -157,12 +238,13 @@ export function useChannelThreadReplyController({ activeThreadRoot, activeThreadMessages, draft, - pending, + pending: pending || loading, error, snapshot, openThreadForMessage, changeDraft, sendReply, + refreshThread, closeThread, restoreSnapshot, }; diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 9c6fdca2a..65c655c8d 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -2260,7 +2260,17 @@ export const zhCN = { "Context artifact exists, but no Context Pack snapshot was recorded.": "上下文产物存在,但没有记录 Context Pack 快照。", "Context packet could not be read.": "上下文包无法读取。", "Context inspect metadata is available.": "上下文检查元数据可用。", + "Context inspect metadata is unavailable.": "上下文检查元数据不可用。", "Context inspect summary": "上下文检查摘要", + "Loading context inspect...": "正在加载上下文检查信息……", + "Item watermark": "条目水位", + "Summary coverage": "摘要覆盖范围", + "Dropped items": "已丢弃条目", + "Redacted items": "已脱敏条目", + "Correct context segment": "修正上下文分段", + "Apply context correction": "应用上下文修正", + "Context changed while you were editing. Review the latest segment and try again.": "你编辑期间上下文已经变化。请检查最新分段后重试。", + "Context diagnostics never include raw prompts, hidden reasoning, private memory bodies, or secret values.": "上下文诊断绝不会包含原始提示词、隐藏推理、私有记忆正文或密钥值。", "Budget": "预算", "Budget diagnostics": "预算诊断", "Developer diagnostics": "开发者诊断", diff --git a/apps/web/src/lib/channel-conversation-items.ts b/apps/web/src/lib/channel-conversation-items.ts new file mode 100644 index 000000000..27ddc5331 --- /dev/null +++ b/apps/web/src/lib/channel-conversation-items.ts @@ -0,0 +1,89 @@ +import type { + ChannelConversationItemDecoration, + ChannelConversationItemPageResponse, + ChannelConversationThread, + ChannelConversationTurnResponse, + ConversationItemV1, + ChannelMessage, +} from "@ling-stack/contracts"; +import { projectConversationItemToChannelMessage } from "@ling-stack/contracts"; + +export interface ChannelConversationMessagePage { + readonly messages: readonly ChannelMessage[]; + readonly hasMore: boolean; + readonly nextBeforeSequence?: number; +} + +export function projectChannelConversationItemPage( + response: ChannelConversationItemPageResponse, +): ChannelConversationMessagePage { + return { + messages: projectMessageItems(response.items, response.decorations, response.thread), + hasMore: response.hasMore, + ...(response.nextBeforeSequence !== undefined + ? { nextBeforeSequence: response.nextBeforeSequence } + : {}), + }; +} + +export function projectChannelConversationTurn(response: ChannelConversationTurnResponse): { + readonly message: ChannelMessage; + readonly aiReply?: ChannelMessage; + readonly aiReplies?: readonly ChannelMessage[]; + readonly replyDispatch?: ChannelConversationTurnResponse["replyDispatch"]; + readonly autoStartedWorkflow?: ChannelConversationTurnResponse["autoStartedWorkflow"]; +} { + const message = projectMessageItem(response.userItem, response.decorations, response.thread); + const aiReplies = projectMessageItems(response.assistantItems ?? [], response.decorations, response.thread); + return { + message, + ...(aiReplies[0] ? { aiReply: aiReplies[0] } : {}), + ...(aiReplies.length > 0 ? { aiReplies } : {}), + ...(response.replyDispatch ? { replyDispatch: response.replyDispatch } : {}), + ...(response.autoStartedWorkflow ? { autoStartedWorkflow: response.autoStartedWorkflow } : {}), + }; +} + +export function projectMessageItems( + items: readonly ConversationItemV1[], + decorations: readonly ChannelConversationItemDecoration[], + thread?: ChannelConversationThread, +): readonly ChannelMessage[] { + const decorationsByItemId = new Map(decorations.map((decoration) => [decoration.itemId, decoration])); + return items + .filter((item) => item.kind === "user_message" || item.kind === "assistant_message" || item.kind === "system_event") + .map((item) => projectMessageItemWithDecoration(item, decorationsByItemId.get(item.id), thread)); +} + +export function projectMessageItem( + item: ConversationItemV1, + decorations: readonly ChannelConversationItemDecoration[], + thread?: ChannelConversationThread, +): ChannelMessage { + return projectMessageItemWithDecoration( + item, + decorations.find((candidate) => candidate.itemId === item.id), + thread, + ); +} + +function projectMessageItemWithDecoration( + item: ConversationItemV1, + decoration: ChannelConversationItemDecoration | undefined, + thread?: ChannelConversationThread, +): ChannelMessage { + const message = projectConversationItemToChannelMessage(item, { + linkedRunId: decoration?.linkedRunId, + }); + return { + ...message, + ...(thread ? { + threadSurfaceKind: thread.surfaceKind, + threadRelationKind: thread.relationKind, + ...(thread.anchorItemId ? { threadAnchorItemId: thread.anchorItemId } : {}), + } : {}), + ...(decoration?.editedAt ? { editedAt: decoration.editedAt } : {}), + ...(decoration?.pendingReplacement ? { pendingReplacement: decoration.pendingReplacement } : {}), + ...(decoration?.webEvidence ? { webEvidence: decoration.webEvidence } : {}), + }; +} diff --git a/apps/web/src/lib/conversation-turn-projector.ts b/apps/web/src/lib/conversation-turn-projector.ts index 0610d1aa1..48b06722b 100644 --- a/apps/web/src/lib/conversation-turn-projector.ts +++ b/apps/web/src/lib/conversation-turn-projector.ts @@ -84,7 +84,11 @@ export interface BuildConversationTurnProjectionInput { export function buildConversationTurnProjection(input: BuildConversationTurnProjectionInput): ConversationTurnProjection { const hiddenActivityIds = new Set(); const channelMessages = input.messages.filter((message) => message.channelId === input.channelId); - const childMessagesByThreadId = groupBy(channelMessages.filter((message) => Boolean(message.threadId)), (message) => message.threadId ?? ""); + const messagesByTurnId = groupBy(channelMessages.filter((message) => Boolean(message.turnId)), (message) => message.turnId ?? ""); + const legacyChildMessagesBySourceItemId = groupBy( + channelMessages.filter((message) => !message.turnId && Boolean(message.threadId)), + (message) => message.threadId ?? "", + ); const proposalsBySourceMessageId = groupBy( input.proposals.filter((proposal) => proposal.channelId === input.channelId && proposal.status !== "cancelled" && proposal.status !== "superseded"), (proposal) => proposal.sourceMessageId, @@ -118,7 +122,12 @@ export function buildConversationTurnProjection(input: BuildConversationTurnProj operationCellByWorkflowId: input.operationCellByWorkflowId, operationCellByThreadId: input.operationCellByThreadId, }); - const childMessages = (childMessagesByThreadId.get(sourceMessage.id) ?? []).sort(compareByCreatedAtThenId); + const childMessages = uniqueMessages([ + ...(sourceMessage.turnId ? messagesByTurnId.get(sourceMessage.turnId) ?? [] : []), + ...(legacyChildMessagesBySourceItemId.get(sourceMessage.id) ?? []), + ]) + .filter((message) => message.id !== sourceMessage.id) + .sort(compareByCreatedAtThenId); const allCoordinatorMessages = childMessages.filter((message) => isLingAgentMessage(message, input.lingAgentAuthorId)); const coordinatorMessages = allCoordinatorMessages.filter((message) => !input.terminalReportMessageIds?.has(message.id)); const { startMessages, terminalMessages } = splitCoordinatorMessages(coordinatorMessages, operation?.cell); @@ -179,7 +188,11 @@ export function buildConversationTurnProjection(input: BuildConversationTurnProj } function isRootUserMessage(message: ChannelMessage): boolean { - return message.authorType === "local-user" && message.kind === "message" && !message.threadId; + return message.authorType === "local-user" && message.kind === "message" && (Boolean(message.turnId) || !message.threadId); +} + +function uniqueMessages(messages: readonly ChannelMessage[]): readonly ChannelMessage[] { + return [...new Map(messages.map((message) => [message.id, message])).values()]; } function isLingAgentMessage(message: ChannelMessage, lingAgentAuthorId: string): boolean { diff --git a/apps/web/test/channel-conversation-items.test.mjs b/apps/web/test/channel-conversation-items.test.mjs new file mode 100644 index 000000000..a67d361e1 --- /dev/null +++ b/apps/web/test/channel-conversation-items.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + projectChannelConversationItemPage, + projectChannelConversationTurn, +} from "../src/lib/channel-conversation-items.ts"; + +const thread = { + id: "thread-1", + workspaceId: "workspace_default", + kind: "channel_discussion", + status: "open", + title: "Channel", + objective: "Conversation", + channelId: "channel-1", + surfaceKind: "channel_root", + relationKind: "root", + nextItemSequence: 3, + createdAt: "2026-08-10T00:00:00.000Z", + updatedAt: "2026-08-10T00:01:00.000Z", +}; + +const turn = { + id: "turn-1", + workspaceId: "workspace_default", + threadId: thread.id, + kind: "initial_request", + status: "succeeded", + title: "Request", + userInstruction: "Write the answer", + channelId: "channel-1", + sourceItemId: "item-user", + createdByActor: "local-user", + createdAt: "2026-08-10T00:00:00.000Z", + updatedAt: "2026-08-10T00:01:00.000Z", +}; + +const userItem = { + schemaVersion: 1, + id: "item-user", + workspaceId: "workspace_default", + channelId: "channel-1", + threadId: thread.id, + turnId: turn.id, + sequence: 1, + kind: "user_message", + status: "committed", + actorType: "local_user", + visibility: "channel", + payload: { + body: "Write the answer", + format: "plain_text", + mentions: [], + attachmentIds: [], + linkPreviewIds: [], + fileRefs: [], + }, + domainRefs: [], + revision: 1, + createdAt: "2026-08-10T00:00:00.000Z", + updatedAt: "2026-08-10T00:00:00.000Z", + committedAt: "2026-08-10T00:00:00.000Z", +}; + +const assistantItem = { + schemaVersion: 1, + id: "item-assistant", + workspaceId: "workspace_default", + channelId: "channel-1", + threadId: thread.id, + turnId: turn.id, + sequence: 2, + kind: "assistant_message", + status: "committed", + actorType: "ling_agent", + actorId: "ling-agent", + visibility: "channel", + payload: { + body: "Here is the answer.", + format: "markdown", + streaming: false, + deliveryStatus: "ready", + }, + domainRefs: [ + { type: "conversation_item", id: userItem.id }, + { type: "run", id: "run-1" }, + ], + revision: 2, + createdAt: "2026-08-10T00:00:01.000Z", + updatedAt: "2026-08-10T00:00:02.000Z", + committedAt: "2026-08-10T00:00:02.000Z", +}; + +test("channel Web domain projects canonical items and discardable decorations", () => { + const page = projectChannelConversationItemPage({ + cwd: "/repo", + channelId: "channel-1", + thread, + turns: [turn], + items: [userItem, assistantItem], + decorations: [{ + itemId: assistantItem.id, + linkedRunId: "run-1", + editedAt: "2026-08-10T00:00:03.000Z", + webEvidence: { + status: "available", + sources: [{ url: "https://example.com" }], + readUrls: [], + updatedAt: "2026-08-10T00:00:03.000Z", + }, + }], + hasMore: true, + nextBeforeSequence: 1, + }); + assert.deepEqual(page.messages.map((message) => [message.id, message.kind]), [ + ["item-user", "message"], + ["item-assistant", "ai_reply"], + ]); + assert.equal(page.messages[0].threadId, thread.id); + assert.equal(page.messages[0].turnId, turn.id); + assert.equal(page.messages[0].threadSurfaceKind, "channel_root"); + assert.equal(page.messages[0].threadRelationKind, "root"); + assert.equal(page.messages[1].linkedRunId, "run-1"); + assert.equal(page.messages[1].editedAt, "2026-08-10T00:00:03.000Z"); + assert.equal(page.messages[1].webEvidence.sources[0].url, "https://example.com"); + assert.equal(page.nextBeforeSequence, 1); +}); + +test("channel stream projection derives presentation messages from turn items", () => { + const projected = projectChannelConversationTurn({ + thread, + turn, + userItem, + assistantItems: [assistantItem], + decorations: [{ itemId: assistantItem.id, linkedRunId: "run-1" }], + }); + assert.equal(projected.message.id, userItem.id); + assert.equal(projected.aiReply.id, assistantItem.id); + assert.deepEqual(projected.aiReplies.map((message) => message.id), [assistantItem.id]); +}); diff --git a/apps/web/test/channel-guidance-targets.test.mjs b/apps/web/test/channel-guidance-targets.test.mjs index baecfce54..fc2866ea5 100644 --- a/apps/web/test/channel-guidance-targets.test.mjs +++ b/apps/web/test/channel-guidance-targets.test.mjs @@ -70,13 +70,17 @@ test("task and terminal reply runs do not become ordinary reply targets", () => assert.equal(resolveChannelGuidanceTarget({ replyTargets: targets }), undefined); }); -test("topic replies use source message lineage instead of the canonical LingThread id", () => { +test("canonical root and topic replies use explicit LingThread topology", () => { const rootRun = run("run_root", "wu_root", "teammate_designer", "ling_thread_root", "message_root"); - const topicRun = run("run_topic", "wu_topic", "teammate_engineer", "ling_thread_root", "message_topic_reply"); + const topicRun = run("run_topic", "wu_topic", "teammate_engineer", "ling_thread_topic", "message_topic_reply"); const messages = [ - message("message_root"), - message("message_topic_root"), - message("message_topic_reply", "message_topic_root"), + { ...message("message_root", "ling_thread_root"), threadSurfaceKind: "channel_root" }, + { ...message("message_topic_root", "ling_thread_root"), threadSurfaceKind: "channel_root" }, + { + ...message("message_topic_reply", "ling_thread_topic"), + threadSurfaceKind: "topic", + threadAnchorItemId: "message_topic_root", + }, ]; const rootTargets = deriveChannelReplyGuidanceTargets({ @@ -88,7 +92,7 @@ test("topic replies use source message lineage instead of the canonical LingThre }); const topicTargets = deriveChannelReplyGuidanceTargets({ channelId: "channel_main", - conversationLineThreadId: "message_topic_root", + conversationLineThreadId: "ling_thread_topic", channelMessages: messages, runs: [rootRun, topicRun], workUnits: [workUnit("wu_root", "channel_ai_reply"), workUnit("wu_topic", "channel_ai_reply")], diff --git a/apps/web/test/channel-pending-send-preview.test.mjs b/apps/web/test/channel-pending-send-preview.test.mjs index 27f3b576f..d966e0c1c 100644 --- a/apps/web/test/channel-pending-send-preview.test.mjs +++ b/apps/web/test/channel-pending-send-preview.test.mjs @@ -4,6 +4,7 @@ import { acknowledgePendingChannelSendPreview, projectPendingChannelReply, } from "../src/features/workspace/channel-pending-send-preview.ts"; +import { derivePendingChannelSendState } from "../src/features/workspace/useChannelMessageFeedAssembly.ts"; const preview = { id: "pending_local_1", @@ -46,3 +47,45 @@ test("stale channel reply events cannot replace a newer pending send", () => { }, ).sourceMessageId, "message_current"); }); + +test("canonical root acknowledgement matches a root preview without overloading threadId", () => { + const source = channelMessage({ + id: "item_user", + turnId: "turn_user", + threadId: "thread_root", + threadSurfaceKind: "channel_root", + kind: "message", + authorType: "local-user", + body: preview.body, + }); + const unrelatedReply = channelMessage({ + id: "item_other_reply", + turnId: "turn_other", + threadId: "thread_root", + threadSurfaceKind: "channel_root", + kind: "ai_reply", + authorType: "ai-teammate", + body: "Unrelated reply", + }); + assert.deepEqual(derivePendingChannelSendState([source, unrelatedReply], preview), { + acknowledged: true, + acknowledgedMessageId: source.id, + aiReplyStarted: false, + }); +}); + +function channelMessage(overrides) { + return { + workspaceId: "workspace_default", + channelId: preview.channelId, + intent: "discuss", + mentions: [], + attachmentIds: [], + linkPreviewIds: [], + fileRefs: [], + status: "ready", + createdAt: "2026-08-07T00:00:00.000Z", + updatedAt: "2026-08-07T00:00:00.000Z", + ...overrides, + }; +} diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 3b5112b4a..1e5402f9e 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -1240,8 +1240,8 @@ limit=1..500 Surface semantics: -- `channel` is a channel-header notification projection. It never writes rows into - `channel_messages`. +- `channel` is a channel-header notification projection. It never writes ordinary + notification rows into `conversation_items`. - `teammate_direct` is a single teammate private-thread projection. Delivery is quiet window gated and writes a short direct `system` message only after the thread is idle. - `ling_agent` is for workspace or cross-owner review pressure with no safer local @@ -1404,7 +1404,7 @@ GET /teammates/:id/activity?cwd=/path/to/project&limit=100 messages, assigned tasks, teammate runs, approvals attached to teammate runs, and channel messages that mention the teammate. -## Channel Messages, Attachments, and Links +## Channel Conversation Items, Turns, Attachments, and Links Channel discussion is durable in the workspace SQLite database. Uploaded files are stored under: @@ -1412,18 +1412,36 @@ Channel discussion is durable in the workspace SQLite database. Uploaded files a .ling-stack/attachments ``` -### Messages +### Conversation Items And Turns ```http -GET /channels/:id/messages?cwd=/path/to/project&limit=100 -POST /channels/:id/messages?cwd=/path/to/project -POST /channels/:id/messages/stream?cwd=/path/to/project +GET /channels/:id/items?cwd=/path/to/project&limit=100&beforeSequence=123 +GET /channels/:id/items?cwd=/path/to/project&threadId=thread_... +GET /channels/:id/items?cwd=/path/to/project&anchorItemId=item_... +POST /channels/:id/turns?cwd=/path/to/project +POST /channels/:id/turns/stream?cwd=/path/to/project ``` -`POST` accepts a local/user/system message plus structured mentions, attachment ids, link preview ids, and optional `requestAiReply`. Referenced attachments and link previews must belong to the same channel. When AI reply is requested, the daemon uses the selected provider preset or the channel default provider in text-only, no-tools mode. If no provider is ready, the user message is still persisted and the AI reply is stored with `status: "blocked"`. +`conversation_items` is the only Channel timeline authority. `GET /items` returns the +resolved `LingThread`, owning `LingTurn` records, ordered canonical items, and discardable +presentation decorations. Pagination uses the stable per-Thread `sequence` cursor. + +The read surface is explicit: + +- no selector selects Channel Root; +- `threadId` is a canonical `LingThread.id` for Root, Topic, or Branch reads; +- `anchorItemId` materializes or reuses a Topic from a committed item; +- both selectors together return `400 bad_request`. + +`POST /turns` accepts one local user turn plus structured mentions, attachment ids, link +preview ids, file refs, optional reply supervision, and optional `requestAiReply`. +Referenced attachments and link previews must belong to the same channel. The user item +and owning `LingTurn` are committed atomically. If no provider is ready, the accepted user +item remains durable and any requested assistant item becomes blocked. ```json { + "surface": { "kind": "thread", "threadId": "thread_..." }, "body": "请 @Engineer 完善需求", "mentions": [{ "id": "engineer", "label": "Engineer", "kind": "ai-teammate" }], "attachmentIds": ["att_..."], @@ -1435,9 +1453,14 @@ POST /channels/:id/messages/stream?cwd=/path/to/project } ``` -`POST /messages/stream` uses the same request body and returns `text/event-stream`. -It persists the local message, creates durable pending AI reply messages, and streams -visible assistant text into those same message rows. +`surface` is one of `{ "kind": "channel_root" }`, +`{ "kind": "thread", "threadId": "..." }`, or +`{ "kind": "topic", "anchorItemId": "..." }`. Omission selects Channel Root. The Turn +request has no top-level `threadId` compatibility alias. + +`POST /turns/stream` uses the same body and returns `text/event-stream`. It persists the +user Item and Turn, creates durable pending assistant Items, and streams visible text into +those same Items. The Web normally sends mentions and `requestAiReply` without a duplicate `teammateId`; the daemon resolves no mention to `ling_agent`, one mention to that teammate, and multiple mentions back to `ling_agent`. @@ -1454,21 +1477,21 @@ When accepted, the response contains `replyDispatch.disposition = "followup_queu not the public Run-intervention request body, writes the intervention's channel lineage fields `sourceMessageId` and `targetTeammateId`. Continuations resolve the target's current channel model binding when their FIFO item starts. `RunSession.threadId` remains the canonical LingThread id; -`ConversationActiveReplyTarget.sourceMessageId` identifies the Channel message used for Root, -Branch, and Topic projection. `GET /channels/:id/messages` excludes queued source messages until -their intervention has `startedAt`. It also excludes a source message while dispatch classification +`ConversationActiveReplyTarget.sourceMessageId` identifies the source ConversationItem used for +Root, Branch, and Topic projection. `GET /channels/:id/items` excludes queued source Items until +their intervention has `startedAt`. It also excludes a source Item while dispatch classification is in flight between message persistence and the authoritative `reply_pending` or `followup_queued` state, preventing concurrent refresh from briefly exposing a queued message. SSE events: -- `ready`: `{ "message": ChannelMessage, "aiReply"?: ChannelMessage, "aiReplies"?: ChannelMessage[] }` -- `reply_pending`: `{ "sourceMessageId": string, "targetTeammateId": string, "startedAt": string }` -- `run_linked`: `{ "messageId": string, "linkedRunId": string, "activeReply"?: ConversationActiveReplyTarget }` -- `delta`: `{ "messageId": string, "delta": string, "body": string }` -- `done`: `{ "message": ChannelMessage, "aiReply"?: ChannelMessage, "aiReplies"?: ChannelMessage[], "autoStartedWorkflow"?: ... }` -- `blocked`: `{ "message": ChannelMessage, "aiReply"?: ChannelMessage, "aiReplies"?: ChannelMessage[], "error"?: string }` -- `error`: `{ "message"?: ChannelMessage, "aiReply"?: ChannelMessage, "aiReplies"?: ChannelMessage[], "error": string }` +- `ready`: `{ "thread": LingThread, "turn": LingTurn, "userItem": ConversationItem, "assistantItems"?: ConversationItem[], "decorations": [] }` +- `reply_pending`: `{ "sourceItemId": string, "targetTeammateId": string, "startedAt": string }` +- `run_linked`: `{ "itemId": string, "linkedRunId": string, "activeReply"?: ConversationActiveReplyTarget }` +- `delta`: `{ "itemId": string, "delta": string, "body": string }` +- `done`: `ChannelConversationTurnResponse` +- `blocked`: `ChannelConversationTurnResponse & { "error"?: string }` +- `error`: partial Turn response plus `{ "error": string }` Hidden `` blocks are not streamed as visible deltas. The daemon continues to extract and persist proposals through the channel proposal pipeline. diff --git a/docs/design/ai-teammate-private-workspace.md b/docs/design/ai-teammate-private-workspace.md index 851a25d6f..4f0df1537 100644 --- a/docs/design/ai-teammate-private-workspace.md +++ b/docs/design/ai-teammate-private-workspace.md @@ -29,7 +29,7 @@ Implemented before this slice: - Web has an `AI Teammates` management page with role, model, profile, and channel steps. - The sidebar has distinct active targets for workspace, tasks, channels, teammates, and direct messages. -- Channel messages can mention AI teammates and request a no-tools AI reply. +- Channel ConversationItems can mention AI teammates and request a supervised no-tools reply. - Channels and tasks can reference `defaultTeammateId` and `assigneeTeammateId`. Implemented in this slice: @@ -809,7 +809,8 @@ GET /teammates/:id/activity?cwd=/path/to/workspace ``` These endpoints are read-only composition endpoints. They should reuse existing task, -run, approval, and channel message stores rather than introducing duplicate durable state. +run, approval, Channel `conversation_items`, and teammate-private message stores rather +than introducing duplicate durable state. ## AI Reply And Execution Flow diff --git a/docs/design/channel-and-agent-governance-notifications.md b/docs/design/channel-and-agent-governance-notifications.md index 654a4b8db..94eea3fa6 100644 --- a/docs/design/channel-and-agent-governance-notifications.md +++ b/docs/design/channel-and-agent-governance-notifications.md @@ -92,7 +92,7 @@ Current gaps: ## Non-Goals -- No writing governance notifications into `channel_messages`. +- No writing ordinary governance notifications into `conversation_items`. - No channel feed system messages for ordinary memory, skill, or health review events. - No AI auto-reply triggered by a governance notification. - No approval, publish, archive, quarantine, or delete action performed by clicking a @@ -450,7 +450,7 @@ If there are no actionable events, the header should not show a badge. Low-prior pressure. First Web implementation uses a header popover. The popover is intentionally separate -from both `channel_messages` and the right-side task/review drawer: it can show review +from both the canonical `conversation_items` timeline and the right-side task/review drawer: it can show review pressure without rewriting conversation history or stealing the authoritative review surface. It may support notification-level `seen` and `dismiss` mutations, but those mutations affect only the notification projection; source memory, skill, protocol, and @@ -864,7 +864,7 @@ Tests: | Failure mode | Guardrail | | --- | --- | | Ling Agent becomes a dumping ground | Route local channel events to channel header, not Ling Agent. | -| Channel feed becomes noisy | Never write ordinary notifications into `channel_messages`. | +| Channel feed becomes noisy | Never write ordinary notifications into `conversation_items`. | | Private chat gets interrupted mid-turn | Gate direct system messages on idle thread and no running reply. | | Same cluster appears once per agent | Dedupe by cluster id and route to the channel owner. | | User dismisses reminder and loses work | Dismiss notification only; source governance queue remains authoritative. | diff --git a/docs/design/channel-auto-reply-router.md b/docs/design/channel-auto-reply-router.md index 22ec23b6b..51303bcfc 100644 --- a/docs/design/channel-auto-reply-router.md +++ b/docs/design/channel-auto-reply-router.md @@ -83,8 +83,8 @@ Then it sends: requestAiReply: shouldRequestAi ``` -Under Ling Agent Session, `requestAiReply` is a compatibility signal meaning "resolve a -channel reply for this accepted Composer message." Mentions remain the target authority: +Under Ling Agent Session, `requestAiReply` is an explicit responder signal meaning +"resolve a channel reply for this accepted Turn." Mentions remain the target authority: none selects `ling_agent`, one selects that teammate, and multiple return to `ling_agent` for coordination. Web does not submit another teammate selector. The flag is not an instruction to bypass executable intent detection. The daemon must persist the user message, build the @@ -99,7 +99,7 @@ executor id. `createdBy: "explicit_mention"` after the coordinator classifies the turn as `answer`; - `@Ling Agent 在工作区创建 login.html` must not be swallowed by this router. It should - become `direct_workflow`, confirmation, clarification, or blocked work before ordinary + become `execute`, a workflow proposal, structured clarification, or blocked work before ordinary answer routing; - `可以直接在工作区帮我做好吗?` after a prior reply naming one file target, such as `login.html`, should resolve that target through TargetResolver before this router is @@ -672,14 +672,16 @@ PATCH /channels/:channelId?cwd=... Accept `autoReplyMode`. -Channel message create: +Channel Turn create: ```text -POST /channels/:channelId/messages?cwd=... +POST /channels/:channelId/turns?cwd=... +POST /channels/:channelId/turns/stream?cwd=... ``` -Keep existing `requestAiReply` for explicit client behavior. The daemon should derive -auto routing when `requestAiReply` is false. +The Turn request uses the explicit `ChannelConversationSurface` union. It has no +top-level `threadId` compatibility alias. `requestAiReply` remains a responder hint; the +daemon still runs Continuity v2 intent selection before ordinary reply routing. P0 evidence route: diff --git a/docs/design/channel-conversation-continuity-v2-implementation-plan.md b/docs/design/channel-conversation-continuity-v2-implementation-plan.md index 6b0d3e700..f10019fd0 100644 --- a/docs/design/channel-conversation-continuity-v2-implementation-plan.md +++ b/docs/design/channel-conversation-continuity-v2-implementation-plan.md @@ -1,6 +1,6 @@ # Channel Conversation Continuity v2 Implementation Plan -Status: G0 complete; S-1 and S0 merged; S1+S2 through S6 implemented and locally verified; S7 pending +Status: G0 complete; S-1 and S0 merged; S1+S2 through S7 implemented and locally verified; PR-G pending Owner: conversation, channel context, Ling Agent Session, and storage maintainers Date: 2026-08-08 Last verified: 2026-08-10 @@ -562,6 +562,8 @@ remain deterministic. - Project conversation transcript blocks from `conversation_items` plus domain refs. - Replace message endpoints/types with item/turn endpoints or a stable compatibility-free API rename appropriate for the pre-release product. +- Use an explicit `ChannelConversationSurface` union so canonical Thread ids and Topic + anchor Item ids can never share one `threadId` field. - Update safe Context inspect details with: - thread/turn/segment ids; - projection version; @@ -680,6 +682,24 @@ Compare: - clarification actionability; - permission-boundary rejection rate. +Current offline semantic regression evidence is deterministic and provider-independent: + +| Required dimension | Evidence | +| --- | --- | +| Chinese continuation and content generation | `article-continuation-remains-reply` golden scenario plus the daemon issue #421 article continuation test | +| English continuation and topic switching | segmentation-policy structural inheritance, switch, and provisional-promotion tests use English turns and explicit relations | +| Ambiguous reference and actionable clarification | underspecified-work, missing-target, and multiple-target daemon tests require typed non-empty `missingFields` | +| Content versus local file execution | issue #421 article/README test and explicit file execute tests assert `reply` versus `execute` before TargetResolver | +| Explicit command execution | daemon command regression asserts `run_command`, `targetKind="command"`, and bounded `bash` tools for `pnpm test` | +| Inaccessible Segment ids | segmentation-policy tests reject model-invented segment and evidence ids before assignment | +| Multi-agent privacy and stale results | agent-context consistency tests fail closed on private-memory leakage and relevant projection drift | + +These tests exercise scripted model outputs through the real validation, persistence, and +routing pipeline. They do not claim empirical accuracy for a particular paid provider; +no paid live-provider call is part of this local closure. Provider-specific prompt or +model releases must run the same corpus through their release eval environment and record +the comparison metrics above. + ### Web E2E Add or extend channel E2E coverage for: @@ -822,8 +842,11 @@ interfaces at the end of S3 and keep implementation files separate before parall - Verify: budget, race, invalidation, reconstruction, and fallback tests. - [x] **T7 (P2, human: ~2 days / Codex: ~3-5 hours)**: enforce multi-agent projection versions and private-memory isolation. - Verify: shared-core, stale fan-in, handoff, and security fixtures. -- [ ] **T8 (P2, human: ~2 days / Codex: ~3-4 hours)**: switch transcript/API/Web diagnostics and remove old runtime authorities. - - Verify: package suites, channel E2E, old-symbol search, and migration smoke. +- [x] **T8 (P2, human: ~2 days / Codex: ~3-4 hours)**: switch transcript/API/Web diagnostics and remove old runtime authorities. + - Verified locally: Contracts 36, Core 362, Daemon 905, API Client 49, Remote Protocol + 40, Web unit 45, Channel E2E 113, and adjacent Channel E2E 15 all pass; typecheck, + i18n, lint, migration/recovery, paging-index, old-authority search, public-safety, + docs-link, and diff checks pass. ## NOT In Scope @@ -870,7 +893,8 @@ schema. | Design Review | `/plan-design-review` | UI and UX gaps | 0 | not run | UI changes are limited to later advanced Context details | | DX Review | `/plan-devex-review` | Developer experience gaps | 0 | not run | Not required for the design baseline | -**VERDICT:** ENG CLEARED. S-1 and S0 are merged; S1+S2 through S6 are implemented -and locally verified; S7 retains the reviewed delivery order and exit criteria. +**VERDICT:** ENG CLEARED. S-1 and S0 are merged; S1+S2 through S7 are implemented +and locally verified. PR-G, remote CI, main merge verification, and issue #421 closure +remain delivery operations rather than design decisions. NO UNRESOLVED DECISIONS diff --git a/docs/design/channel-conversation-continuity-v2.md b/docs/design/channel-conversation-continuity-v2.md index 5cd58750a..c929694a0 100644 --- a/docs/design/channel-conversation-continuity-v2.md +++ b/docs/design/channel-conversation-continuity-v2.md @@ -1,6 +1,6 @@ # Channel Conversation Continuity v2 -Status: approved design baseline; S-1 and S0 merged; S1+S2 through S6 implemented and locally verified; S7 pending +Status: approved design baseline; S-1 and S0 merged; S1+S2 through S7 implemented and locally verified; PR-G pending Date: 2026-08-08 Last verified: 2026-08-10 Scope: channel conversation persistence, Thread/Turn/Item topology, context segmentation, @@ -197,6 +197,20 @@ Rules: - A channel may contain many work threads, but only one active non-archived `channel_root` thread. +API callers select a surface without overloading identifiers: + +```ts +type ChannelConversationSurface = + | { kind: "channel_root" } + | { kind: "thread"; threadId: string } + | { kind: "topic"; anchorItemId: string }; +``` + +`threadId` always means a canonical `LingThread.id`. Topic materialization alone accepts +an `anchorItemId`; after the daemon returns the Topic Thread, paging and sending use that +Thread id. A Turn request has no top-level `threadId` compatibility alias, and an Items +query cannot send `threadId` and `anchorItemId` together. + ### Root-level topic isolation Users will still discuss unrelated work in the root without creating a formal topic. diff --git a/docs/design/channel-intent-workflow-selector.md b/docs/design/channel-intent-workflow-selector.md index b93a47078..0562a1def 100644 --- a/docs/design/channel-intent-workflow-selector.md +++ b/docs/design/channel-intent-workflow-selector.md @@ -15,6 +15,24 @@ Related docs: - [Multi-Agent Orchestrator](./multi-agent-orchestrator.md) - [Channel Context Budget Manager](channel-context-budget-manager.md) +## Current Runtime Contract + +Continuity v2 accepts only these actions: + +```text +no_reply | reply | visible_multi_reply | clarify | workflow_proposal | execute +``` + +Every decision references the source Turn, source ConversationItem, effective Segment, +and shared projection version. `reply` completes without TargetResolver. Only a validated +`execute` or executable proposal candidate may enter target resolution and policy gating. +`clarify` carries typed `missingFields`; model output cannot grant tools, approvals, +workspace scope, secrets, external sends, or memory access. + +The v1 action taxonomy retained below is non-normative migration history. No runtime or +new test may emit `single_reply` or `direct_workflow`, and no compatibility alias maps +those values into v2. + ## Purpose This document now describes an internal Ling Agent Coordinator classifier. The user-facing diff --git a/docs/design/channel-message-streaming.md b/docs/design/channel-message-streaming.md index bb4c064a3..9ff7a239c 100644 --- a/docs/design/channel-message-streaming.md +++ b/docs/design/channel-message-streaming.md @@ -1,208 +1,139 @@ -# Channel Message Streaming +# Channel Turn Streaming -## Problem +Status: active Continuity v2 transport contract +Updated: 2026-08-10 -Channel discussion replies currently behave differently from private teammate chat: +Related docs: -- private chat creates a durable pending AI message and streams `delta` events into it; -- channel chat creates a pending AI message, runs completion in the background, then replaces the body only when the runner finishes. +- [Channel Conversation Continuity v2](channel-conversation-continuity-v2.md) +- [Conversation Surface Contract](conversation-surface-contract.md) +- [Ling Agent Session Control Plane](ling-agent-session.md) -This makes channel Ling Agent replies feel like a batch job instead of a GPT/Codex-style live conversation. +## Purpose -## Product Behavior - -Sending a channel message from the composer should start a normal Ling Agent channel turn and show the assistant reply as it is generated. The user should not need to select a special route, workflow, selector, or debug mode. The channel remains durable: refreshing the page during generation should show the latest persisted partial AI message. - -The existing JSON `POST /channels/:id/messages` remains supported for API compatibility. The Web app should prefer the streaming endpoint for composer sends. - -The current message-body stream is a compatibility transport. The target product stream -is a shared semantic transcript stream that can be consumed by Web, Desktop, Mobile -through its constrained Gateway, and CLI/TUI-style clients: +Channel streaming is a live transport over the canonical conversation timeline. It does +not own history, intent, lineage, or recovery. ```text -daemon/core channel records and run events - -> ConversationTranscript snapshot - -> transcript delta stream - -> Web renderer / Desktop renderer / CLI renderer +LingThread + -> LingTurn + -> ConversationItem user/assistant/action/result rows + -> item/turn API response + -> SSE projection + -> Web presentation ``` -Clients may render the same transcript differently, but they should not re-run intent -routing, workflow/run grouping, active-cell lifecycle, or message-body phrase detection. -That interpretation belongs in the daemon/core projection layer and the shared contracts. - -Streaming changes only the transport and UI cadence; it must not make the reply -stateless. The daemon should build the same Ling Agent layered channel context for -streamed and non-streamed sends: current turn envelope, channel state, effective protocol, -bounded recent visible conversation, compacted channel context, active work state when -relevant, materialized inputs, and scoped memory recall. Short follow-ups such as `400字`, -`继续`, or `改短点` should therefore continue the visible prior topic instead of being -treated as isolated new prompts. - -That context is owned by the [Channel Context Processing Layer](channel-context-processing-layer.md), -not by the streaming transport. SSE only delivers the visible body updates for the same -durable Ling Agent turn. - -Transcript projection is owned by the shared conversation contract described in -[Contracts and API Client](contracts-api-client.md), not by the Web component tree. Web can -host the first implementation while the visual design stabilizes, but the contract must be -portable to Desktop and CLI before the projection becomes a permanent API. - -While a pending AI reply has no visible text yet, the Web feed should show a -generic `Thinking...` status with a spinner. As soon as `delta.body` is non-empty, -the same bubble must render that body instead of a generic pending label. -The initial optimistic row remains visible after user-message acknowledgement only while -dispatch is unresolved. The daemon emits `reply_pending` before slow routing work only for a -message that owns an idle conversation-line lane. If the Web sends an explicit same-line -`queue_followup` disposition and the daemon validates it, the terminal stream event returns -`followup_queued`; Web removes the optimistic transcript row and projects the durable -intervention in the guidance queue instead. Other active replies in the same Channel do not -capture Root, Branch, or Topic messages from a different line. - -If the channel feed is already pinned to the latest item, streamed `delta.body` -updates must keep the feed pinned to the bottom as the same message grows. If -the user has intentionally scrolled upward to read history, streaming updates -must not steal focus; the existing jump-to-latest affordance remains the recovery -path. +`conversation_items` is the only Channel timeline authority. The browser may optimistically +render a pending send, but it must replace that state from daemon item/turn responses and may +discard all local projections on refresh. ## HTTP Contract -### Compatibility: Message Body Stream +### Read items ```http -POST /channels/:id/messages/stream?cwd=/path/to/project -Accept: text/event-stream -Content-Type: application/json +GET /channels/:channelId/items?cwd=/path&limit=200&beforeSequence=123 +GET /channels/:channelId/items?cwd=/path&threadId=thread_... +GET /channels/:channelId/items?cwd=/path&anchorItemId=item_... ``` -The request body is the same as `POST /channels/:id/messages`. +- no selector selects the Channel Root; +- `threadId` is always a canonical `LingThread.id`; +- `anchorItemId` materializes or reuses the Topic anchored at that committed item; +- sending both selectors is a `400 bad_request`; +- pagination uses the stable per-Thread `sequence` cursor. -SSE events: - -| Event | Payload | Semantics | -| --- | --- | --- | -| `ready` | `{ message, aiReply?, aiReplies? }` | The local user message and one or more pending durable AI messages now exist. | -| `reply_pending` | `{ sourceMessageId, targetTeammateId, startedAt }` | The accepted message owns the idle conversation-line lane and is preparing a reply. Queued follow-ups do not emit it. This is not a Run or persistence object. | -| `run_linked` | `{ messageId, linkedRunId, activeReply? }` | The durable pending AI message is linked to its real Run and supervising WorkUnit. Channel `activeReply.sourceMessageId` identifies the source Channel message; `activeReply.threadId` remains the canonical LingThread id. | -| `delta` | `{ messageId, delta, body }` | A visible text delta was appended to the durable AI message. `body` is the full visible body so far. | -| `done` | `{ message, aiReply?, aiReplies?, replyDispatch?, autoStartedWorkflow? }` | The channel turn reached its persisted dispatch state. `followup_queued` keeps the message out of the transcript until continuation start. | -| `blocked` | `{ message, aiReply?, aiReplies?, error? }` | The reply could not continue because of provider, teammate, or runtime blocking. | -| `error` | `{ message?, aiReply?, aiReplies?, error }` | The stream itself failed after the request started. Persisted messages, if any, are included. | - -### Target: Transcript Snapshot And Delta Stream - -The target API should expose transcript state as a durable read model: +### Create a turn ```http -GET /channels/:id/transcript?cwd=/path/to/project -Accept: application/json +POST /channels/:channelId/turns?cwd=/path +POST /channels/:channelId/turns/stream?cwd=/path ``` -Suggested response: +The JSON and streaming routes accept the same request body: ```ts -interface ChannelConversationTranscriptResponse { - readonly cwd: string; - readonly channelId: string; - readonly transcript: ConversationTranscript; - readonly sequence: number; +type ChannelConversationSurface = + | { kind: "channel_root" } + | { kind: "thread"; threadId: string } + | { kind: "topic"; anchorItemId: string }; + +interface CreateChannelTurnRequest { + readonly idempotencyKey?: string; + readonly surface?: ChannelConversationSurface; + readonly contextSegmentId?: string; + readonly body: string; + readonly mentions?: readonly ChannelMention[]; + readonly attachmentIds?: readonly string[]; + readonly linkPreviewIds?: readonly string[]; + readonly fileRefs?: readonly ChannelMessageFileRef[]; + readonly requestAiReply?: boolean; + readonly replyDisposition?: { + readonly mode: "queue_followup"; + readonly activeRunId?: string; + }; + readonly providerPresetId?: string; + readonly teammateId?: string; } ``` -The live stream can be a separate endpoint or an extension of the existing send stream. -The important part is the event model: - -| Event | Payload | Semantics | -| --- | --- | --- | -| `transcript_snapshot` | `{ transcript, sequence }` | Full current transcript for initial load or reconnect. | -| `turn_started` | `{ turnId, sequence }` | A user turn now owns a transcript container. | -| `block_started` | `{ turnId, block, sequence }` | A semantic block exists, for example coordinator text, operation cell, command row, approval, or agent span. | -| `block_delta` | `{ turnId, blockId, patch, sequence }` | The same block mutates in place. Text deltas, stdout chunks, status changes, and evidence growth update the existing block. | -| `block_completed` | `{ turnId, blockId, status, patch?, sequence }` | The block is terminal and should remain as a committed transcript block. | -| `turn_completed` | `{ turnId, status, sequence }` | The turn is terminal. Active popovers should clear unless another active turn exists. | -| `error` | `{ error, sequence? }` | The stream failed. Persisted transcript state remains the source of truth. | - -This mirrors the useful part of Codex's `Thread / Turn / Item` model without copying its -JSON-RPC app-server wholesale. Ling's names can remain `ConversationTranscript`, -`ConversationTurn`, and `TranscriptBlock`. - -## Persistence And Reconnect - -`delta` updates the same `ChannelMessage` row with `status: "pending"`. `done` updates the same row to `ready`, `blocked`, or `failed`. The browser stream is only a live transport; the SQLite channel message store remains the source of truth. - -The constrained mobile surface uses the same semantic transcript model through the -Remote Gateway described in [Ling Mobile Remote Access](mobile-remote-access.md). It does -not receive the broad channel SSE route or pass `cwd`. The Gateway maps an opaque approved -workspace id to the local store, projects a redacted snapshot/delta stream, and keeps the -daemon as the only state authority. - -Remote text sends carry a durable `(gatewayId, deviceEpoch, commandId)` receipt. The receipt, -the local-user channel message, and a bounded Mobile dispatch intent are committed in one -daemon transaction before asynchronous conversational reply work starts. A reconnect that -retries the same command therefore returns the original message result instead of creating -a duplicate message or duplicate visible reply. A different request body with the same -command id is an explicit idempotency conflict. +Omitted `surface` and `{ kind: "channel_root" }` both select the Channel Root. There is +no top-level `threadId` compatibility alias. Topic clients use `anchorItemId` only for the +initial lookup/materialization; later reads and sends use the returned canonical Thread id. -If the browser disconnects, the daemon should stop writing to that client but should not treat the disconnect as user cancellation for channel replies. Explicit cancellation remains a separate runtime control concern. +## SSE Events -The same rule applies to Mobile: a phone network drop never cancels a PC run or changes its -state. The phone re-authenticates, resumes from its last acknowledged remote sequence, and -requests a fresh snapshot when the cursor is too old. Mobile V0 has no offline mutation -queue, so an unsent draft is local UI state rather than an eventually replayed command. - -For transcript streaming, the daemon persists the underlying channel/run/workflow records -and rebuilds the transcript snapshot from those records. The transcript stream itself is -not the source of truth. Reconnect should request a snapshot at or after the last observed -sequence and then continue with deltas. If delta replay is unavailable, clients must -replace their local transcript with the latest snapshot. - -## Hidden Proposal Safety +| Event | Payload | Meaning | +| --- | --- | --- | +| `ready` | `{ thread, turn, userItem, assistantItems?, decorations }` | The accepted user item and any current assistant items are durable. | +| `reply_pending` | `{ sourceItemId, targetTeammateId, startedAt }` | The turn owns an idle reply lane and routing is in progress. | +| `run_linked` | `{ itemId, linkedRunId, activeReply? }` | An assistant item is linked to its supervised Run. | +| `delta` | `{ itemId, delta, body }` | The same pending assistant item gained visible text. | +| `done` | `ChannelConversationTurnResponse` | Dispatch reached its durable terminal state. | +| `blocked` | `ChannelConversationTurnResponse & { error? }` | Provider, teammate, policy, or runtime blocked the reply. | +| `error` | partial turn response plus `error` | Transport or reply processing failed after acceptance. | -Channel replies may contain hidden `...` blocks. Streaming must never reveal those blocks in the visible chat body. The daemon streams only the visible prefix before any hidden proposal block and finalizes the hidden proposal through the existing `extractChannelExecutionProposal` path. +Clients upsert by `ConversationItem.id`. They never create a second assistant row merely +because `ready`, `run_linked`, `delta`, and `done` arrive separately. -## Multi-Reply Behavior +## Persistence And Reconnect -When a channel turn intentionally creates multiple AI replies, the stream may emit multiple `ready` events and `delta` events tagged by `messageId`. Clients should upsert by id instead of assuming a single assistant bubble. +- the user item and owning `LingTurn` are created atomically; +- a streaming assistant item remains `pending` and is updated in place until committed, + blocked, or failed; +- disconnecting a browser stops writes to that socket but does not cancel the Run; +- refresh reconstructs the feed from Items, Turns, and discardable decorations; +- retrying an accepted turn with the same idempotency key returns the existing item; +- committed corrections create superseding items rather than mutating history. -## Collaboration Stream Folding +## Reply Supervision -For multi-agent collaboration, streaming is still a live transport, not permanent process -spam and not a collaboration-card-specific model. +Root, Topic, and Branch Threads have independent reply lanes. The Web sends +`queue_followup` only for the active reply projected on the same canonical Thread. The +daemon validates `activeRunId`; stale, terminal, cross-channel, and cross-line targets +fall back to normal intent selection. -Default rules: +Queued guidance stays out of the transcript until its intervention starts. During the +persistence-to-classification gap, the daemon fence prevents a background refresh from +briefly exposing and retracting that guidance row. -- requirement shaping streams update the same shaping card until Ling Agent posts a - compact summary or asks a clarifying question; -- execution step streams update the active `OperationCell` or `AgentActivitySpan` row; -- when a step reaches terminal state, the same row changes status and remains folded in - the transcript; -- parallel branch streams expose one latest-action row per active agent, with prior - semantic actions folded under that agent span; -- raw runtime stream events stay in evidence/details, not the default feed. +## Safety -See -[Ling Agent Operation Cell UI](ling-agent-operation-cell-ui.md) -for the current product-level transcript behavior. +- hidden proposal markup and hidden reasoning never enter `delta.body`; +- raw prompts, secrets, and teammate-private memory never enter public decorations; +- streaming cannot grant tools, approvals, workspace scope, or external-send authority; +- `requestAiReply` and mentions are responder hints, not execution permission; +- intent remains `reply`, `clarify`, `workflow_proposal`, `execute`, or `no_reply` under + the Continuity v2 contract. ## Acceptance -- `POST /channels/:id/messages/stream` streams `ready -> reply_pending -> ready -> run_linked -> delta -> done` for a normal Ling Agent reply. -- `reply_pending.targetTeammateId`, the pending AI message author, WorkUnit, Run, and daemon - dispatch lane use the same canonical identity; no mention is `ling_agent`. -- The visible AI message body updates during generation and is persisted through `GET /channels/:id/messages`. -- Hidden proposal blocks are not present in any streamed `delta` body. -- Existing non-streaming `POST /channels/:id/messages` continues to work. -- The Web composer consumes the stream and updates the existing message bubble rather than inserting duplicate assistant messages. -- Pending AI bubbles show `Thinking...` only before visible body text exists; they do not hide streamed body text behind a static pending label. -- While pinned to latest, long streamed AI replies keep the message feed at the bottom without requiring manual scrolling. -- Streamed and non-streamed channel replies include the same layered channel context, while excluding the current pending assistant placeholder. -- Multi-agent collaboration streams fold into task/step summaries after terminal state - instead of leaving raw process streams in the default feed. -- Future transcript streams expose turn/block lifecycle events where running blocks mutate - in place and terminal blocks commit without being deleted. -- Web and Desktop consume the same transcript snapshot/delta contract. CLI may render the - same model as text or TUI, but it does not own the conversation projection. -- Mobile consumes the same semantic snapshot/delta contract through an authenticated - Remote Gateway; it never receives arbitrary daemon routes, local paths, raw tool output, - or artifact bytes. -- Repeating a Mobile `commandId` after a disconnect creates one and only one durable local - user message. +- normal replies emit `ready -> reply_pending -> ready -> run_linked -> delta -> done`; +- Topic initial reads use `anchorItemId`, then all later operations use canonical + `threadId`; +- refresh during generation restores the same pending assistant item; +- pinned feeds follow growing text while manually scrolled feeds do not steal focus; +- Root, Topic, and Branch messages never capture another line's active reply; +- hidden proposal blocks do not appear in streamed text; +- the JSON and SSE routes produce the same durable Turn and Item lineage; +- no `/channels/:id/messages` compatibility route or message-store authority remains. diff --git a/docs/design/conversation-shared-capabilities-detailed-design.md b/docs/design/conversation-shared-capabilities-detailed-design.md index 275a6c087..9560e8f8d 100644 --- a/docs/design/conversation-shared-capabilities-detailed-design.md +++ b/docs/design/conversation-shared-capabilities-detailed-design.md @@ -47,7 +47,8 @@ Daemon application services -> rich-content-independent edit orchestration and private execution dispatch Core stores - -> message revisions, supersession links, turn/run state, audit records + -> Channel conversation items, private-chat messages, supersession links, + turn/run state, audit records ``` Hard dependency rules: @@ -55,7 +56,8 @@ Hard dependency rules: - shared React components do not import daemon routes or workspace stores; - content renderers receive normalized blocks and callbacks, not API clients; - edit orchestration does not live in React hooks; -- native message stores remain the authority for current visible message data; +- `conversation_items` is the Channel timeline authority; teammate-direct and other + non-Channel surfaces retain their scoped native stores; - revision and supersession records remain the authority for edit history; - `LingTurn`, `RunSession`, approvals, artifacts, and `TerminalReport` remain the authority for execution evidence. @@ -70,7 +72,7 @@ flowchart LR UI["Shared MessageRow / WorkCell / TerminalReport"] Edit["Conversation message edit service"] Direct["Teammate direct execution service"] - Stores["Native message stores + revision/supersession ledger"] + Stores["Channel Items + scoped private stores + supersession ledger"] Runtime["LingTurn + RunSession + approvals + artifacts"] Surface --> Policy @@ -86,8 +88,8 @@ flowchart LR Runtime --> View ``` -The shared UI owns presentation only. Native surface adapters remain responsible for -message identity and lane ownership. The edit service coordinates revisions and +The shared UI owns presentation only. Surface adapters project canonical Item or scoped +private-message identity and lane ownership. The edit service coordinates revisions and supersession. The direct execution service resolves effective authority and delegates to the standard runtime. Neither application service may synthesize UI state from assistant copy. diff --git a/docs/design/conversation-surface-contract.md b/docs/design/conversation-surface-contract.md index b61219ab3..a1c15d5f9 100644 --- a/docs/design/conversation-surface-contract.md +++ b/docs/design/conversation-surface-contract.md @@ -303,10 +303,10 @@ When `followup_queued` arrives, the optimistic guidance rekeys to the durable in and remains as a confirmed bridge until the authoritative intervention snapshot appears or the source message enters the transcript. The handoff must never expose an empty or duplicate intermediate frame. -The daemon also fences the source message between message persistence and dispatch -classification. `GET /channels/:id/messages` excludes a fenced message until either the -message owns the reply lane or its durable `queue_followup` exists. A refresh can therefore -never expose a queued source message during the persistence gap and then retract it. +The daemon also fences the source Item between Turn persistence and dispatch +classification. `GET /channels/:id/items` excludes a fenced Item until either the Turn +owns the reply lane or its durable `queue_followup` exists. A refresh can therefore +never expose a queued source Item during the persistence gap and then retract it. A `queue_followup` created from an already-persisted channel user message retains both `sourceMessageId` and `targetTeammateId` for audit and execution. Until its intervention has diff --git a/docs/design/implementation-status.md b/docs/design/implementation-status.md index df42868aa..84eb4b161 100644 --- a/docs/design/implementation-status.md +++ b/docs/design/implementation-status.md @@ -28,7 +28,7 @@ counts, screenshots, hashes and run diaries belong in CI or Release artifacts. | Work | Current boundary | Closure condition | |---|---|---| -| [Channel Conversation Continuity v2](channel-conversation-continuity-v2-implementation-plan.md) | G0, S0, and S1+S2 merged; S3-S6 implemented and locally verified | S7, final migration, recovery, performance, security, eval, and Web E2E gates pass | +| [Channel Conversation Continuity v2](channel-conversation-continuity-v2-implementation-plan.md) | S0-S7 implemented; final local gates pass with 36 Contracts, 362 Core, 905 Daemon, 49 API Client, 40 Remote Protocol, 45 Web unit, and 128 Channel E2E tests | Merge PR-G after remote CI, verify `main`, then close issue #421 | | [Channel Context Processing](channel-context-processing-layer-implementation-plan.md) | Channel and direct-message context views exist | Condensation, quote eligibility, persistence and redaction ownership are closed | | [Permission Presets](codex-inspired-settings-permission-presets-implementation-plan.md) | Enforcement modes exist | Preset UI, effective preview and run audit agree with daemon enforcement | | [Conversation Surface](conversation-surface-contract-implementation-plan.md) | Shared primitives and initial package extraction are implemented | WorkCell, evidence, attachment, package CSS and Desktop-consumption boundaries close | diff --git a/docs/design/ling-agent-session.md b/docs/design/ling-agent-session.md index 9af3cf998..e24273ef6 100644 --- a/docs/design/ling-agent-session.md +++ b/docs/design/ling-agent-session.md @@ -248,45 +248,48 @@ deploy authority, external-send authority, or memory authority. signals. They may influence `responderHint`, participant selection, or whether an ordinary `reply` is expected. They must not short-circuit executable intent handling. -### Current v1 model-assisted implementation reference +### Continuity v2 model-assisted intent contract -The section below records the currently implemented v1 action names and migration logic. -It is superseded for new work by Channel Conversation Continuity v2. The clean-break v2 -implementation uses `reply` and `execute`, one context projection version, structured -clarification fields, and post-intent TargetResolver gating. +Model-assisted intent classification is part of the Ling Agent Session turn loop, not a +separate auto-reply feature. Every accepted input already owns a committed +`LingTurn`/user `ConversationItem`, an effective ContextSegment assignment, and one shared +projection version before classification begins. -The implementation must treat model-assisted intent classification as part of the Ling -Agent Session turn loop, not as a separate optional auto-reply feature. The model may -classify a turn as `single_reply` (the ordinary `answer` outcome), `clarify`, -`workflow_proposal`, `direct_workflow`, `visible_multi_reply`, or `no_reply`, but that -classification is only intent evidence. It is never execution permission. +The classifier may return `reply`, `clarify`, `workflow_proposal`, `execute`, +`visible_multi_reply`, or `no_reply`. The result is intent evidence only. It never grants +execution permission. -The migration target is: +The shipped order is: ```text -build LingAgentTurnEnvelope +commit LingTurn + user ConversationItem +-> assign effective ContextSegment +-> build one permission-filtered ConversationContextProjection +-> build LingAgentTurnEnvelope from that projection -> active-session intake -> deterministic high-confidence validators -> model-assisted intent classification when deterministic validators do not settle it --> target/action validator +-> TargetResolver only for execute/proposal candidates -> policy gate -> one product outcome ``` The validator/policy split is mandatory: -- model says `direct_workflow` and TargetResolver finds one safe target -> create +- model says `execute` and TargetResolver finds one safe target -> create bounded proposal evidence, then let Policy Gate decide whether to auto-start; -- model says `direct_workflow` but target/action/content/objective is ambiguous -> ask - one clarification question; -- model says `direct_workflow` for a file create and TargetResolver only found a file +- model says `execute` but target/action/content/objective is ambiguous -> emit + `clarify` with typed `missingFields` and one actionable question; +- model says `execute` for a file create and TargetResolver only found a file name -> require one executable input source before run creation: literal requested content, materialized attachment text, a same-thread code block, the current objective, or a recent same-session objective. A bare target such as `index.html` must not become an empty placeholder run; - model says `workflow_proposal` -> create proposal evidence only after participant and required-tool validation; -- model says `single_reply` or `no_reply` -> ordinary reply routing may run, but it +- model says `reply` -> ordinary reply routing may run without calling TargetResolver; +- model says `no_reply` -> suppress only system/noise cases, never accepted user content; +- model says `reply` or `no_reply` -> ordinary reply routing may run, but it remains the final outcome after executable paths declined; - model output never grants tool access, bypasses runtime approval, expands workspace scope, selects secrets, or performs external/account mutations. @@ -298,10 +301,11 @@ be considered executable. Follow-up turns such as "can you write it locally now? eligible for model classification and target validation against the same-thread context before ordinary answer mode can respond. -`ChannelMessage.threadId` can narrow recent target lookup and auto-start risk policy, but -it must not block model-assisted `direct_workflow`, `clarify`, or approval handling. A -thread reply that asks Ling to execute against a target established in that thread should -produce a bounded proposal or clarification, not an ordinary "I cannot write files" +`ChannelMessage.threadId` is a presentation projection of the canonical `LingThread.id`; +it is never an Item anchor or a target-resolution fallback. Topic materialization uses an +explicit `anchorItemId`, then later reads and sends use the returned Thread id. A Turn that +asks Ling to execute against a target established in its projected context should produce +a bounded proposal or structured clarification, not an ordinary "I cannot write files" answer. ### Policy Gate @@ -892,10 +896,11 @@ policy error. Internal outcomes: -- `single_reply`; +- `reply`; - `visible_multi_reply`; - `workflow_proposal`; -- `direct_workflow`; +- `execute`; +- `clarify` with typed `missingFields`; - `SelectorDecision`; - `ChannelMessageRouteDecision`; - `ExecutionAction`; diff --git a/packages/api-client/src/endpoints/channels.ts b/packages/api-client/src/endpoints/channels.ts index 18382df4d..4e3126379 100644 --- a/packages/api-client/src/endpoints/channels.ts +++ b/packages/api-client/src/endpoints/channels.ts @@ -24,8 +24,10 @@ import type { ChannelLinkPreviewListResponse, ChannelLinkPreviewMutationResponse, ChannelListResponse, - ChannelMessageListResponse, - ChannelMessageMutationResponse, + ChannelConversationContextInspectResponse, + ChannelConversationItemPageResponse, + ChannelConversationSurface, + ChannelConversationTurnResponse, ChannelPlaybookCandidateListResponse, ChannelPlaybookCandidateMutationResponse, ChannelPlaybookCandidateReviewRequest, @@ -77,7 +79,7 @@ import type { CreateChannelExecutionProposalRequest, CreateChannelHandoffRequest, CreateChannelLinkPreviewRequest, - CreateChannelMessageRequest, + CreateChannelTurnRequest, CreateChannelRequest, CreateChannelTeamPatternRequest, CreateChannelTeamEvaluationRunRequest, @@ -86,6 +88,9 @@ import type { CreateChannelWorkflowTemplateCompileRequest, CreateChannelWorkflowRequest, CreateChannelWorkflowStepRequest, + CorrectChannelTurnContextRequest, + CorrectChannelTurnContextResponse, + ListChannelConversationItemsOptions, PreviewContentResponse, UpdateChannelBriefRequest, UpdateChannelBranchRequest, @@ -103,6 +108,18 @@ import { LingDaemonError } from "../errors.js"; import { readErrorMessage } from "../transport.js"; import type { LingDaemonEndpointContext, PublicEndpointGroup } from "../transport.js"; +function channelConversationSurfaceQuery(surface: ChannelConversationSurface | undefined): { + readonly threadId?: string; + readonly anchorItemId?: string; +} { + if (!surface || surface.kind === "channel_root") { + return {}; + } + return surface.kind === "thread" + ? { threadId: surface.threadId } + : { anchorItemId: surface.anchorItemId }; +} + export const channelEndpoints = { async listChannels(this: LingDaemonEndpointContext, cwd: string, options: { readonly archived?: boolean; readonly limit?: number } = {}): Promise { return this._get("/channels", { cwd, archived: options.archived, limit: options.limit }); @@ -293,33 +310,34 @@ export const channelEndpoints = { return this._post(`/channels/${encodeURIComponent(channelId)}/workspace/cleanup`, { cwd }); }, - async listChannelMessages(this: LingDaemonEndpointContext, + async listChannelConversationItems(this: LingDaemonEndpointContext, cwd: string, channelId: string, - options: { readonly limit?: number; readonly beforeMessageId?: string; readonly threadId?: string } = {}, - ): Promise { - return this._get(`/channels/${encodeURIComponent(channelId)}/messages`, { + options: ListChannelConversationItemsOptions = {}, + ): Promise { + const surfaceQuery = channelConversationSurfaceQuery(options.surface); + return this._get(`/channels/${encodeURIComponent(channelId)}/items`, { cwd, limit: options.limit, - beforeMessageId: options.beforeMessageId, - threadId: options.threadId, + beforeSequence: options.beforeSequence, + ...surfaceQuery, }); }, - async createChannelMessage(this: LingDaemonEndpointContext, + async createChannelTurn(this: LingDaemonEndpointContext, cwd: string, channelId: string, - input: CreateChannelMessageRequest, - ): Promise { - return this._post(`/channels/${encodeURIComponent(channelId)}/messages`, { cwd }, input); + input: CreateChannelTurnRequest, + ): Promise { + return this._post(`/channels/${encodeURIComponent(channelId)}/turns`, { cwd }, input); }, - async streamChannelMessage(this: LingDaemonEndpointContext, + async streamChannelTurn(this: LingDaemonEndpointContext, cwd: string, channelId: string, - input: CreateChannelMessageRequest, + input: CreateChannelTurnRequest, ): Promise { - const response = await this._fetch(this._url(`/channels/${encodeURIComponent(channelId)}/messages/stream`, { cwd }), { + const response = await this._fetch(this._url(`/channels/${encodeURIComponent(channelId)}/turns/stream`, { cwd }), { method: "POST", headers: { Accept: "text/event-stream", @@ -333,6 +351,23 @@ export const channelEndpoints = { return response; }, + async getChannelTurnContext(this: LingDaemonEndpointContext, + cwd: string, + channelId: string, + turnId: string, + ): Promise { + return this._get(`/channels/${encodeURIComponent(channelId)}/turns/${encodeURIComponent(turnId)}/context`, { cwd }); + }, + + async correctChannelTurnContext(this: LingDaemonEndpointContext, + cwd: string, + channelId: string, + turnId: string, + input: CorrectChannelTurnContextRequest, + ): Promise { + return this._post(`/channels/${encodeURIComponent(channelId)}/turns/${encodeURIComponent(turnId)}/context/corrections`, { cwd }, input); + }, + async listChannelMessageRouteDecisions(this: LingDaemonEndpointContext, cwd: string, channelId: string, diff --git a/packages/api-client/test/client.test.mjs b/packages/api-client/test/client.test.mjs index 62571b3c4..5b05174e7 100644 --- a/packages/api-client/test/client.test.mjs +++ b/packages/api-client/test/client.test.mjs @@ -133,7 +133,11 @@ test("builds URL query parameters for list endpoints", async () => { const client = new LingDaemonClient({ baseUrl: "http://daemon.test/", fetch: mock.fetch }); await client.listRuns("/repo path", 25); - await client.listChannelMessages("/repo", "channel 1", { limit: 15 }); + await client.listChannelConversationItems("/repo", "channel 1", { + limit: 15, + beforeSequence: 40, + surface: { kind: "thread", threadId: "thread 1" }, + }); await client.listChannelAttachments("/repo", "channel 1", 16); await client.listChannelLinkPreviews("/repo", "channel 1", 17); await client.getChannelBrief("/repo", "channel 1"); @@ -223,7 +227,7 @@ test("builds URL query parameters for list endpoints", async () => { await client.runWorkspaceTerminalCommand("/repo path", { command: "pwd" }); assert.equal(mock.calls[0].url, "http://daemon.test/runs?cwd=%2Frepo+path&limit=25"); - assert.equal(mock.calls[1].url, "http://daemon.test/channels/channel%201/messages?cwd=%2Frepo&limit=15"); + assert.equal(mock.calls[1].url, "http://daemon.test/channels/channel%201/items?cwd=%2Frepo&limit=15&beforeSequence=40&threadId=thread+1"); assert.equal(mock.calls[2].url, "http://daemon.test/channels/channel%201/attachments?cwd=%2Frepo&limit=16"); assert.equal(mock.calls[3].url, "http://daemon.test/channels/channel%201/link-previews?cwd=%2Frepo&limit=17"); assert.equal(mock.calls[4].url, "http://daemon.test/channels/channel%201/brief?cwd=%2Frepo"); @@ -1045,7 +1049,7 @@ test("sends mutation bodies for channel, task, provider, policy, media, and appr }, }); await client.updateChannel("/repo", "channel 1", { defaultTeammateId: "designer" }); - await client.createChannelMessage("/repo", "channel 1", { + await client.createChannelTurn("/repo", "channel 1", { body: "hello @Engineer", mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], requestAiReply: true, @@ -1142,7 +1146,7 @@ test("sends mutation bodies for channel, task, provider, policy, media, and appr assert.equal(mock.calls[1].url, "http://daemon.test/channels/channel%201?cwd=%2Frepo"); assert.deepEqual(JSON.parse(mock.calls[1].body), { defaultTeammateId: "designer" }); assert.equal(mock.calls[2].method, "POST"); - assert.equal(mock.calls[2].url, "http://daemon.test/channels/channel%201/messages?cwd=%2Frepo"); + assert.equal(mock.calls[2].url, "http://daemon.test/channels/channel%201/turns?cwd=%2Frepo"); assert.deepEqual(JSON.parse(mock.calls[2].body), { body: "hello @Engineer", mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], @@ -1216,24 +1220,64 @@ test("uses expected routes for delete, cancel, check, and artifact URLs", async assert.equal(client.attachmentContentUrl("attachment 1", "/repo"), "http://daemon.test/attachments/attachment%201/content?cwd=%2Frepo"); }); -test("streams channel messages through the channel stream route", async () => { +test("creates, streams, inspects, and corrects channel turns through item APIs", async () => { const mock = createFetchMock({ ok: true }); const client = new LingDaemonClient({ baseUrl: "http://daemon.test", fetch: mock.fetch }); - await client.streamChannelMessage("/repo", "channel 1", { + await client.createChannelTurn("/repo", "channel 1", { + body: "first turn", + requestAiReply: false, + }); + await client.streamChannelTurn("/repo", "channel 1", { body: "hello @Engineer", mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], requestAiReply: true, }); + await client.getChannelTurnContext("/repo", "channel 1", "turn 1"); + await client.correctChannelTurnContext("/repo", "channel 1", "turn 1", { + segmentId: "segment 2", + expectedAssignmentRevision: 3, + }); assert.equal(mock.calls[0].method, "POST"); - assert.equal(mock.calls[0].url, "http://daemon.test/channels/channel%201/messages/stream?cwd=%2Frepo"); - assert.deepEqual(JSON.parse(mock.calls[0].body), { + assert.equal(mock.calls[0].url, "http://daemon.test/channels/channel%201/turns?cwd=%2Frepo"); + assert.deepEqual(JSON.parse(mock.calls[0].body), { body: "first turn", requestAiReply: false }); + assert.equal(mock.calls[1].method, "POST"); + assert.equal(mock.calls[1].url, "http://daemon.test/channels/channel%201/turns/stream?cwd=%2Frepo"); + assert.deepEqual(JSON.parse(mock.calls[1].body), { body: "hello @Engineer", mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], requestAiReply: true, }); - assert.equal(mock.calls[0].headers.Accept, "text/event-stream"); + assert.equal(mock.calls[1].headers.Accept, "text/event-stream"); + assert.equal(mock.calls[2].url, "http://daemon.test/channels/channel%201/turns/turn%201/context?cwd=%2Frepo"); + assert.equal(mock.calls[3].url, "http://daemon.test/channels/channel%201/turns/turn%201/context/corrections?cwd=%2Frepo"); + assert.deepEqual(JSON.parse(mock.calls[3].body), { segmentId: "segment 2", expectedAssignmentRevision: 3 }); +}); + +test("serializes canonical Thread ids and Topic anchor item ids separately", async () => { + const mock = createFetchMock({ ok: true }); + const client = new LingDaemonClient({ baseUrl: "http://daemon.test", fetch: mock.fetch }); + + await client.listChannelConversationItems("/repo", "channel 1"); + await client.listChannelConversationItems("/repo", "channel 1", { + surface: { kind: "thread", threadId: "thread 1" }, + }); + await client.listChannelConversationItems("/repo", "channel 1", { + surface: { kind: "topic", anchorItemId: "item 1" }, + }); + await client.createChannelTurn("/repo", "channel 1", { + surface: { kind: "topic", anchorItemId: "item 1" }, + body: "start topic", + }); + + assert.equal(mock.calls[0].url, "http://daemon.test/channels/channel%201/items?cwd=%2Frepo"); + assert.equal(mock.calls[1].url, "http://daemon.test/channels/channel%201/items?cwd=%2Frepo&threadId=thread+1"); + assert.equal(mock.calls[2].url, "http://daemon.test/channels/channel%201/items?cwd=%2Frepo&anchorItemId=item+1"); + assert.deepEqual(JSON.parse(mock.calls[3].body), { + surface: { kind: "topic", anchorItemId: "item 1" }, + body: "start topic", + }); }); test("uses expected managed plugin routes", async () => { diff --git a/packages/core/src/conversation-item-channel-view.ts b/packages/contracts/src/channel-conversation-projection.ts similarity index 87% rename from packages/core/src/conversation-item-channel-view.ts rename to packages/contracts/src/channel-conversation-projection.ts index 10bcd361a..6c2ba7551 100644 --- a/packages/core/src/conversation-item-channel-view.ts +++ b/packages/contracts/src/channel-conversation-projection.ts @@ -1,13 +1,18 @@ import type { ConversationItemV1, ConversationMessageMentionV1, -} from "@ling-stack/contracts"; +} from "./conversation-continuity.js"; import type { ChannelMention, ChannelMessage, ChannelMessageFileRef, -} from "./workspace-types.js"; +} from "./channels.js"; +/** + * Projects a canonical conversation item into the message-shaped presentation view used + * by the current Channel UI. The item remains the authority; this helper is pure and + * browser-safe so daemon, API consumers, and Web cannot drift on projection semantics. + */ export function projectConversationItemToChannelMessage( item: ConversationItemV1, options: { readonly linkedRunId?: string } = {}, @@ -15,13 +20,12 @@ export function projectConversationItemToChannelMessage( if (!item.channelId) { throw new Error(`Conversation item is not attached to a channel: ${item.id}`); } - const payload = item.payload; - const status = projectStatus(item); return { id: item.id, workspaceId: item.workspaceId, channelId: item.channelId, threadId: item.threadId, + ...(item.turnId ? { turnId: item.turnId } : {}), kind: item.kind === "user_message" ? "message" : item.kind === "assistant_message" ? "ai_reply" : "system", intent: item.domainRefs.some((ref) => ref.type === "task") ? "task" : "discuss", authorType: item.actorType === "local_user" @@ -35,9 +39,12 @@ export function projectConversationItemToChannelMessage( attachmentIds: item.kind === "user_message" ? item.payload.attachmentIds : [], linkPreviewIds: item.kind === "user_message" ? item.payload.linkPreviewIds : [], fileRefs: item.kind === "user_message" ? item.payload.fileRefs.map(projectFileRef) : [], + ...(item.kind === "user_message" && item.payload.replyToItemId + ? { replyToItemId: item.payload.replyToItemId } + : {}), linkedTaskId: item.domainRefs.find((ref) => ref.type === "task")?.id, linkedRunId: options.linkedRunId ?? item.domainRefs.find((ref) => ref.type === "run")?.id, - status, + status: projectStatus(item), errorMessage: item.kind === "assistant_message" ? item.payload.errorMessage : undefined, revision: item.revision, createdAt: item.createdAt, diff --git a/packages/contracts/src/channel-conversations.ts b/packages/contracts/src/channel-conversations.ts new file mode 100644 index 000000000..260ccd4d6 --- /dev/null +++ b/packages/contracts/src/channel-conversations.ts @@ -0,0 +1,181 @@ +import type { + ContextSegment, + ConversationContextProjectionMetadata, + ConversationContextSummary, + ConversationDomainRef, + ConversationItemV1, + LingThreadContinuityV2, + TurnContextAssignment, +} from "./conversation-continuity.js"; +import type { + ChannelExecutionProposal, + ChannelMessageFileRef, + ChannelMention, + ChannelReplyDispatch, + MessageWebEvidence, +} from "./channels.js"; +import type { ConversationPendingReplacement } from "./shared.js"; +import type { ConversationActiveReplyTarget } from "./runtime.js"; +import type { LingThread } from "./threads.js"; +import type { LingTurn } from "./turns.js"; +import type { ChannelWorkflow, ChannelWorkflowOrchestrationResponse, ChannelWorkflowStep } from "./workflows.js"; + +export type ChannelConversationThread = LingThread & LingThreadContinuityV2 & { + readonly nextItemSequence: number; +}; + +/** + * Identifies the conversation surface without overloading canonical Thread ids + * with ConversationItem anchor ids. + */ +export type ChannelConversationSurface = + | { readonly kind: "channel_root" } + | { readonly kind: "thread"; readonly threadId: string } + | { readonly kind: "topic"; readonly anchorItemId: string }; + +export interface ListChannelConversationItemsOptions { + readonly limit?: number; + readonly beforeSequence?: number; + /** Omitted and `channel_root` both select the Channel Root conversation. */ + readonly surface?: ChannelConversationSurface; +} + +/** + * Non-authoritative display metadata that cannot be reconstructed from an item alone. + * The item remains the timeline authority; clients may discard and reload decorations. + */ +export interface ChannelConversationItemDecoration { + readonly itemId: string; + readonly linkedRunId?: string; + readonly editedAt?: string; + readonly pendingReplacement?: ConversationPendingReplacement; + readonly webEvidence?: MessageWebEvidence; +} + +export interface ChannelConversationItemPageResponse { + readonly cwd: string; + readonly channelId: string; + readonly thread: ChannelConversationThread; + readonly turns: readonly LingTurn[]; + readonly items: readonly ConversationItemV1[]; + readonly decorations: readonly ChannelConversationItemDecoration[]; + readonly hasMore: boolean; + /** Stable per-thread sequence cursor. The next page contains only lower sequences. */ + readonly nextBeforeSequence?: number; +} + +export interface CreateChannelTurnRequest { + readonly idempotencyKey?: string; + /** Omitted and `channel_root` both append to the Channel Root conversation. */ + readonly surface?: ChannelConversationSurface; + readonly contextSegmentId?: string; + readonly body: string; + readonly mentions?: readonly ChannelMention[]; + readonly attachmentIds?: readonly string[]; + readonly linkPreviewIds?: readonly string[]; + readonly fileRefs?: readonly ChannelMessageFileRef[]; + readonly requestAiReply?: boolean; + readonly replyDisposition?: { + readonly mode: "queue_followup"; + readonly activeRunId?: string; + }; + readonly providerPresetId?: string; + readonly teammateId?: string; +} + +export interface ChannelConversationTurnResponse { + readonly thread: ChannelConversationThread; + readonly turn: LingTurn; + readonly userItem: ConversationItemV1; + readonly assistantItems?: readonly ConversationItemV1[]; + readonly decorations: readonly ChannelConversationItemDecoration[]; + readonly replyDispatch?: ChannelReplyDispatch; + readonly autoStartedWorkflow?: { + readonly proposal: ChannelExecutionProposal; + readonly workflow: ChannelWorkflow; + readonly workflowSteps: readonly ChannelWorkflowStep[]; + readonly orchestration?: ChannelWorkflowOrchestrationResponse; + }; +} + +export interface ChannelConversationStreamReadyEvent { + readonly thread: ChannelConversationThread; + readonly turn: LingTurn; + readonly userItem: ConversationItemV1; + readonly assistantItems?: readonly ConversationItemV1[]; + readonly decorations: readonly ChannelConversationItemDecoration[]; +} + +export interface ChannelConversationStreamReplyPendingEvent { + readonly sourceItemId: string; + readonly targetTeammateId: string; + readonly startedAt: string; +} + +export interface ChannelConversationStreamDeltaEvent { + readonly itemId: string; + readonly delta: string; + readonly body: string; +} + +export interface ChannelConversationStreamRunLinkedEvent { + readonly itemId: string; + readonly linkedRunId: string; + readonly activeReply?: ConversationActiveReplyTarget; +} + +export interface ChannelConversationStreamDoneEvent extends ChannelConversationTurnResponse {} + +export interface ChannelConversationStreamBlockedEvent extends ChannelConversationTurnResponse { + readonly error?: string; +} + +export interface ChannelConversationStreamErrorEvent { + readonly thread?: ChannelConversationThread; + readonly turn?: LingTurn; + readonly userItem?: ConversationItemV1; + readonly assistantItems?: readonly ConversationItemV1[]; + readonly decorations?: readonly ChannelConversationItemDecoration[]; + readonly replyDispatch?: ChannelReplyDispatch; + readonly error: string; +} + +export interface ChannelConversationContextInspectResponse { + readonly cwd: string; + readonly channelId: string; + readonly thread: ChannelConversationThread; + readonly turn: LingTurn; + readonly sourceItemId: string; + readonly segment: ContextSegment; + readonly segments: readonly ContextSegment[]; + readonly assignment: TurnContextAssignment; + readonly projection: ConversationContextProjectionMetadata; + readonly summaries: readonly ConversationContextSummary[]; + readonly sourceRefs: readonly ConversationDomainRef[]; + readonly droppedItemCount: number; + readonly redactedItemCount: number; + readonly safety: { + readonly rawPromptTextIncluded: false; + readonly hiddenReasoningIncluded: false; + readonly privateMemoryBodiesIncluded: false; + readonly secretValuesIncluded: false; + }; +} + +export interface CorrectChannelTurnContextRequest { + readonly segmentId: string; + readonly expectedAssignmentRevision: number; +} + +export interface CorrectChannelTurnContextResponse { + readonly cwd: string; + readonly channelId: string; + readonly thread: ChannelConversationThread; + readonly turn: LingTurn; + readonly segment: ContextSegment; + readonly assignment: TurnContextAssignment; + readonly previousAssignment: TurnContextAssignment; + readonly invalidatedSummaryIds: readonly string[]; + readonly projection: ConversationContextProjectionMetadata; + readonly idempotentReplay: boolean; +} diff --git a/packages/contracts/src/channels.ts b/packages/contracts/src/channels.ts index 82de261e0..d1b92edf3 100644 --- a/packages/contracts/src/channels.ts +++ b/packages/contracts/src/channels.ts @@ -1,11 +1,13 @@ import type { ConversationPendingReplacement, RunTool, ToolName } from "./shared.js"; -import type { ConversationActiveReplyTarget, RunIntervention } from "./runtime.js"; +import type { RunIntervention } from "./runtime.js"; import type { ChannelIntentDecisionV2, ChannelIntentDecisionV2Action, ChannelIntentDecisionV2CreatedBy, ChannelIntentDecisionV2Status, ConversationContextProjectionMetadata, + LingThreadRelationKindV2, + LingThreadSurfaceKindV2, } from "./conversation-continuity.js"; import type { ResponseLanguage, ResponseLanguagePreference } from "./settings.js"; import type { @@ -14,7 +16,6 @@ import type { ProcedureObservationValidationStep, SkillCandidateSourceRef, } from "./skills.js"; -import type { ChannelWorkflow, ChannelWorkflowOrchestrationResponse, ChannelWorkflowStep } from "./workflows.js"; export type ChannelStatus = "active" | "paused" | "archived"; @@ -501,7 +502,13 @@ export interface ChannelMessage { readonly id: string; readonly workspaceId: string; readonly channelId: string; + /** Canonical LingThread identity. Never overloaded with a parent item id. */ readonly threadId?: string; + readonly turnId?: string; + readonly threadSurfaceKind?: LingThreadSurfaceKindV2; + readonly threadRelationKind?: LingThreadRelationKindV2; + readonly threadAnchorItemId?: string; + readonly replyToItemId?: string; readonly kind: ChannelMessageKind; readonly intent: ChannelMessageIntent; readonly authorType: ChannelMessageAuthorType; @@ -523,53 +530,11 @@ export interface ChannelMessage { readonly updatedAt: string; } -export interface CreateChannelMessageRequest { - readonly idempotencyKey?: string; - readonly threadId?: string; - readonly contextSegmentId?: string; - readonly kind?: ChannelMessageKind; - readonly intent?: ChannelMessageIntent; - readonly authorType?: ChannelMessageAuthorType; - readonly authorId?: string; - readonly body: string; - readonly mentions?: readonly ChannelMention[]; - readonly attachmentIds?: readonly string[]; - readonly linkPreviewIds?: readonly string[]; - readonly fileRefs?: readonly ChannelMessageFileRef[]; - readonly linkedTaskId?: string; - readonly linkedRunId?: string; - readonly requestAiReply?: boolean; - readonly replyDisposition?: ChannelReplyDisposition; - readonly providerPresetId?: string; - readonly teammateId?: string; -} - export interface ChannelReplyDisposition { readonly mode: "queue_followup"; readonly activeRunId?: string; } -export interface ChannelMessageListResponse { - readonly cwd: string; - readonly channelId: string; - readonly messages: readonly ChannelMessage[]; - readonly hasMore?: boolean; - readonly nextBeforeMessageId?: string; -} - -export interface ChannelMessageMutationResponse { - readonly message: ChannelMessage; - readonly aiReply?: ChannelMessage; - readonly aiReplies?: readonly ChannelMessage[]; - readonly replyDispatch?: ChannelReplyDispatch; - readonly autoStartedWorkflow?: { - readonly proposal: ChannelExecutionProposal; - readonly workflow: ChannelWorkflow; - readonly workflowSteps: readonly ChannelWorkflowStep[]; - readonly orchestration?: ChannelWorkflowOrchestrationResponse; - }; -} - export interface ChannelReplyDispatch { readonly targetTeammateId: string; readonly disposition: "reply_requested" | "followup_queued"; @@ -577,54 +542,12 @@ export interface ChannelReplyDispatch { readonly intervention?: RunIntervention; } -export interface ChannelMessageStreamReadyEvent { - readonly message: ChannelMessage; - readonly aiReply?: ChannelMessage; - readonly aiReplies?: readonly ChannelMessage[]; -} - -export interface ChannelMessageStreamReplyPendingEvent { +export interface ChannelReplyPendingEvent { readonly sourceMessageId: string; readonly targetTeammateId: string; readonly startedAt: string; } -export interface ChannelMessageStreamDeltaEvent { - readonly messageId: string; - readonly delta: string; - readonly body: string; -} - -export interface ChannelMessageStreamRunLinkedEvent { - readonly messageId: string; - readonly linkedRunId: string; - readonly activeReply?: ConversationActiveReplyTarget; -} - -export interface ChannelMessageStreamDoneEvent { - readonly message: ChannelMessage; - readonly aiReply?: ChannelMessage; - readonly aiReplies?: readonly ChannelMessage[]; - readonly replyDispatch?: ChannelReplyDispatch; - readonly autoStartedWorkflow?: ChannelMessageMutationResponse["autoStartedWorkflow"]; -} - -export interface ChannelMessageStreamBlockedEvent { - readonly message: ChannelMessage; - readonly aiReply?: ChannelMessage; - readonly aiReplies?: readonly ChannelMessage[]; - readonly replyDispatch?: ChannelReplyDispatch; - readonly error?: string; -} - -export interface ChannelMessageStreamErrorEvent { - readonly message?: ChannelMessage; - readonly aiReply?: ChannelMessage; - readonly aiReplies?: readonly ChannelMessage[]; - readonly replyDispatch?: ChannelReplyDispatch; - readonly error: string; -} - export type ChannelMessageRouteAction = "reply" | "no_reply"; export type ChannelMessageRouteDecisionStatus = "accepted" | "suppressed" | "failed"; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 19e0dba68..d54b22be2 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -8,12 +8,14 @@ export * from "./threads.js"; export type * from "./conversations.js"; export * from "./conversation-continuity.js"; export * from "./conversation-continuity-validation.js"; +export * from "./channel-conversation-projection.js"; export type * from "./traces.js"; export type * from "./turns.js"; export type * from "./automations.js"; export type * from "./execution-actions.js"; export type * from "./tasks.js"; export type * from "./channels.js"; +export type * from "./channel-conversations.js"; export type * from "./channel-team.js"; export type * from "./workflows.js"; export type * from "./external-work.js"; diff --git a/packages/contracts/test/channel-conversation-projection.test.mjs b/packages/contracts/test/channel-conversation-projection.test.mjs new file mode 100644 index 000000000..32c76b9f5 --- /dev/null +++ b/packages/contracts/test/channel-conversation-projection.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { projectConversationItemToChannelMessage } from "../dist/index.js"; + +test("canonical conversation items project to browser-safe Channel presentation views", () => { + const user = projectConversationItemToChannelMessage({ + schemaVersion: 1, + id: "item_user", + workspaceId: "workspace_default", + channelId: "channel_1", + threadId: "thread_1", + turnId: "turn_1", + sequence: 1, + kind: "user_message", + status: "committed", + actorType: "local_user", + actorId: "local-user", + visibility: "channel", + payload: { + body: "Ask Engineer", + format: "plain_text", + mentions: [{ id: "engineer", label: "Engineer", kind: "ai_teammate" }], + attachmentIds: ["attachment_1"], + linkPreviewIds: [], + fileRefs: [{ path: "README.md", source: "composer" }], + }, + domainRefs: [{ type: "task", id: "task_1" }], + revision: 1, + createdAt: "2026-08-10T00:00:00.000Z", + updatedAt: "2026-08-10T00:00:00.000Z", + committedAt: "2026-08-10T00:00:00.000Z", + }); + assert.equal(user.kind, "message"); + assert.equal(user.authorType, "local-user"); + assert.equal(user.intent, "task"); + assert.deepEqual(user.mentions, [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }]); + assert.deepEqual(user.fileRefs, [{ path: "README.md", source: "composer" }]); + + const assistant = projectConversationItemToChannelMessage({ + schemaVersion: 1, + id: "item_reply", + workspaceId: "workspace_default", + channelId: "channel_1", + threadId: "thread_1", + turnId: "turn_1", + sequence: 2, + kind: "assistant_message", + status: "committed", + actorType: "ling_agent", + actorId: "ling_agent", + visibility: "channel", + payload: { + body: "Need a target.", + format: "markdown", + streaming: false, + deliveryStatus: "blocked", + errorMessage: "target missing", + }, + domainRefs: [{ type: "run", id: "run_1" }], + revision: 2, + createdAt: "2026-08-10T00:00:01.000Z", + updatedAt: "2026-08-10T00:00:02.000Z", + committedAt: "2026-08-10T00:00:02.000Z", + }); + assert.equal(assistant.kind, "ai_reply"); + assert.equal(assistant.authorType, "ai-teammate"); + assert.equal(assistant.status, "blocked"); + assert.equal(assistant.linkedRunId, "run_1"); + assert.equal(assistant.errorMessage, "target missing"); +}); diff --git a/packages/core/src/conversation-context-store-types.ts b/packages/core/src/conversation-context-store-types.ts index 706c6bb7a..840680c3d 100644 --- a/packages/core/src/conversation-context-store-types.ts +++ b/packages/core/src/conversation-context-store-types.ts @@ -30,6 +30,8 @@ export type TurnContextAssignmentTarget = export interface ApplyTurnContextAssignmentInput { readonly turnId: string; + /** Optional compare-and-swap fence for explicit user corrections. */ + readonly expectedPreviousRevision?: number; readonly target: TurnContextAssignmentTarget; readonly status: TurnContextAssignmentStatus; readonly decisionSource: TurnContextDecisionSource; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 98035b2ea..561fa0b39 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -83,7 +83,10 @@ export * from "./conversation-item-store-types.js"; export * from "./conversation-turn-ingest-types.js"; export * from "./conversation-thread-topology-types.js"; export * from "./conversation-context-store-types.js"; -export * from "./conversation-item-channel-view.js"; +export { + isChannelMessageConversationItem, + projectConversationItemToChannelMessage, +} from "@ling-stack/contracts"; export * from "./remote-access-types.js"; export * from "./effective-skills.js"; export * from "./mcp-types.js"; diff --git a/packages/core/src/run-types.ts b/packages/core/src/run-types.ts index 421b0e7d2..cb7717318 100644 --- a/packages/core/src/run-types.ts +++ b/packages/core/src/run-types.ts @@ -167,7 +167,6 @@ import type { ChannelLinkPreview, ChannelMessage, ChannelMessageRouteDecision, - ChannelMessageListPage, ChannelCouncilPolicy, ChannelUpdateInput, CloneAiCouncilInput, @@ -189,7 +188,6 @@ import type { CreateChannelInput, CreateChannelIntentDecisionInput, CreateChannelLinkPreviewInput, - CreateChannelMessageInput, CreateConversationAttachmentInput, CreateAiCouncilInput, CreateAiTeammateInput, @@ -219,7 +217,6 @@ import type { ListChannelIntentDecisionsOptions, ListChannelsOptions, ListChannelLinkPreviewsOptions, - ListChannelMessagesOptions, ListChannelMessageRouteDecisionsOptions, ListConversationAttachmentsOptions, ListTeammatePrivateAttachmentsOptions, diff --git a/packages/core/src/storage/sqlite-run-store.ts b/packages/core/src/storage/sqlite-run-store.ts index 843249c3b..aa9b604bc 100644 --- a/packages/core/src/storage/sqlite-run-store.ts +++ b/packages/core/src/storage/sqlite-run-store.ts @@ -72,7 +72,6 @@ import { } from "../approval-policy.js"; import { DEFAULT_WORKSPACE_ID, - createChannelMessageId, createChannelAttachmentId, createChannelContextSummaryId, createChannelDecisionId, @@ -104,7 +103,6 @@ import { type ChannelIntentDecision, type ChannelLinkPreview, type ChannelMessage, - type ChannelMessageListPage, type ChannelMessageRouteDecision, type ChannelCouncilPolicy, type ChannelUpdateInput, @@ -128,7 +126,6 @@ import { type CreateChannelInput, type CreateChannelIntentDecisionInput, type CreateChannelLinkPreviewInput, - type CreateChannelMessageInput, type CreateChannelMessageRouteDecisionInput, type CreateChannelWorkflowInput, type CreateChannelWorkflowStepInput, @@ -157,7 +154,6 @@ import { type ListChannelIntentDecisionsOptions, type ListChannelsOptions, type ListChannelLinkPreviewsOptions, - type ListChannelMessagesOptions, type ListChannelMessageRouteDecisionsOptions, type ListChannelWorkflowsOptions, type ListChannelWorkflowStepsOptions, @@ -483,7 +479,6 @@ import { type UpsertGovernanceNotificationResult, } from "../governance-notification-types.js"; import { redactSecrets } from "../redaction.js"; -import { projectConversationItemToChannelMessage } from "../conversation-item-channel-view.js"; import { applySqliteRunStoreMigrations, configureSqliteRunStoreDatabase } from "./sqlite/migrations.js"; import { withSqliteRunStoreMigrationLock } from "./sqlite/migration-coordinator.js"; import { @@ -2575,223 +2570,6 @@ export class SqliteRunStore implements RunStore { return this.conversationContext.listConversationContextSnapshots(options); } - createChannelMessage(input: CreateChannelMessageInput, now: string): ChannelMessage { - if (this.legacyContinuityFixtureMode) return this.createLegacyChannelMessageFixture(input, now); - const channel = this.getChannel(input.channelId); - if (!channel) throw new Error(`Channel not found: ${input.channelId}`); - const itemId = input.id ?? createChannelMessageId(); - const explicitThread = input.threadId ? this.getLingThread(input.threadId) : undefined; - const anchorItem = input.threadId && !explicitThread ? this.getConversationItem(input.threadId) : undefined; - const thread = explicitThread - ?? (anchorItem - ? this.resolveOrCreateConversationThread({ - surfaceKind: "topic", - channelId: input.channelId, - anchorItemId: anchorItem.id, - title: input.body.replace(/\s+/gu, " ").trim().slice(0, 120) || "Topic", - objective: `Topic anchored at conversation item ${anchorItem.id}.`, - }, now).thread - : this.resolveOrCreateConversationThread({ - surfaceKind: "channel_root", - channelId: input.channelId, - title: `${channel.name} conversation`, - objective: `Root conversation for ${channel.name}.`, - }, now).thread); - if (thread.channelId !== input.channelId) { - throw new Error(`Conversation thread does not belong to channel: ${thread.id}`); - } - - if (input.authorType === "local-user" && (input.kind === undefined || input.kind === "message")) { - if (input.status && input.status !== "ready") { - throw new Error(`Local-user conversation item must be committed: ${itemId}`); - } - const result = this.appendConversationUserTurn({ - itemId, - channelId: input.channelId, - threadId: thread.id, - turnKind: thread.nextItemSequence > 1 ? "manual_followup" : "initial_request", - title: input.body.replace(/\s+/gu, " ").trim().slice(0, 160) || "Channel input", - userInstruction: input.body || "Channel input with references.", - ...(input.authorId ? { actorId: input.authorId } : {}), - payload: { - body: input.body, - format: "plain_text", - mentions: (input.mentions ?? []).map((mention) => ({ - id: mention.id, - label: mention.label, - kind: mention.kind === "ai-teammate" - ? "ai_teammate" as const - : mention.kind === "local-user" - ? "local_user" as const - : "channel" as const, - })), - attachmentIds: input.attachmentIds ?? [], - linkPreviewIds: input.linkPreviewIds ?? [], - fileRefs: input.fileRefs ?? [], - ...(anchorItem ? { replyToItemId: anchorItem.id } : {}), - }, - domainRefs: [ - ...(input.linkedTaskId ? [{ type: "task" as const, id: input.linkedTaskId }] : []), - ...(input.linkedRunId ? [{ type: "run" as const, id: input.linkedRunId }] : []), - ], - idempotencyKey: `channel-item:${itemId}`, - }, now); - return projectConversationItemToChannelMessage(result.item, { linkedRunId: input.linkedRunId }); - } - - const latestTurnId = [...this.listConversationItemsPage({ threadId: thread.id, limit: 100 }).items] - .reverse() - .find((item) => item.kind === "user_message" && item.turnId)?.turnId; - const status = input.status ?? "ready"; - const assistantMessage = input.kind === "ai_reply" || (input.kind === undefined && input.authorType === "ai-teammate"); - const item = this.appendConversationItem(assistantMessage ? { - id: itemId, - channelId: input.channelId, - threadId: thread.id, - ...(latestTurnId ? { turnId: latestTurnId } : {}), - kind: "assistant_message", - status: status === "pending" ? "pending" : status === "failed" ? "failed" : "committed", - actorType: "teammate", - ...(input.authorId ? { actorId: input.authorId } : {}), - visibility: "channel", - payload: { - body: input.body, - format: "markdown", - streaming: status === "pending", - deliveryStatus: status, - ...(input.errorMessage ? { errorMessage: input.errorMessage } : {}), - }, - domainRefs: [ - ...(anchorItem ? [{ type: "conversation_item" as const, id: anchorItem.id }] : []), - ...(input.linkedTaskId ? [{ type: "task" as const, id: input.linkedTaskId }] : []), - ...(input.linkedRunId ? [{ type: "run" as const, id: input.linkedRunId }] : []), - ], - idempotencyKey: `channel-item:${itemId}`, - } : { - id: itemId, - channelId: input.channelId, - threadId: thread.id, - ...(latestTurnId ? { turnId: latestTurnId } : {}), - kind: "system_event", - status: status === "failed" ? "failed" : "committed", - actorType: "system", - ...(input.authorId ? { actorId: input.authorId } : {}), - visibility: "channel", - payload: { eventType: input.kind ?? "system", summary: input.body }, - domainRefs: [ - ...(input.linkedTaskId ? [{ type: "task" as const, id: input.linkedTaskId }] : []), - ...(input.linkedRunId ? [{ type: "run" as const, id: input.linkedRunId }] : []), - ], - idempotencyKey: `channel-item:${itemId}`, - }, now); - return projectConversationItemToChannelMessage(item, { linkedRunId: input.linkedRunId }); - } - - updateChannelMessageRuntime( - id: string, - input: { - readonly body?: string; - readonly status?: ChannelMessage["status"]; - readonly errorMessage?: string | null; - readonly linkedRunId?: string | null; - }, - now: string, - ): ChannelMessage | undefined { - if (this.legacyContinuityFixtureMode) { - const existing = this.getLegacyChannelMessageFixture(id); - if (!existing) return undefined; - this.database.prepare(` - UPDATE channel_messages - SET body = ?, status = ?, error_message = ?, linked_run_id = ?, updated_at = ? - WHERE id = ? - `).run( - input.body ?? existing.body, - input.status ?? existing.status, - input.errorMessage === undefined ? existing.errorMessage ?? null : input.errorMessage, - input.linkedRunId === undefined ? existing.linkedRunId ?? null : input.linkedRunId, - now, - id, - ); - return this.getLegacyChannelMessageFixture(id); - } - const item = this.getEffectiveConversationItem(id); - if (!item) return undefined; - if (input.linkedRunId && item.turnId && !this.getLingTurnForRun(input.linkedRunId)) { - this.linkRunToLingTurn({ turnId: item.turnId, runId: input.linkedRunId, now }); - } - if (item.kind !== "assistant_message" || item.status !== "pending") { - if (input.body !== undefined || input.status !== undefined || input.errorMessage !== undefined) { - throw new Error(`Committed conversation item is immutable: ${id}`); - } - return this.getChannelMessage(id); - } - const status = input.status ?? (item.payload.deliveryStatus === "pending" ? "pending" : item.payload.deliveryStatus); - const terminal = status === "ready" || status === "blocked" || status === "failed"; - const updated = this.updatePendingConversationItem(id, { - expectedRevision: item.revision, - payload: { - body: input.body ?? item.payload.body, - format: item.payload.format, - streaming: !terminal, - deliveryStatus: status, - ...(input.errorMessage === null - ? {} - : input.errorMessage !== undefined - ? { errorMessage: input.errorMessage } - : item.payload.errorMessage - ? { errorMessage: item.payload.errorMessage } - : {}), - }, - status: status === "failed" ? "failed" : terminal ? "committed" : "pending", - }, now); - return projectConversationItemToChannelMessage(updated, { linkedRunId: input.linkedRunId ?? undefined }); - } - - getChannelMessage(id: string): ChannelMessage | undefined { - if (this.legacyContinuityFixtureMode) return this.getLegacyChannelMessageFixture(id); - const item = this.getEffectiveConversationItem(id); - if (!item?.channelId) return undefined; - const turn = item.turnId ? this.getLingTurn(item.turnId) : undefined; - return projectConversationItemToChannelMessage(item, { - linkedRunId: turn?.activeRunId ?? turn?.lastRunId, - }); - } - - listChannelMessages(options: ListChannelMessagesOptions): ChannelMessage[] { - return this.listChannelMessagesPage(options).messages.slice(); - } - - listChannelMessagesPage(options: ListChannelMessagesOptions): ChannelMessageListPage { - if (this.legacyContinuityFixtureMode) return this.listLegacyChannelMessagesFixture(options); - let page: import("../conversation-item-store-types.js").ConversationItemChannelListPage; - try { - page = this.listConversationItemsByChannelPage({ - channelId: options.channelId, - ...(options.threadId ? { threadId: options.threadId } : {}), - ...(options.excludeThreadIds ? { excludeThreadIds: options.excludeThreadIds } : {}), - ...(options.beforeMessageId ? { beforeItemId: options.beforeMessageId } : {}), - kinds: ["user_message", "assistant_message", "system_event"], - effectiveOnly: true, - ...(options.limit !== undefined ? { limit: options.limit } : {}), - }); - } catch (error) { - if (options.beforeMessageId && error instanceof Error && error.message.startsWith("Invalid conversation item cursor:")) { - throw new Error(`Invalid channel message cursor: ${options.beforeMessageId}`, { cause: error }); - } - throw error; - } - return { - messages: page.items.map((item) => { - const turn = item.turnId ? this.getLingTurn(item.turnId) : undefined; - return projectConversationItemToChannelMessage(item, { - linkedRunId: turn?.activeRunId ?? turn?.lastRunId, - }); - }), - hasMore: page.hasMore, - ...(page.nextBeforeItemId ? { nextBeforeMessageId: page.nextBeforeItemId } : {}), - }; - } - createChannelMessageRouteDecision(input: CreateChannelMessageRouteDecisionInput, now: string): ChannelMessageRouteDecision { if (this.legacyContinuityFixtureMode) return this.createLegacyRouteDecisionFixture(input, now); return this.channelRouteDecisions.createChannelMessageRouteDecision(input, now); @@ -3911,59 +3689,6 @@ export class SqliteRunStore implements RunStore { }); } - private createLegacyChannelMessageFixture(input: CreateChannelMessageInput, now: string): ChannelMessage { - this.assertChannelExists(input.channelId); - const message: ChannelMessage = { - id: input.id ?? createChannelMessageId(), - workspaceId: DEFAULT_WORKSPACE_ID, - channelId: input.channelId, - ...(input.threadId ? { threadId: input.threadId } : {}), - kind: input.kind ?? "message", - intent: input.intent ?? "discuss", - authorType: input.authorType, - ...(input.authorId ? { authorId: input.authorId } : {}), - body: input.body, - mentions: input.mentions ?? [], - attachmentIds: input.attachmentIds ?? [], - linkPreviewIds: input.linkPreviewIds ?? [], - fileRefs: input.fileRefs ?? [], - ...(input.linkedTaskId ? { linkedTaskId: input.linkedTaskId } : {}), - ...(input.linkedRunId ? { linkedRunId: input.linkedRunId } : {}), - status: input.status ?? "ready", - ...(input.errorMessage ? { errorMessage: input.errorMessage } : {}), - createdAt: now, - updatedAt: now, - }; - this.database.prepare(` - INSERT INTO channel_messages ( - id, workspace_id, channel_id, thread_id, kind, intent, author_type, author_id, - body, mentions_json, attachment_ids_json, link_preview_ids_json, file_refs_json, - linked_task_id, linked_run_id, status, error_message, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - message.id, - message.workspaceId, - message.channelId, - message.threadId ?? null, - message.kind, - message.intent, - message.authorType, - message.authorId ?? null, - message.body, - JSON.stringify(message.mentions), - JSON.stringify(message.attachmentIds), - JSON.stringify(message.linkPreviewIds), - JSON.stringify(message.fileRefs), - message.linkedTaskId ?? null, - message.linkedRunId ?? null, - message.status, - message.errorMessage ?? null, - now, - now, - ); - return message; - } - private createLegacyDecisionFixture(input: CreateChannelDecisionInput, now: string): ChannelDecision { const decision: ChannelDecision = { id: createChannelDecisionId(), workspaceId: DEFAULT_WORKSPACE_ID, channelId: input.channelId, @@ -4164,30 +3889,6 @@ export class SqliteRunStore implements RunStore { }; } - private getLegacyChannelMessageFixture(id: string): ChannelMessage | undefined { - const row = this.database.prepare(`${legacyChannelMessageFixtureSelectSql} WHERE id = ?`).get(id); - return row ? mapLegacyChannelMessageFixture(row as Record) : undefined; - } - - private listLegacyChannelMessagesFixture(options: ListChannelMessagesOptions): ChannelMessageListPage { - const clauses = ["workspace_id = ?", "channel_id = ?"]; - const params: (string | number)[] = [DEFAULT_WORKSPACE_ID, options.channelId]; - if (options.threadId) { - clauses.push("thread_id = ?"); - params.push(options.threadId); - } - const limit = Math.min(Math.max(options.limit ?? 200, 1), 500); - params.push(limit + 1); - const rows = this.database.prepare(` - ${legacyChannelMessageFixtureSelectSql} - WHERE ${clauses.join(" AND ")} - ORDER BY created_at DESC, rowid DESC LIMIT ? - `).all(...params).map((row) => mapLegacyChannelMessageFixture(row as Record)); - const hasMore = rows.length > limit; - const messages = rows.slice(0, limit).reverse(); - return { messages, hasMore, ...(hasMore && messages[0] ? { nextBeforeMessageId: messages[0].id } : {}) }; - } - private assertChannelExists(channelId: string): void { this.channelReferences.assertChannelExists(channelId); } @@ -4320,46 +4021,6 @@ export function createLegacySqliteRunStoreForMigrationTest( return new SqliteRunStore({ cwd, paths, deferContinuityV2MigrationForTest: true }); } -const legacyChannelMessageFixtureSelectSql = ` - SELECT id, workspace_id, channel_id, thread_id, kind, intent, author_type, author_id, - body, mentions_json, attachment_ids_json, link_preview_ids_json, file_refs_json, - linked_task_id, linked_run_id, status, error_message, created_at, updated_at - FROM channel_messages -`; - -function mapLegacyChannelMessageFixture( - row: Record, -): ChannelMessage { - const mentions = parseJson(readString(row, "mentions_json")); - const attachmentIds = parseJson(readString(row, "attachment_ids_json")); - const linkPreviewIds = parseJson(readString(row, "link_preview_ids_json")); - const fileRefs = parseJson(readString(row, "file_refs_json")); - if (!Array.isArray(mentions) || !Array.isArray(attachmentIds) || !Array.isArray(linkPreviewIds) || !Array.isArray(fileRefs)) { - throw new Error(`Invalid legacy channel message fixture payload: ${readString(row, "id")}`); - } - return { - id: readString(row, "id"), - workspaceId: readString(row, "workspace_id"), - channelId: readString(row, "channel_id"), - ...(readNullableString(row, "thread_id") ? { threadId: readNullableString(row, "thread_id") } : {}), - kind: readString(row, "kind") as ChannelMessage["kind"], - intent: readString(row, "intent") as ChannelMessage["intent"], - authorType: readString(row, "author_type") as ChannelMessage["authorType"], - ...(readNullableString(row, "author_id") ? { authorId: readNullableString(row, "author_id") } : {}), - body: readString(row, "body"), - mentions: mentions as ChannelMessage["mentions"], - attachmentIds: attachmentIds as readonly string[], - linkPreviewIds: linkPreviewIds as readonly string[], - fileRefs: fileRefs as ChannelMessage["fileRefs"], - ...(readNullableString(row, "linked_task_id") ? { linkedTaskId: readNullableString(row, "linked_task_id") } : {}), - ...(readNullableString(row, "linked_run_id") ? { linkedRunId: readNullableString(row, "linked_run_id") } : {}), - status: readString(row, "status") as ChannelMessage["status"], - ...(readNullableString(row, "error_message") ? { errorMessage: readNullableString(row, "error_message") } : {}), - createdAt: readString(row, "created_at"), - updatedAt: readString(row, "updated_at"), - }; -} - function normalizeOptionalOwnerId(value: string | undefined): string | undefined { if (value === undefined) { return undefined; diff --git a/packages/core/src/storage/sqlite/conversation-context-store.ts b/packages/core/src/storage/sqlite/conversation-context-store.ts index 5d64e4a4c..4a80bac8d 100644 --- a/packages/core/src/storage/sqlite/conversation-context-store.ts +++ b/packages/core/src/storage/sqlite/conversation-context-store.ts @@ -92,6 +92,16 @@ export class SqliteConversationContextStore { if (!turn) throw new Error(`Ling turn not found for context assignment: ${input.turnId}`); const threadId = readString(turn, "thread_id"); const previousAssignment = this.getEffectiveTurnContextAssignment(input.turnId); + if (input.expectedPreviousRevision !== undefined) { + if (!Number.isSafeInteger(input.expectedPreviousRevision) || input.expectedPreviousRevision < 1) { + throw new RangeError(`Invalid expected context assignment revision: ${input.expectedPreviousRevision}`); + } + if (previousAssignment?.revision !== input.expectedPreviousRevision) { + throw new Error( + `Context assignment revision conflict: expected ${input.expectedPreviousRevision}, current ${previousAssignment?.revision ?? "missing"}`, + ); + } + } const segment = this.resolveAssignmentSegment(input, threadId, previousAssignment, now); if (input.antecedentTurnId) { const antecedent = this.context.database.prepare(` diff --git a/packages/core/src/storage/sqlite/run-intervention-store.ts b/packages/core/src/storage/sqlite/run-intervention-store.ts index f84500bf3..3aafa91c1 100644 --- a/packages/core/src/storage/sqlite/run-intervention-store.ts +++ b/packages/core/src/storage/sqlite/run-intervention-store.ts @@ -1,6 +1,8 @@ import type { SQLOutputValue } from "node:sqlite"; -import type { ConversationItemV1 } from "@ling-stack/contracts"; -import { projectConversationItemToChannelMessage } from "../../conversation-item-channel-view.js"; +import { + projectConversationItemToChannelMessage, + type ConversationItemV1, +} from "@ling-stack/contracts"; import type { ChannelMessage } from "../../workspace-types.js"; import { createRunInterventionId, diff --git a/packages/core/src/workspace-types.ts b/packages/core/src/workspace-types.ts index 002e0b7dc..878476c6e 100644 --- a/packages/core/src/workspace-types.ts +++ b/packages/core/src/workspace-types.ts @@ -1306,39 +1306,6 @@ export interface ChannelMessage { readonly updatedAt: string; } -export interface CreateChannelMessageInput { - readonly id?: string; - readonly channelId: string; - readonly threadId?: string; - readonly kind?: ChannelMessageKind; - readonly intent?: ChannelMessageIntent; - readonly authorType: ChannelMessageAuthorType; - readonly authorId?: string; - readonly body: string; - readonly mentions?: readonly ChannelMention[]; - readonly attachmentIds?: readonly string[]; - readonly linkPreviewIds?: readonly string[]; - readonly fileRefs?: readonly ChannelMessageFileRef[]; - readonly linkedTaskId?: string; - readonly linkedRunId?: string; - readonly status?: ChannelMessage["status"]; - readonly errorMessage?: string; -} - -export interface ListChannelMessagesOptions { - readonly channelId: string; - readonly threadId?: string; - readonly excludeThreadIds?: readonly string[]; - readonly beforeMessageId?: string; - readonly limit?: number; -} - -export interface ChannelMessageListPage { - readonly messages: readonly ChannelMessage[]; - readonly hasMore: boolean; - readonly nextBeforeMessageId?: string; -} - export type ChannelMessageRouteAction = "reply" | "no_reply"; export type ChannelMessageRouteDecisionStatus = "accepted" | "suppressed" | "failed"; @@ -2559,10 +2526,6 @@ export function createTeammateDirectLinkPreviewId(): string { return `dm_link_${randomUUID()}`; } -export function createChannelMessageId(): string { - return `msg_${randomUUID()}`; -} - export function createChannelMessageRouteDecisionId(): string { return `route_${randomUUID()}`; } diff --git a/packages/core/test/approval-policy.test.mjs b/packages/core/test/approval-policy.test.mjs index b082e944d..264dc69d4 100644 --- a/packages/core/test/approval-policy.test.mjs +++ b/packages/core/test/approval-policy.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "./channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -558,7 +559,7 @@ test("sqlite store loads only the latest completed intervention replies before t const replyIds = []; for (let index = 0; index < 8; index += 1) { const timestamp = new Date(Date.parse("2026-08-08T00:00:00.000Z") + index * 1_000).toISOString(); - const reply = store.createChannelMessage({ + const reply = appendChannelItemFixture(store, { channelId: channel.id, kind: "ai_reply", intent: "discuss", diff --git a/packages/core/test/channel-conversation-fixture.mjs b/packages/core/test/channel-conversation-fixture.mjs new file mode 100644 index 000000000..7625ea918 --- /dev/null +++ b/packages/core/test/channel-conversation-fixture.mjs @@ -0,0 +1,201 @@ +import { randomUUID } from "node:crypto"; +import { projectConversationItemToChannelMessage } from "../dist/index.js"; + +/** + * Test-only convenience for fixtures that need a message-shaped presentation view. + * It writes exclusively through the canonical ConversationItem/LingTurn stores. + */ +export function appendChannelItemFixture(store, input, now) { + const channel = store.getChannel(input.channelId); + if (!channel) throw new Error(`Channel not found: ${input.channelId}`); + const explicitThread = input.threadId ? store.getLingThread(input.threadId) : undefined; + if (input.threadId && !explicitThread) { + throw new Error(`Fixture threadId must be a canonical LingThread id: ${input.threadId}`); + } + const topicAnchorItem = input.topicAnchorItemId + ? store.getConversationItem(input.topicAnchorItemId) + : undefined; + if (input.topicAnchorItemId && !topicAnchorItem) { + throw new Error(`Fixture Topic anchor item not found: ${input.topicAnchorItemId}`); + } + const replyToItem = input.replyToItemId + ? store.getConversationItem(input.replyToItemId) + : undefined; + if (input.replyToItemId && !replyToItem) { + throw new Error(`Fixture reply source item not found: ${input.replyToItemId}`); + } + const thread = explicitThread + ?? (topicAnchorItem + ? store.resolveOrCreateConversationThread({ + surfaceKind: "topic", + channelId: input.channelId, + anchorItemId: topicAnchorItem.id, + title: input.body.replace(/\s+/gu, " ").trim().slice(0, 120) || "Topic", + objective: `Topic anchored at conversation item ${topicAnchorItem.id}.`, + }, now).thread + : replyToItem + ? store.getLingThread(replyToItem.threadId) + : store.resolveOrCreateConversationThread({ + surfaceKind: "channel_root", + channelId: input.channelId, + title: `${channel.name} conversation`, + objective: `Root conversation for ${channel.name}.`, + }, now).thread); + if (!thread) { + throw new Error(`Fixture reply source Thread not found: ${replyToItem?.threadId ?? "unknown"}`); + } + if (thread.channelId !== input.channelId) { + throw new Error(`Conversation thread does not belong to channel: ${thread.id}`); + } + + if (input.authorType === "local-user" && (input.kind === undefined || input.kind === "message")) { + const result = store.appendConversationUserTurn({ + ...(input.id ? { itemId: input.id } : {}), + channelId: input.channelId, + threadId: thread.id, + turnKind: thread.nextItemSequence > 1 ? "manual_followup" : "initial_request", + title: input.body.replace(/\s+/gu, " ").trim().slice(0, 160) || "Channel input", + userInstruction: input.body || "Channel input with references.", + ...(input.authorId ? { actorId: input.authorId } : {}), + payload: { + body: input.body, + format: "plain_text", + mentions: (input.mentions ?? []).map((mention) => ({ + id: mention.id, + label: mention.label, + kind: mention.kind === "ai-teammate" + ? "ai_teammate" + : mention.kind === "local-user" + ? "local_user" + : "channel", + })), + attachmentIds: input.attachmentIds ?? [], + linkPreviewIds: input.linkPreviewIds ?? [], + fileRefs: input.fileRefs ?? [], + ...((topicAnchorItem ?? replyToItem) ? { replyToItemId: (topicAnchorItem ?? replyToItem).id } : {}), + }, + domainRefs: [ + ...(input.linkedTaskId ? [{ type: "task", id: input.linkedTaskId }] : []), + ...(input.linkedRunId ? [{ type: "run", id: input.linkedRunId }] : []), + ], + idempotencyKey: `fixture-channel-item:${input.id ?? randomUUID()}`, + }, now); + return projectConversationItemToChannelMessage(result.item, { linkedRunId: input.linkedRunId }); + } + + const latestTurnId = replyToItem?.turnId + ?? [...store.listConversationItemsPage({ threadId: thread.id, limit: 100 }).items] + .reverse() + .find((item) => item.kind === "user_message" && item.turnId)?.turnId; + const status = input.status ?? "ready"; + const assistantMessage = input.kind === "ai_reply" || (input.kind === undefined && input.authorType === "ai-teammate"); + const item = store.appendConversationItem(assistantMessage ? { + ...(input.id ? { id: input.id } : {}), + channelId: input.channelId, + threadId: thread.id, + ...(latestTurnId ? { turnId: latestTurnId } : {}), + kind: "assistant_message", + status: status === "pending" ? "pending" : status === "failed" ? "failed" : "committed", + actorType: input.authorId === "ling_agent" ? "ling_agent" : "teammate", + ...(input.authorId ? { actorId: input.authorId } : {}), + visibility: "channel", + payload: { + body: input.body, + format: "markdown", + streaming: status === "pending", + deliveryStatus: status, + ...(input.errorMessage ? { errorMessage: input.errorMessage } : {}), + }, + domainRefs: [ + ...((topicAnchorItem ?? replyToItem) + ? [{ type: "conversation_item", id: (topicAnchorItem ?? replyToItem).id }] + : []), + ...(input.linkedTaskId ? [{ type: "task", id: input.linkedTaskId }] : []), + ...(input.linkedRunId ? [{ type: "run", id: input.linkedRunId }] : []), + ], + ...(input.id ? { idempotencyKey: `channel-item:${input.id}` } : {}), + } : { + ...(input.id ? { id: input.id } : {}), + channelId: input.channelId, + threadId: thread.id, + ...(latestTurnId ? { turnId: latestTurnId } : {}), + kind: "system_event", + status: status === "failed" ? "failed" : "committed", + actorType: "system", + ...(input.authorId ? { actorId: input.authorId } : {}), + visibility: "channel", + payload: { eventType: input.kind ?? "system", summary: input.body }, + domainRefs: [ + ...(input.linkedTaskId ? [{ type: "task", id: input.linkedTaskId }] : []), + ...(input.linkedRunId ? [{ type: "run", id: input.linkedRunId }] : []), + ], + ...(input.id ? { idempotencyKey: `channel-item:${input.id}` } : {}), + }, now); + return projectConversationItemToChannelMessage(item, { linkedRunId: input.linkedRunId }); +} + +export function getChannelItemViewFixture(store, itemId) { + const item = store.getEffectiveConversationItem(itemId); + if (!item?.channelId) return undefined; + const turn = item.turnId ? store.getLingTurn(item.turnId) : undefined; + return projectConversationItemToChannelMessage(item, { + linkedRunId: turn?.activeRunId ?? turn?.lastRunId, + }); +} + +export function listChannelItemViewsFixture(store, options) { + return store.listConversationItemsByChannelPage({ + channelId: options.channelId, + ...(options.threadId ? { threadId: options.threadId } : {}), + ...(options.excludeThreadIds ? { excludeThreadIds: options.excludeThreadIds } : {}), + ...(options.beforeItemId ? { beforeItemId: options.beforeItemId } : {}), + kinds: ["user_message", "assistant_message", "system_event"], + effectiveOnly: true, + ...(options.limit !== undefined ? { limit: options.limit } : {}), + }).items.map((item) => getChannelItemViewFixture(store, item.id)); +} + +export function listChannelItemViewPageFixture(store, options) { + const page = store.listConversationItemsPage({ + threadId: options.threadId, + ...(options.beforeSequence !== undefined ? { beforeSequence: options.beforeSequence } : {}), + effectiveOnly: true, + ...(options.limit !== undefined ? { limit: options.limit } : {}), + }); + return { + messages: page.items.map((item) => getChannelItemViewFixture(store, item.id)), + hasMore: page.nextBeforeSequence !== undefined, + ...(page.nextBeforeSequence !== undefined ? { nextBeforeSequence: page.nextBeforeSequence } : {}), + }; +} + +export function updateChannelItemFixture(store, itemId, input, now) { + const item = store.getConversationItem(itemId); + if (!item) return undefined; + if (input.linkedRunId && item.turnId && !store.getLingTurnForRun(input.linkedRunId)) { + store.linkRunToLingTurn({ turnId: item.turnId, runId: input.linkedRunId, now }); + } + if (item.kind !== "assistant_message" || item.status !== "pending") { + return getChannelItemViewFixture(store, itemId); + } + const status = input.status ?? item.payload.deliveryStatus; + const terminal = status === "ready" || status === "blocked" || status === "failed"; + store.updatePendingConversationItem(itemId, { + expectedRevision: item.revision, + payload: { + body: input.body ?? item.payload.body, + format: item.payload.format, + streaming: !terminal, + deliveryStatus: status, + ...(input.errorMessage === null + ? {} + : input.errorMessage !== undefined + ? { errorMessage: input.errorMessage } + : item.payload.errorMessage + ? { errorMessage: item.payload.errorMessage } + : {}), + }, + status: status === "failed" ? "failed" : terminal ? "committed" : "pending", + }, now); + return getChannelItemViewFixture(store, itemId); +} diff --git a/packages/core/test/channel-intent-v2-migration.test.mjs b/packages/core/test/channel-intent-v2-migration.test.mjs index 7d9e1c86a..4c4122dd9 100644 --- a/packages/core/test/channel-intent-v2-migration.test.mjs +++ b/packages/core/test/channel-intent-v2-migration.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { appendLegacyChannelMessageFixture } from "./legacy-continuity-fixture.mjs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -16,7 +17,7 @@ test("Channel Intent v2 migration deletes v1 rows and preserves new v2 decisions t.after(() => rm(cwd, { recursive: true, force: true })); const legacy = createLegacySqliteRunStoreForMigrationTest(cwd); const channel = legacy.createChannel({ name: "Intent v2", boundPath: cwd }, "2026-08-10T10:00:00.000Z"); - const message = legacy.createChannelMessage({ + const message = appendLegacyChannelMessageFixture(legacy, { channelId: channel.id, authorType: "local-user", body: "Write an article", diff --git a/packages/core/test/channel-runtime-store.test.mjs b/packages/core/test/channel-runtime-store.test.mjs index 34c47db05..b6c2c516d 100644 --- a/packages/core/test/channel-runtime-store.test.mjs +++ b/packages/core/test/channel-runtime-store.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewPageFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "./channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -192,7 +193,7 @@ test("sqlite channel auto reply mode and route decisions round trip", async () = const reset = store.updateChannel(channel.id, { autoReplyMode: null }, "2026-06-09T00:02:00.000Z"); assert.equal(reset.autoReplyMode, "mentions_only"); - const message = store.createChannelMessage( + const message = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", @@ -200,10 +201,10 @@ test("sqlite channel auto reply mode and route decisions round trip", async () = }, "2026-06-09T00:03:00.000Z", ); - const reply = store.createChannelMessage( + const reply = appendChannelItemFixture(store, { channelId: channel.id, - threadId: message.id, + replyToItemId: message.id, kind: "ai_reply", authorType: "ai-teammate", authorId: "teammate_builtin_engineer", @@ -308,7 +309,7 @@ test("sqlite channel intent decisions round trip visible multi-reply ids", async }, now, ); - const message = store.createChannelMessage( + const message = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", @@ -316,10 +317,10 @@ test("sqlite channel intent decisions round trip visible multi-reply ids", async }, "2026-06-10T00:01:00.000Z", ); - const architectReply = store.createChannelMessage( + const architectReply = appendChannelItemFixture(store, { channelId: channel.id, - threadId: message.id, + replyToItemId: message.id, kind: "ai_reply", authorType: "ai-teammate", authorId: "teammate_builtin_architect", @@ -327,10 +328,10 @@ test("sqlite channel intent decisions round trip visible multi-reply ids", async }, "2026-06-10T00:02:00.000Z", ); - const engineerReply = store.createChannelMessage( + const engineerReply = appendChannelItemFixture(store, { channelId: channel.id, - threadId: message.id, + replyToItemId: message.id, kind: "ai_reply", authorType: "ai-teammate", authorId: "teammate_builtin_engineer", @@ -468,7 +469,7 @@ test("sqlite channel workspace assets persist messages, attachments, and link pr }, now, ); - const message = store.createChannelMessage( + const message = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", @@ -501,8 +502,8 @@ test("sqlite channel workspace assets persist messages, attachments, and link pr ); assert.equal(store.getChannel(channel.id).defaultTeammateId, "engineer"); - assert.deepEqual(store.listChannelMessages({ channelId: channel.id }).map((item) => item.id), [message.id]); - assert.equal(store.getChannelMessage(message.id).mentions[0].id, "engineer"); + assert.deepEqual(listChannelItemViewsFixture(store, { channelId: channel.id }).map((item) => item.id), [message.id]); + assert.equal(getChannelItemViewFixture(store, message.id).mentions[0].id, "engineer"); assert.deepEqual(store.listChannelAttachments({ channelId: channel.id }).map((item) => item.id), [attachment.id]); assert.equal(store.getChannelAttachment(attachment.id).fileName, "brief.pdf"); assert.deepEqual(store.listChannelLinkPreviews({ channelId: channel.id }).map((item) => item.id), [preview.id]); @@ -521,7 +522,7 @@ test("sqlite channel workspace assets persist messages, attachments, and link pr } }); -test("sqlite channel messages page latest history with stable cursors", async () => { +test("sqlite channel items page latest history with stable sequence cursors", async () => { const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-message-page-")); const store = createSqliteRunStore(cwd); try { @@ -534,7 +535,7 @@ test("sqlite channel messages page latest history with stable cursors", async () ); const messages = []; for (let index = 0; index < 520; index += 1) { - messages.push(store.createChannelMessage( + messages.push(appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", @@ -544,16 +545,17 @@ test("sqlite channel messages page latest history with stable cursors", async () )); } - const defaultPage = store.listChannelMessagesPage({ channelId: channel.id }); + const threadId = store.getConversationItem(messages[0].id).threadId; + const defaultPage = listChannelItemViewPageFixture(store, { threadId, limit: 200 }); assert.equal(defaultPage.messages.length, 200); assert.equal(defaultPage.hasMore, true); assert.equal(defaultPage.messages[0].body, "Message 321"); assert.equal(defaultPage.messages.at(-1).body, "Message 520"); - assert.equal(defaultPage.nextBeforeMessageId, defaultPage.messages[0].id); + assert.equal(defaultPage.nextBeforeSequence, store.getConversationItem(defaultPage.messages[0].id).sequence); - const olderPage = store.listChannelMessagesPage({ - channelId: channel.id, - beforeMessageId: defaultPage.nextBeforeMessageId, + const olderPage = listChannelItemViewPageFixture(store, { + threadId, + beforeSequence: defaultPage.nextBeforeSequence, limit: 100, }); assert.equal(olderPage.messages.length, 100); @@ -562,17 +564,17 @@ test("sqlite channel messages page latest history with stable cursors", async () assert.equal(olderPage.messages.at(-1).body, "Message 320"); assert.equal(new Set([...defaultPage.messages, ...olderPage.messages].map((message) => message.id)).size, 300); - const oversizedPage = store.listChannelMessagesPage({ channelId: channel.id, limit: 999 }); + const oversizedPage = listChannelItemViewPageFixture(store, { threadId, limit: 999 }); assert.equal(oversizedPage.messages.length, 500); assert.equal(oversizedPage.messages[0].body, "Message 21"); assert.equal(oversizedPage.messages.at(-1).body, "Message 520"); assert.throws( - () => store.listChannelMessagesPage({ channelId: channel.id, beforeMessageId: "message_missing" }), - /Invalid channel message cursor/, + () => listChannelItemViewPageFixture(store, { threadId, beforeSequence: 0 }), + /Invalid conversation item cursor/, ); - assert.deepEqual(store.listChannelMessages({ channelId: channel.id, limit: 3 }).map((message) => message.body), [ + assert.deepEqual(listChannelItemViewsFixture(store, { channelId: channel.id, limit: 3 }).map((message) => message.body), [ "Message 518", "Message 519", "Message 520", @@ -596,7 +598,7 @@ test("sqlite channel message root pages can exclude channel branch lanes while k }, "2026-05-29T00:00:00.000Z", ); - const rootMessage = store.createChannelMessage( + const rootMessage = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", @@ -634,10 +636,10 @@ test("sqlite channel message root pages can exclude channel branch lanes while k }, "2026-05-29T00:00:02.750Z", ); - store.createChannelMessage( + appendChannelItemFixture(store, { channelId: channel.id, - threadId: rootMessage.id, + replyToItemId: rootMessage.id, kind: "ai_reply", authorType: "ai-teammate", authorId: "ling_agent", @@ -645,7 +647,7 @@ test("sqlite channel message root pages can exclude channel branch lanes while k }, "2026-05-29T00:00:03.000Z", ); - store.createChannelMessage( + appendChannelItemFixture(store, { channelId: channel.id, threadId: branch.rootThreadId, @@ -656,7 +658,7 @@ test("sqlite channel message root pages can exclude channel branch lanes while k ); assert.deepEqual( - store.listChannelMessages({ + listChannelItemViewsFixture(store, { channelId: channel.id, excludeThreadIds: [branch.rootThreadId], limit: 10, @@ -664,7 +666,7 @@ test("sqlite channel message root pages can exclude channel branch lanes while k ["Root message", "Root attached reply"], ); assert.deepEqual( - store.listChannelMessages({ + listChannelItemViewsFixture(store, { channelId: channel.id, threadId: branch.rootThreadId, limit: 10, @@ -677,7 +679,7 @@ test("sqlite channel message root pages can exclude channel branch lanes while k } }); -test("sqlite channel message cursors keep same-timestamp order stable", async () => { +test("sqlite channel item sequence cursors keep same-timestamp order stable", async () => { const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-message-same-time-")); const store = createSqliteRunStore(cwd); try { @@ -689,7 +691,7 @@ test("sqlite channel message cursors keep same-timestamp order stable", async () "2026-05-29T01:00:00.000Z", ); for (let index = 0; index < 6; index += 1) { - store.createChannelMessage( + appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", @@ -699,16 +701,17 @@ test("sqlite channel message cursors keep same-timestamp order stable", async () ); } - const latest = store.listChannelMessagesPage({ channelId: channel.id, limit: 3 }); + const threadId = store.findLingThreadByOwner({ kind: "channel_discussion", channelId: channel.id }).id; + const latest = listChannelItemViewPageFixture(store, { threadId, limit: 3 }); assert.deepEqual(latest.messages.map((message) => message.body), [ "Same time 4", "Same time 5", "Same time 6", ]); - const older = store.listChannelMessagesPage({ - channelId: channel.id, + const older = listChannelItemViewPageFixture(store, { + threadId, limit: 3, - beforeMessageId: latest.nextBeforeMessageId, + beforeSequence: latest.nextBeforeSequence, }); assert.deepEqual(older.messages.map((message) => message.body), [ "Same time 1", @@ -729,8 +732,8 @@ test("sqlite channel collaboration protocol persists brief, decisions, handoffs, try { const channel = store.createChannel({ name: "Engineering", boundPath: cwd }, now); const otherChannel = store.createChannel({ name: "Design", boundPath: cwd }, now); - const message = store.createChannelMessage({ channelId: channel.id, authorType: "local-user", body: "Use manual handoffs." }, now); - const otherMessage = store.createChannelMessage({ channelId: otherChannel.id, authorType: "local-user", body: "Wrong channel." }, now); + const message = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "Use manual handoffs." }, now); + const otherMessage = appendChannelItemFixture(store, { channelId: otherChannel.id, authorType: "local-user", body: "Wrong channel." }, now); const task = store.createTask({ channelId: channel.id, title: "Implement protocol", prompt: "Build v1.1." }, now); const firstDecision = store.createChannelDecision( @@ -1431,7 +1434,7 @@ test("sqlite channel workspace migration self-heals old channel and task columns }, now, ); - const message = store.createChannelMessage( + const message = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", @@ -1442,7 +1445,7 @@ test("sqlite channel workspace migration self-heals old channel and task columns assert.equal(store.getChannel(channel.id).defaultTeammateId, "engineer"); assert.equal(store.getTask(task.id).assigneeTeammateId, "engineer"); - assert.equal(store.getChannelMessage(message.id).body, "Migration should keep channel chat writable."); + assert.equal(getChannelItemViewFixture(store, message.id).body, "Migration should keep channel chat writable."); } finally { store.close(); } diff --git a/packages/core/test/channel-team-evidence-store.test.mjs b/packages/core/test/channel-team-evidence-store.test.mjs index 414602f2b..515784eb7 100644 --- a/packages/core/test/channel-team-evidence-store.test.mjs +++ b/packages/core/test/channel-team-evidence-store.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "./channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -42,7 +43,7 @@ test("sqlite channel team evidence round-trips without visible message or runtim const now = "2026-06-26T00:00:00.000Z"; const store = createSqliteRunStore(cwd); const channel = store.createChannel({ name: "Engineering", boundPath: cwd }, now); - const message = store.createChannelMessage( + const message = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", diff --git a/packages/core/test/continuity-v2-migration.test.mjs b/packages/core/test/continuity-v2-migration.test.mjs index 39e9f2674..84dc8b8d7 100644 --- a/packages/core/test/continuity-v2-migration.test.mjs +++ b/packages/core/test/continuity-v2-migration.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { appendLegacyChannelMessageFixture, updateLegacyChannelMessageFixture } from "./legacy-continuity-fixture.mjs"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { mkdtemp, rm } from "node:fs/promises"; @@ -43,7 +44,7 @@ test("Continuity v2 migration atomically imports root, topic and branch timeline rootThreadId: branchThread.id, }, "2026-08-09T12:00:03.000Z"); - const rootUser = store.createChannelMessage({ + const rootUser = appendLegacyChannelMessageFixture(store, { id: "message-root-user", channelId: channel.id, authorType: "local-user", @@ -51,7 +52,7 @@ test("Continuity v2 migration atomically imports root, topic and branch timeline mentions: [{ id: "ling_agent", label: "Ling", kind: "ai-teammate" }], fileRefs: [{ path: "README.md", source: "explicit_text", line: 1 }], }, "2026-08-09T12:01:00.000Z"); - const rootReply = store.createChannelMessage({ + const rootReply = appendLegacyChannelMessageFixture(store, { id: "message-root-reply", channelId: channel.id, threadId: rootUser.id, @@ -60,14 +61,14 @@ test("Continuity v2 migration atomically imports root, topic and branch timeline authorId: "ling_agent", body: "Use Thread, Turn and Item.", }, "2026-08-09T12:01:01.000Z"); - const topicUser = store.createChannelMessage({ + const topicUser = appendLegacyChannelMessageFixture(store, { id: "message-topic-user", channelId: channel.id, threadId: rootUser.id, authorType: "local-user", body: "Proceed with option B.", }, "2026-08-09T12:01:02.000Z"); - const topicReply = store.createChannelMessage({ + const topicReply = appendLegacyChannelMessageFixture(store, { id: "message-topic-reply", channelId: channel.id, threadId: rootUser.id, @@ -76,14 +77,14 @@ test("Continuity v2 migration atomically imports root, topic and branch timeline authorId: "teammate_builtin_architect", body: "Option B is recorded.", }, "2026-08-09T12:01:03.000Z"); - const branchUser = store.createChannelMessage({ + const branchUser = appendLegacyChannelMessageFixture(store, { id: "message-branch-user", channelId: channel.id, threadId: branchThread.id, authorType: "local-user", body: "Implement in the branch.", }, "2026-08-09T12:02:00.000Z"); - const branchReply = store.createChannelMessage({ + const branchReply = appendLegacyChannelMessageFixture(store, { id: "message-branch-reply", channelId: channel.id, threadId: branchThread.id, @@ -145,7 +146,7 @@ test("Continuity v2 migration atomically imports root, topic and branch timeline turnId: rootTurn.id, createdAt: rootReply.createdAt, }); - store.updateChannelMessageRuntime(rootReply.id, { linkedRunId: replyRun.id }, rootReply.updatedAt); + updateLegacyChannelMessageFixture(store, rootReply.id, { linkedRunId: replyRun.id }, rootReply.updatedAt); const attachment = store.createChannelAttachment({ channelId: channel.id, @@ -396,7 +397,7 @@ test("Continuity v2 migration rolls back all schema and data changes on an unrep t.after(() => rm(cwd, { recursive: true, force: true })); const store = createLegacySqliteRunStoreForMigrationTest(cwd); const channel = store.createChannel({ name: "Rollback", boundPath: cwd }, "2026-08-09T12:00:00.000Z"); - store.createChannelMessage({ + appendLegacyChannelMessageFixture(store, { id: "message-too-large", channelId: channel.id, authorType: "local-user", diff --git a/packages/core/test/conversation-context-store.test.mjs b/packages/core/test/conversation-context-store.test.mjs index d36f9f959..95b779ff3 100644 --- a/packages/core/test/conversation-context-store.test.mjs +++ b/packages/core/test/conversation-context-store.test.mjs @@ -136,6 +136,49 @@ test("context assignments reject invented cross-thread segments and invalid conf }, secondAt), /between 0 and 1/); }); +test("explicit context corrections use an atomic assignment revision fence", async (t) => { + const cwd = await mkdtemp(join(tmpdir(), "ling-context-assignment-cas-")); + t.after(() => rm(cwd, { recursive: true, force: true })); + const store = createSqliteRunStore(cwd); + t.after(() => store.close()); + const channel = store.createChannel({ name: "Correction", boundPath: cwd }, firstAt); + const thread = store.resolveOrCreateConversationThread({ + surfaceKind: "channel_root", + channelId: channel.id, + title: "Correction", + objective: "Test explicit correction fencing", + }, firstAt).thread; + const first = appendUserTurn(store, channel.id, thread.id, "first", firstAt); + const second = appendUserTurn(store, channel.id, thread.id, "second", secondAt); + const firstAssignment = store.getEffectiveTurnContextAssignment(first.turnId); + const secondAssignment = store.getEffectiveTurnContextAssignment(second.turnId); + assert.ok(firstAssignment); + assert.ok(secondAssignment); + + const corrected = store.applyTurnContextAssignment({ + turnId: second.turnId, + expectedPreviousRevision: secondAssignment.revision, + target: { kind: "existing", segmentId: firstAssignment.segmentId }, + status: "committed", + decisionSource: "user", + evidenceRefs: [{ type: "turn", id: second.turnId }], + policyVersion: "continuity-v2-user-correction-v1", + }, thirdAt); + assert.equal(corrected.assignment.revision, secondAssignment.revision + 1); + assert.equal(corrected.assignment.decisionSource, "user"); + + assert.throws(() => store.applyTurnContextAssignment({ + turnId: second.turnId, + expectedPreviousRevision: secondAssignment.revision, + target: { kind: "existing", segmentId: firstAssignment.segmentId }, + status: "committed", + decisionSource: "user", + evidenceRefs: [{ type: "turn", id: second.turnId }], + policyVersion: "continuity-v2-user-correction-v1", + }, thirdAt), /Context assignment revision conflict/); + assert.equal(store.getEffectiveTurnContextAssignment(second.turnId)?.id, corrected.assignment.id); +}); + test("context summaries commit with a source fence, supersede atomically, and discard stale results", async (t) => { const cwd = await mkdtemp(join(tmpdir(), "ling-context-summary-")); t.after(() => rm(cwd, { recursive: true, force: true })); diff --git a/packages/core/test/conversation-item-store.test.mjs b/packages/core/test/conversation-item-store.test.mjs index 06fc076bc..3e005a112 100644 --- a/packages/core/test/conversation-item-store.test.mjs +++ b/packages/core/test/conversation-item-store.test.mjs @@ -247,6 +247,29 @@ test("ConversationItemStore enforces thread, turn, channel and visibility bounda } }); +test("ConversationItemStore paging uses the thread-sequence index instead of scanning the timeline", () => { + const { database } = createStore(); + try { + const plan = database.prepare(` + EXPLAIN QUERY PLAN + SELECT * + FROM conversation_items + WHERE workspace_id = ? AND thread_id = ? AND sequence < ? + AND NOT EXISTS ( + SELECT 1 FROM conversation_items AS replacement + WHERE replacement.supersedes_item_id = conversation_items.id + ) + ORDER BY sequence DESC + LIMIT ? + `).all("workspace_default", "thread-1", Number.MAX_SAFE_INTEGER, 101); + const detail = plan.map((row) => String(row.detail ?? "")).join("\n"); + assert.match(detail, /SEARCH conversation_items USING INDEX .*\(thread_id=\? AND sequence<\?\)/); + assert.doesNotMatch(detail, /SCAN conversation_items(?:\s|$)/); + } finally { + database.close(); + } +}); + function createStore() { const database = new DatabaseSync(":memory:"); database.exec(` diff --git a/packages/core/test/conversation-read-state.test.mjs b/packages/core/test/conversation-read-state.test.mjs index 96e3f1f33..5db1ed0b9 100644 --- a/packages/core/test/conversation-read-state.test.mjs +++ b/packages/core/test/conversation-read-state.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "./channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -26,7 +27,7 @@ test("daemon-owned read cursor is monotonic and later message revisions become u assert.equal(initial.unreadCount, 0); assert.equal(initial.mentioned, false); - const reply = store.createChannelMessage({ + const reply = appendChannelItemFixture(store, { channelId: channel.id, authorType: "ai-teammate", authorId: createBuiltinAiTeammateId("engineer"), @@ -52,7 +53,7 @@ test("daemon-owned read cursor is monotonic and later message revisions become u }, "2026-07-31T03:00:03.000Z"); assert.equal(backwards.readThroughActivitySeq, unread.activitySeq); - store.updateChannelMessageRuntime(reply.id, { + updateChannelItemFixture(store, reply.id, { body: "Please review the completed answer.", status: "ready", }, "2026-07-31T03:00:04.000Z"); @@ -68,7 +69,7 @@ test("daemon-owned read cursor is monotonic and later message revisions become u assert.equal(capped.readThroughActivitySeq, editedUnread.activitySeq); assert.equal(capped.unreadCount, 0); - store.createChannelMessage({ + appendChannelItemFixture(store, { channelId: channel.id, authorType: "ai-teammate", authorId: createBuiltinAiTeammateId("designer"), diff --git a/packages/core/test/conversation-revision-store.test.mjs b/packages/core/test/conversation-revision-store.test.mjs index cd200e26d..d7ca9279b 100644 --- a/packages/core/test/conversation-revision-store.test.mjs +++ b/packages/core/test/conversation-revision-store.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "./channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -43,7 +44,7 @@ test("channel message edits create revisions, supersede the old turn, and replay const now = "2026-07-12T01:00:00.000Z"; try { const channel = store.createChannel({ name: "Engineering", boundPath: cwd }, now); - const message = store.createChannelMessage({ + const message = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "Review the old implementation.", @@ -92,7 +93,7 @@ test("channel message edits create revisions, supersede the old turn, and replay [2, "Review and fix the current implementation."], ], ); - assert.equal(store.getChannelMessage(message.id)?.body, input.replacement.body); + assert.equal(getChannelItemViewFixture(store, message.id)?.body, input.replacement.body); assert.equal(store.getLingTurn(turn.id)?.status, "superseded"); const replay = store.commitConversationMessageEdit(input, "2026-07-12T01:02:00.000Z"); diff --git a/packages/core/test/legacy-continuity-fixture.mjs b/packages/core/test/legacy-continuity-fixture.mjs new file mode 100644 index 000000000..74a2ab2b8 --- /dev/null +++ b/packages/core/test/legacy-continuity-fixture.mjs @@ -0,0 +1,74 @@ +import { randomUUID } from "node:crypto"; + +/** Seeds the pre-v2 table directly for one-time migration and recovery tests only. */ +export function appendLegacyChannelMessageFixture(store, input, now) { + if (!store.getChannel(input.channelId)) throw new Error(`Channel not found: ${input.channelId}`); + const message = { + id: input.id ?? `message_${randomUUID()}`, + workspaceId: "workspace_default", + channelId: input.channelId, + ...(input.threadId ? { threadId: input.threadId } : {}), + kind: input.kind ?? "message", + intent: input.intent ?? "discuss", + authorType: input.authorType, + ...(input.authorId ? { authorId: input.authorId } : {}), + body: input.body, + mentions: input.mentions ?? [], + attachmentIds: input.attachmentIds ?? [], + linkPreviewIds: input.linkPreviewIds ?? [], + fileRefs: input.fileRefs ?? [], + ...(input.linkedTaskId ? { linkedTaskId: input.linkedTaskId } : {}), + ...(input.linkedRunId ? { linkedRunId: input.linkedRunId } : {}), + status: input.status ?? "ready", + ...(input.errorMessage ? { errorMessage: input.errorMessage } : {}), + createdAt: now, + updatedAt: now, + }; + store.database.prepare(` + INSERT INTO channel_messages ( + id, workspace_id, channel_id, thread_id, kind, intent, author_type, author_id, + body, mentions_json, attachment_ids_json, link_preview_ids_json, file_refs_json, + linked_task_id, linked_run_id, status, error_message, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + message.id, + message.workspaceId, + message.channelId, + message.threadId ?? null, + message.kind, + message.intent, + message.authorType, + message.authorId ?? null, + message.body, + JSON.stringify(message.mentions), + JSON.stringify(message.attachmentIds), + JSON.stringify(message.linkPreviewIds), + JSON.stringify(message.fileRefs), + message.linkedTaskId ?? null, + message.linkedRunId ?? null, + message.status, + message.errorMessage ?? null, + now, + now, + ); + return message; +} + +export function updateLegacyChannelMessageFixture(store, id, input, now) { + store.database.prepare(` + UPDATE channel_messages + SET body = COALESCE(?, body), + status = COALESCE(?, status), + error_message = ?, + linked_run_id = COALESCE(?, linked_run_id), + updated_at = ? + WHERE id = ? + `).run( + input.body ?? null, + input.status ?? null, + input.errorMessage ?? null, + input.linkedRunId ?? null, + now, + id, + ); +} diff --git a/packages/core/test/remote-command-store.test.mjs b/packages/core/test/remote-command-store.test.mjs index 4e8ff7b98..87b1cb414 100644 --- a/packages/core/test/remote-command-store.test.mjs +++ b/packages/core/test/remote-command-store.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "./channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -47,7 +48,7 @@ test("remote sends atomically persist one native message, receipt, dispatch inte conversationId: channel.id, }), "2026-07-31T01:00:01.000Z"); assert.equal(channelSend.idempotentReplay, false); - assert.equal(store.getChannelMessage(channelSend.messageId)?.body, "Sent from iPhone."); + assert.equal(getChannelItemViewFixture(store, channelSend.messageId)?.body, "Sent from iPhone."); assert.equal(channelSend.receipt.status, "completed"); assert.deepEqual(channelSend.receipt.terminalOutcome?.result, { kind: "message_created", @@ -68,7 +69,7 @@ test("remote sends atomically persist one native message, receipt, dispatch inte }), "2026-07-31T01:05:00.000Z"); assert.equal(replay.idempotentReplay, true); assert.equal(replay.messageId, channelSend.messageId); - assert.equal(store.listChannelMessages({ channelId: channel.id }).filter((item) => item.id === channelSend.messageId).length, 1); + assert.equal(listChannelItemViewsFixture(store, { channelId: channel.id }).filter((item) => item.id === channelSend.messageId).length, 1); assert.throws( () => store.commitRemoteConversationSend(sendInput({ diff --git a/packages/core/test/remote-projection-outbox.test.mjs b/packages/core/test/remote-projection-outbox.test.mjs index 298e6abf2..63f552418 100644 --- a/packages/core/test/remote-projection-outbox.test.mjs +++ b/packages/core/test/remote-projection-outbox.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "./channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -44,7 +45,7 @@ test("existing conversation and profile writes durably enqueue lightweight proje try { const start = store.listRemoteProjectionOutbox({ limit: 1_000 }).at(-1)?.activitySeq ?? 0; const channel = store.createChannel({ name: "Projection", boundPath: cwd }, "2026-07-31T02:00:00.000Z"); - const channelMessage = store.createChannelMessage({ + const channelMessage = appendChannelItemFixture(store, { channelId: channel.id, authorType: "ai-teammate", authorId: createBuiltinAiTeammateId("engineer"), diff --git a/packages/daemon/src/features/channels/channel-context-inspect.ts b/packages/daemon/src/features/channels/channel-context-inspect.ts new file mode 100644 index 000000000..513be5e00 --- /dev/null +++ b/packages/daemon/src/features/channels/channel-context-inspect.ts @@ -0,0 +1,180 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { + CorrectChannelTurnContextRequest, + CorrectChannelTurnContextResponse, + ConversationItemV1, +} from "@ling-stack/contracts"; +import type { RunStore } from "@ling-stack/core"; +import type { ResolvedDaemonConfig } from "../../config.js"; +import { HttpError } from "../../errors.js"; +import type { RunStoreRegistry } from "../../runtime/store-registry.js"; +import { readJsonBody } from "../../shared/request.js"; +import { writeJson } from "../../shared/response.js"; +import { isRecord, readString } from "../../shared/validation.js"; +import { buildConversationContextProjection } from "./context-processing/conversation-context-projector.js"; + +const USER_CONTEXT_CORRECTION_POLICY_VERSION = "continuity-v2-user-correction-v1"; + +export async function handleChannelTurnContext( + request: IncomingMessage, + response: ServerResponse, + config: ResolvedDaemonConfig, + storeRegistry: RunStoreRegistry, + channelId: string, + turnId: string, + correction: boolean, +): Promise { + const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${config.host}:${config.port}`}`); + const cwd = url.searchParams.get("cwd"); + if (!cwd) { + writeJson(response, 400, { error: "bad_request", message: "cwd query parameter is required" }); + return; + } + try { + const store = storeRegistry.getStore(cwd); + const lineage = resolveChannelTurnLineage(store, channelId, turnId); + if (correction) { + if (request.method !== "POST") { + writeJson(response, 405, { error: "method_not_allowed", message: `${request.method} is not supported for context corrections` }); + return; + } + const body = await readJsonBody(request, config.maxBodyBytes); + const input = parseContextCorrection(body); + const target = store.getContextSegment(input.segmentId); + if (!target || target.threadId !== lineage.thread.id || target.state === "archived" || target.state === "merged") { + throw new HttpError(400, "context_segment_unavailable", `Context segment is not available to this turn: ${input.segmentId}`); + } + const previousAssignment = store.getEffectiveTurnContextAssignment(turnId); + if (!previousAssignment) { + throw new HttpError(409, "context_assignment_missing", `Turn has no effective context assignment: ${turnId}`); + } + let result; + try { + result = store.applyTurnContextAssignment({ + turnId, + expectedPreviousRevision: input.expectedAssignmentRevision, + target: { kind: "existing", segmentId: target.id }, + status: "committed", + decisionSource: "user", + evidenceRefs: [ + { type: "turn", id: turnId }, + { type: "segment", id: target.id }, + ], + policyVersion: USER_CONTEXT_CORRECTION_POLICY_VERSION, + }, new Date().toISOString()); + } catch (error) { + if (error instanceof Error && error.message.startsWith("Context assignment revision conflict:")) { + throw new HttpError(409, "context_assignment_revision_conflict", error.message); + } + throw error; + } + const projection = buildConversationContextProjection({ + store, + sourceItemId: lineage.sourceItem.id, + view: "inspect", + }); + const payload: CorrectChannelTurnContextResponse = { + cwd, + channelId, + thread: lineage.thread, + turn: lineage.turn, + segment: result.segment, + assignment: result.assignment, + previousAssignment, + invalidatedSummaryIds: result.invalidatedSummaryIds, + projection: projection.metadata, + idempotentReplay: result.idempotentReplay, + }; + writeJson(response, 200, payload); + return; + } + + if (request.method !== "GET") { + writeJson(response, 405, { error: "method_not_allowed", message: `${request.method} is not supported for context inspect` }); + return; + } + const projection = buildConversationContextProjection({ + store, + sourceItemId: lineage.sourceItem.id, + view: "inspect", + }); + const candidates = store.listConversationItemsForSegment({ + threadId: lineage.thread.id, + segmentId: projection.segment.id, + throughSequence: lineage.sourceItem.sequence, + order: "oldest", + limit: 500, + }); + const projectedIds = new Set(projection.items.map((item) => item.id)); + const redactedItemCount = candidates.filter((item) => + item.visibility === "agent_private" && !projectedIds.has(item.id)).length; + writeJson(response, 200, { + cwd, + channelId, + thread: lineage.thread, + turn: lineage.turn, + sourceItemId: lineage.sourceItem.id, + segment: projection.segment, + segments: store.listContextSegments({ + threadId: lineage.thread.id, + states: ["active", "provisional"], + limit: 100, + }), + assignment: projection.assignment, + projection: projection.metadata, + summaries: projection.summaries, + sourceRefs: projection.metadata.sourceRefs, + droppedItemCount: Math.max(0, candidates.length - projection.items.length), + redactedItemCount, + safety: { + rawPromptTextIncluded: false, + hiddenReasoningIncluded: false, + privateMemoryBodiesIncluded: false, + secretValuesIncluded: false, + }, + }); + } catch (error) { + writeJson(response, error instanceof HttpError ? error.statusCode : 400, { + error: error instanceof HttpError ? error.code : "bad_request", + message: error instanceof Error ? error.message : String(error), + }); + } +} + +function resolveChannelTurnLineage(store: RunStore, channelId: string, turnId: string): { + readonly turn: NonNullable>; + readonly thread: NonNullable>; + readonly sourceItem: ConversationItemV1; +} { + const channel = store.getChannel(channelId); + if (!channel) throw new HttpError(404, "channel_not_found", `Channel not found: ${channelId}`); + const turn = store.getLingTurn(turnId); + if (!turn || turn.channelId !== channelId) { + throw new HttpError(404, "channel_turn_not_found", `Channel turn not found: ${turnId}`); + } + const thread = store.getLingThread(turn.threadId); + if (!thread || thread.channelId !== channelId) { + throw new HttpError(404, "channel_thread_not_found", `Channel thread not found: ${turn.threadId}`); + } + const sourceItem = turn.sourceItemId + ? store.getEffectiveConversationItem(turn.sourceItemId) + : store.listConversationItemsForTurn({ turnId }).find((item) => item.kind === "user_message"); + if (!sourceItem || sourceItem.threadId !== thread.id || sourceItem.turnId !== turn.id) { + throw new HttpError(409, "channel_turn_source_missing", `Channel turn has no effective source item: ${turnId}`); + } + return { turn, thread, sourceItem }; +} + +function parseContextCorrection(value: unknown): CorrectChannelTurnContextRequest { + if (!isRecord(value)) throw new Error("Context correction body must be a JSON object"); + const allowed = new Set(["segmentId", "expectedAssignmentRevision"]); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new Error(`Context correction contains unsupported field: ${key}`); + } + const segmentId = readString(value, "segmentId"); + const expectedAssignmentRevision = value.expectedAssignmentRevision; + if (!Number.isSafeInteger(expectedAssignmentRevision) || Number(expectedAssignmentRevision) < 1) { + throw new Error("expectedAssignmentRevision must be a positive safe integer"); + } + return { segmentId, expectedAssignmentRevision: Number(expectedAssignmentRevision) }; +} diff --git a/packages/daemon/src/features/channels/channel-conversation-api.ts b/packages/daemon/src/features/channels/channel-conversation-api.ts new file mode 100644 index 000000000..562887716 --- /dev/null +++ b/packages/daemon/src/features/channels/channel-conversation-api.ts @@ -0,0 +1,189 @@ +import type { + ChannelConversationItemDecoration, + ChannelConversationItemPageResponse, + ChannelConversationThread, + ChannelConversationTurnResponse, + ConversationItemV1, + LingTurn, +} from "@ling-stack/contracts"; +import type { Channel, ChannelMessage, RunStore } from "@ling-stack/core"; +import { + filterSupersededChannelMessages, + projectChannelMessageRevisionMetadata, + buildConversationSupersessionProjection, +} from "../conversations/supersession-projection.js"; +import { + enrichChannelMessageWithWebEvidence, + enrichChannelMessagesWithWebEvidence, +} from "../web/message-web-evidence.js"; +import { filterQueuedChannelReplyMessages } from "./channel-reply-message-visibility.js"; +import type { ChannelReplyDispatchCoordinator } from "./channel-reply-dispatch-coordinator.js"; +import { getChannelMessageView } from "./conversation-item-messages.js"; + +export function listChannelConversationItems(input: { + readonly store: RunStore; + readonly cwd: string; + readonly channel: Channel; + readonly thread: ChannelConversationThread; + readonly beforeSequence?: number; + readonly limit?: number; + readonly replyDispatchCoordinator?: ChannelReplyDispatchCoordinator; +}): ChannelConversationItemPageResponse { + assertChannelThread(input.channel.id, input.thread); + const page = input.store.listConversationItemsPage({ + threadId: input.thread.id, + ...(input.beforeSequence !== undefined ? { beforeSequence: input.beforeSequence } : {}), + effectiveOnly: true, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }); + const visibleItems = filterVisibleTimelineItems({ + store: input.store, + channelId: input.channel.id, + items: page.items, + replyDispatchCoordinator: input.replyDispatchCoordinator, + }); + return { + cwd: input.cwd, + channelId: input.channel.id, + thread: input.thread, + turns: turnsForItems(input.store, visibleItems), + items: visibleItems, + decorations: decorationsForItems(input.store, visibleItems), + hasMore: page.nextBeforeSequence !== undefined, + ...(page.nextBeforeSequence !== undefined ? { nextBeforeSequence: page.nextBeforeSequence } : {}), + }; +} + +export function buildChannelConversationTurnResponse(input: { + readonly store: RunStore; + readonly userItemId: string; + readonly assistantItemIds?: readonly string[]; + readonly replyDispatch?: ChannelConversationTurnResponse["replyDispatch"]; + readonly autoStartedWorkflow?: ChannelConversationTurnResponse["autoStartedWorkflow"]; +}): ChannelConversationTurnResponse { + const userItem = requiredEffectiveItem(input.store, input.userItemId); + if (!userItem.channelId || !userItem.turnId || userItem.kind !== "user_message") { + throw new Error(`Channel turn source is not a user conversation item: ${input.userItemId}`); + } + const turn = input.store.getLingTurn(userItem.turnId); + const thread = input.store.getLingThread(userItem.threadId); + if (!turn || !thread) { + throw new Error(`Channel turn lineage is incomplete for item: ${input.userItemId}`); + } + const assistantItems = uniqueStrings(input.assistantItemIds ?? []) + .map((itemId) => requiredEffectiveItem(input.store, itemId)) + .filter((item) => item.turnId === turn.id && item.kind === "assistant_message"); + const items = [userItem, ...assistantItems]; + return { + thread, + turn, + userItem, + ...(assistantItems.length > 0 ? { assistantItems } : {}), + decorations: decorationsForItems(input.store, items), + ...(input.replyDispatch ? { replyDispatch: input.replyDispatch } : {}), + ...(input.autoStartedWorkflow ? { autoStartedWorkflow: input.autoStartedWorkflow } : {}), + }; +} + +export function buildChannelConversationItemDecoration( + store: RunStore, + itemId: string, +): ChannelConversationItemDecoration | undefined { + const message = getChannelMessageView(store, itemId); + if (!message) return undefined; + return decorationFromMessage( + enrichChannelMessageWithWebEvidence( + store, + projectChannelMessageRevisionMetadata(store, message), + ), + ); +} + +function filterVisibleTimelineItems(input: { + readonly store: RunStore; + readonly channelId: string; + readonly items: readonly ConversationItemV1[]; + readonly replyDispatchCoordinator?: ChannelReplyDispatchCoordinator; +}): readonly ConversationItemV1[] { + const messageItems = input.items.filter(isMessageLikeItem); + const messages = messageItems + .map((item) => getChannelMessageView(input.store, item.id)) + .filter((message): message is ChannelMessage => Boolean(message)); + const visibleMessages = filterQueuedChannelReplyMessages( + input.store, + filterSupersededChannelMessages( + messages, + buildConversationSupersessionProjection(input.store, { + surfaces: ["channel_root", "channel_branch", "topic_reply"], + ownerId: input.channelId, + }), + ), + (message) => Boolean(input.replyDispatchCoordinator?.isMessageClassificationPending( + message.workspaceId, + message.id, + )), + ); + const visibleMessageIds = new Set(visibleMessages.map((message) => message.id)); + return input.items.filter((item) => !isMessageLikeItem(item) || visibleMessageIds.has(item.id)); +} + +function decorationsForItems( + store: RunStore, + items: readonly ConversationItemV1[], +): readonly ChannelConversationItemDecoration[] { + const messages = items + .filter(isMessageLikeItem) + .map((item) => getChannelMessageView(store, item.id)) + .filter((message): message is ChannelMessage => Boolean(message)) + .map((message) => projectChannelMessageRevisionMetadata(store, message)); + return enrichChannelMessagesWithWebEvidence(store, messages).map(decorationFromMessage); +} + +function decorationFromMessage(message: ChannelMessage): ChannelConversationItemDecoration { + return { + itemId: message.id, + ...(message.linkedRunId ? { linkedRunId: message.linkedRunId } : {}), + ...(message.editedAt ? { editedAt: message.editedAt } : {}), + ...(message.pendingReplacement ? { pendingReplacement: message.pendingReplacement } : {}), + ...(message.webEvidence ? { webEvidence: message.webEvidence } : {}), + }; +} + +function turnsForItems( + store: RunStore, + items: readonly ConversationItemV1[], +): readonly LingTurn[] { + const turns: LingTurn[] = []; + const seen = new Set(); + for (const item of items) { + if (!item.turnId || seen.has(item.turnId)) continue; + const turn = store.getLingTurn(item.turnId); + if (!turn) throw new Error(`Conversation item references a missing turn: ${item.id}`); + seen.add(turn.id); + turns.push(turn); + } + return turns; +} + +function requiredEffectiveItem( + store: RunStore, + itemId: string, +): ConversationItemV1 { + const item = store.getEffectiveConversationItem(itemId); + if (!item) throw new Error(`Conversation item not found: ${itemId}`); + return item; +} + +function assertChannelThread(channelId: string, thread: ChannelConversationThread): void { + if (thread.channelId !== channelId) { + throw new Error(`Conversation thread does not belong to channel: ${thread.id}`); + } +} + +function isMessageLikeItem(item: ConversationItemV1): boolean { + return item.kind === "user_message" || item.kind === "assistant_message" || item.kind === "system_event"; +} + +function uniqueStrings(values: readonly string[]): readonly string[] { + return [...new Set(values)]; +} diff --git a/packages/daemon/src/features/channels/context-processing/conversation-context-projector.ts b/packages/daemon/src/features/channels/context-processing/conversation-context-projector.ts index d5a7f5c52..5c1aaf2ad 100644 --- a/packages/daemon/src/features/channels/context-processing/conversation-context-projector.ts +++ b/packages/daemon/src/features/channels/context-processing/conversation-context-projector.ts @@ -123,12 +123,7 @@ export function buildConversationContextProjection( }); const explicitTurnItems = uniqueItems(explicitItems.flatMap((item) => item.turnId ? input.store.listConversationItemsForTurn({ turnId: item.turnId }) : [item])) - .map((item) => { - if (!canProjectItem(item, input.view, input.consumerTeammateId)) { - throw new Error(`Conversation projection explicit turn item is not visible to this consumer: ${item.id}`); - } - return item; - }); + .filter((item) => canProjectItem(item, input.view, input.consumerTeammateId)); const explicitTurnItemIds = new Set(explicitTurnItems.map((item) => item.id)); const inheritedItems = forkSnapshot ? forkSnapshot.sourceRefs diff --git a/packages/daemon/src/features/channels/conversation-thread-resolution.ts b/packages/daemon/src/features/channels/conversation-thread-resolution.ts index aea793dc8..c678be2a4 100644 --- a/packages/daemon/src/features/channels/conversation-thread-resolution.ts +++ b/packages/daemon/src/features/channels/conversation-thread-resolution.ts @@ -1,12 +1,13 @@ +import type { ChannelConversationSurface } from "@ling-stack/contracts"; import type { Channel, LingThread, RunStore } from "@ling-stack/core"; export function resolveChannelConversationThread(input: { readonly store: RunStore; readonly channel: Channel; - readonly surfaceRefId?: string; + readonly surface?: ChannelConversationSurface; readonly now: string; }): LingThread { - if (!input.surfaceRefId) { + if (!input.surface || input.surface.kind === "channel_root") { return input.store.resolveOrCreateConversationThread({ surfaceKind: "channel_root", channelId: input.channel.id, @@ -15,17 +16,20 @@ export function resolveChannelConversationThread(input: { }, input.now).thread; } - const explicitThread = input.store.getLingThread(input.surfaceRefId); - if (explicitThread) { + if (input.surface.kind === "thread") { + const explicitThread = input.store.getLingThread(input.surface.threadId); + if (!explicitThread) { + throw new Error(`Conversation thread not found: ${input.surface.threadId}`); + } if (explicitThread.channelId !== input.channel.id || explicitThread.archivedAt || explicitThread.status === "archived") { - throw new Error(`Conversation thread is not active in channel: ${input.surfaceRefId}`); + throw new Error(`Conversation thread is not active in channel: ${input.surface.threadId}`); } return explicitThread; } - const anchor = input.store.getConversationItem(input.surfaceRefId); + const anchor = input.store.getConversationItem(input.surface.anchorItemId); if (!anchor || anchor.channelId !== input.channel.id) { - throw new Error(`Conversation surface reference does not belong to channel: ${input.surfaceRefId}`); + throw new Error(`Conversation topic anchor does not belong to channel: ${input.surface.anchorItemId}`); } return input.store.resolveOrCreateConversationThread({ surfaceKind: "topic", diff --git a/packages/daemon/src/features/channels/messages.ts b/packages/daemon/src/features/channels/messages.ts index b1b7fdfd2..3d31465a6 100644 --- a/packages/daemon/src/features/channels/messages.ts +++ b/packages/daemon/src/features/channels/messages.ts @@ -1,6 +1,12 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { randomUUID } from "node:crypto"; -import type { ChannelMessageStreamReplyPendingEvent, ChannelReplyDispatch, ChannelReplyDisposition } from "@ling-stack/contracts"; +import type { + ChannelConversationSurface, + ChannelReplyPendingEvent, + ChannelReplyDispatch, + ChannelReplyDisposition, + ChannelWorkflowOrchestrationResponse, +} from "@ling-stack/contracts"; import { projectConversationItemToChannelMessage, type Channel, @@ -37,27 +43,22 @@ import { } from "./channel-reply-dispatch-lane.js"; import { resolveChannelConversationThread } from "./conversation-thread-resolution.js"; import { - listChannelMessageViewsPage, - updatePendingAssistantMessage, -} from "./conversation-item-messages.js"; + buildChannelConversationTurnResponse, + listChannelConversationItems, +} from "./channel-conversation-api.js"; +import { updatePendingAssistantMessage } from "./conversation-item-messages.js"; import { assignContextSegmentForTurn } from "./context-processing/context-segmentation-policy.js"; import { createModelContextSegmentProposer } from "./context-processing/context-segmentation-model.js"; import { buildConversationContextProjection } from "./context-processing/conversation-context-projector.js"; -import { filterQueuedChannelReplyMessages } from "./channel-reply-message-visibility.js"; import { materializeInlineChannelInputs } from "./materialization/inline.js"; import { createProviderBackedImageVisionSummarizer } from "./materialization/provider-vision.js"; import type { LingImageVisionSummarizer } from "./materialization/registry.js"; import { validateChannelMessageReferences } from "./protocol.js"; -import { parseChannelMessageRequest } from "./schemas.js"; +import { parseChannelTurnRequest } from "./schemas.js"; import { enrichChannelMessageWithWebEvidence, enrichChannelMessagesWithWebEvidence, } from "../web/message-web-evidence.js"; -import { - buildConversationSupersessionProjection, - filterSupersededChannelMessages, - projectChannelMessageRevisionMetadata, -} from "../conversations/supersession-projection.js"; import { projectConversationActiveReplyTarget } from "../conversations/active-reply-target.js"; import { resolveChannelReplyExecutionTarget } from "./channel-reply-execution-target.js"; import { resolveProviderContextBudget } from "./context-packs.js"; @@ -79,7 +80,7 @@ export type StartChannelExecutionProposalWorkflow = (input: { readonly proposal: ChannelExecutionProposal; readonly workflow: ChannelWorkflow; readonly workflowSteps: readonly ChannelWorkflowStep[]; - readonly orchestration?: unknown; + readonly orchestration?: ChannelWorkflowOrchestrationResponse; }>; interface LingAgentChannelMessageInput { @@ -98,10 +99,10 @@ interface LingAgentChannelMessageInput { readonly routeDependencies: Omit; readonly imageVisionSummarizer?: LingImageVisionSummarizer; readonly replyDispatchCoordinator?: ChannelReplyDispatchCoordinator; - readonly onReplyPending?: (pending: ChannelMessageStreamReplyPendingEvent) => void; + readonly onReplyPending?: (pending: ChannelReplyPendingEvent) => void; } -export async function handleChannelMessageStream( +export async function handleChannelTurnStream( request: IncomingMessage, response: ServerResponse, config: ResolvedDaemonConfig, @@ -128,12 +129,12 @@ export async function handleChannelMessageStream( return; } if (request.method !== "POST") { - writeJson(response, 405, { error: "method_not_allowed", message: `${request.method} is not supported for channel message stream` }); + writeJson(response, 405, { error: "method_not_allowed", message: `${request.method} is not supported for channel turn stream` }); return; } const body = await readJsonBody(request, config.maxBodyBytes); - const input = parseChannelMessageRequest(body); + const input = parseChannelTurnRequest(body); const referenceError = validateChannelMessageReferences(store, channelId, input.attachmentIds, input.linkPreviewIds); if (referenceError) { writeJson(response, referenceError.statusCode, { @@ -172,7 +173,10 @@ export async function handleChannelMessageStream( }; writeSseHead(response); - safeWriteSse("ready", { message }); + safeWriteSse("ready", buildChannelConversationTurnResponse({ + store, + userItemId: message.id, + })); try { const result = await handleLingAgentChannelMessage({ store, @@ -190,7 +194,11 @@ export async function handleChannelMessageStream( stream: { onReady: (aiReply) => { const aiReplies = upsertAiReply(aiReply); - safeWriteSse("ready", { message, aiReply, aiReplies }); + safeWriteSse("ready", buildChannelConversationTurnResponse({ + store, + userItemId: message.id, + assistantItemIds: aiReplies.map((reply) => reply.id), + })); }, onRunLinked: (aiReply) => { upsertAiReply(aiReply); @@ -202,7 +210,7 @@ export async function handleChannelMessageStream( teammateId: aiReply.authorType === "ai-teammate" ? aiReply.authorId : undefined, }); safeWriteSse("run_linked", { - messageId: aiReply.id, + itemId: aiReply.id, linkedRunId: aiReply.linkedRunId, ...(activeReply ? { activeReply } : {}), }); @@ -210,7 +218,7 @@ export async function handleChannelMessageStream( }, onDelta: (aiReply, delta) => { upsertAiReply(aiReply); - safeWriteSse("delta", { messageId: aiReply.id, delta, body: aiReply.body }); + safeWriteSse("delta", { itemId: aiReply.id, delta, body: aiReply.body }); }, onDone: (aiReply) => { upsertAiReply(enrichChannelMessageWithWebEvidence(store, aiReply)); @@ -227,7 +235,11 @@ export async function handleChannelMessageStream( replyDispatchCoordinator, onReplyPending: (pending) => { releaseMessageClassification(); - safeWriteSse("reply_pending", pending); + safeWriteSse("reply_pending", { + sourceItemId: pending.sourceMessageId, + targetTeammateId: pending.targetTeammateId, + startedAt: pending.startedAt, + }); }, }); releaseMessageClassification(); @@ -247,16 +259,13 @@ export async function handleChannelMessageStream( for (const aiReply of aiReplies) { upsertAiReply(aiReply); } - const aiReply = result.aiReply - ? aiReplies.find((reply) => reply.id === result.aiReply?.id) ?? enrichChannelMessageWithWebEvidence(store, result.aiReply) - : undefined; - const streamPayload = { - message, - ...(aiReply ? { aiReply } : {}), - ...(aiReplies.length > 0 ? { aiReplies } : {}), + const streamPayload = buildChannelConversationTurnResponse({ + store, + userItemId: message.id, + assistantItemIds: aiReplies.map((reply) => reply.id), ...(result.replyDispatch ? { replyDispatch: result.replyDispatch } : {}), ...(result.autoStartedWorkflow ? { autoStartedWorkflow: result.autoStartedWorkflow } : {}), - }; + }); const failedReply = aiReplies.find((reply) => reply.status === "failed"); const blockedReply = aiReplies.find((reply) => reply.status === "blocked"); if (failedReply) { @@ -270,9 +279,11 @@ export async function handleChannelMessageStream( releaseMessageClassification(); const aiReplies = Array.from(aiRepliesById.values()); safeWriteSse("error", { - message, - ...(aiReplies[0] ? { aiReply: aiReplies[0] } : {}), - ...(aiReplies.length > 0 ? { aiReplies } : {}), + ...buildChannelConversationTurnResponse({ + store, + userItemId: message.id, + assistantItemIds: aiReplies.map((reply) => reply.id), + }), error: error instanceof Error ? error.message : String(error), }); } finally { @@ -301,7 +312,55 @@ interface LingAgentChannelMessageResult { readonly autoStartedWorkflow?: Awaited>; } -export async function handleChannelMessagesCollection( +export async function handleChannelItemsCollection( + request: IncomingMessage, + response: ServerResponse, + config: ResolvedDaemonConfig, + storeRegistry: RunStoreRegistry, + channelId: string, + replyDispatchCoordinator?: ChannelReplyDispatchCoordinator, +): Promise { + const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${config.host}:${config.port}`}`); + const cwd = url.searchParams.get("cwd"); + if (!cwd) { + writeJson(response, 400, { error: "bad_request", message: "cwd query parameter is required" }); + return; + } + try { + const store = storeRegistry.getStore(cwd); + const channel = store.getChannel(channelId); + if (!channel) { + writeJson(response, 404, { error: "channel_not_found", message: `Channel not found: ${channelId}` }); + return; + } + if (request.method !== "GET") { + writeJson(response, 405, { error: "method_not_allowed", message: `${request.method} is not supported for channel conversation items` }); + return; + } + const thread = resolveChannelConversationThread({ + store, + channel, + surface: readChannelConversationSurfaceQuery(url), + now: new Date().toISOString(), + }); + writeJson(response, 200, listChannelConversationItems({ + store, + cwd, + channel, + thread, + beforeSequence: readOptionalIntegerQuery(url, "beforeSequence", 1), + limit: readOptionalIntegerQuery(url, "limit", 1), + replyDispatchCoordinator, + })); + } catch (error) { + writeJson(response, error instanceof HttpError ? error.statusCode : 400, { + error: error instanceof HttpError ? error.code : "bad_request", + message: error instanceof Error ? error.message : String(error), + }); + } +} + +export async function handleChannelTurnsCollection( request: IncomingMessage, response: ServerResponse, config: ResolvedDaemonConfig, @@ -328,51 +387,9 @@ export async function handleChannelMessagesCollection( return; } - if (request.method === "GET") { - const thread = resolveChannelConversationThread({ - store, - channel, - surfaceRefId: url.searchParams.get("threadId") ?? undefined, - now: new Date().toISOString(), - }); - const page = listChannelMessageViewsPage({ - store, - channelId, - threadId: thread.id, - beforeItemId: url.searchParams.get("beforeMessageId") ?? undefined, - limit: readOptionalIntegerQuery(url, "limit", 1), - }); - const visibleMessages = url.searchParams.get("includeSuperseded") === "true" - ? page.messages - : filterSupersededChannelMessages(page.messages, buildConversationSupersessionProjection(store, { - surfaces: ["channel_root", "channel_branch", "topic_reply"], - ownerId: channelId, - })); - const messages = enrichChannelMessagesWithWebEvidence( - store, - filterQueuedChannelReplyMessages( - store, - visibleMessages, - (message) => Boolean(replyDispatchCoordinator?.isMessageClassificationPending( - message.workspaceId, - message.id, - )), - ) - .map((message) => projectChannelMessageRevisionMetadata(store, message)), - ); - writeJson(response, 200, { - cwd, - channelId, - messages, - hasMore: page.hasMore, - nextBeforeMessageId: page.nextBeforeMessageId, - }); - return; - } - if (request.method === "POST") { const body = await readJsonBody(request, config.maxBodyBytes); - const input = parseChannelMessageRequest(body); + const input = parseChannelTurnRequest(body); const referenceError = validateChannelMessageReferences(store, channelId, input.attachmentIds, input.linkPreviewIds); if (referenceError) { writeJson(response, referenceError.statusCode, { @@ -421,20 +438,22 @@ export async function handleChannelMessagesCollection( replyMessageId: result.aiReply.id, }, new Date().toISOString()); } - const aiReply = result.aiReply ? enrichChannelMessageWithWebEvidence(store, result.aiReply) : undefined; - const aiReplies = result.aiReplies ? enrichChannelMessagesWithWebEvidence(store, result.aiReplies) : undefined; - - writeJson(response, 201, { - message, - ...(aiReply ? { aiReply } : {}), - ...(aiReplies ? { aiReplies } : {}), + const aiReplies = result.aiReplies?.length + ? result.aiReplies + : result.aiReply + ? [result.aiReply] + : []; + writeJson(response, 201, buildChannelConversationTurnResponse({ + store, + userItemId: message.id, + assistantItemIds: aiReplies.map((reply) => reply.id), ...(result.replyDispatch ? { replyDispatch: result.replyDispatch } : {}), ...(result.autoStartedWorkflow ? { autoStartedWorkflow: result.autoStartedWorkflow } : {}), - }); + })); return; } - writeJson(response, 405, { error: "method_not_allowed", message: `${request.method} is not supported for channel messages` }); + writeJson(response, 405, { error: "method_not_allowed", message: `${request.method} is not supported for channel turns` }); } catch (error) { writeJson(response, error instanceof HttpError ? error.statusCode : 400, { error: error instanceof HttpError ? error.code : "bad_request", @@ -921,8 +940,8 @@ function hasChannelReplyPayload(message: ChannelMessage, turnEnvelope: LingAgent } function notifyReplyPending( - callback: ((pending: ChannelMessageStreamReplyPendingEvent) => void) | undefined, - pending: ChannelMessageStreamReplyPendingEvent, + callback: ((pending: ChannelReplyPendingEvent) => void) | undefined, + pending: ChannelReplyPendingEvent, ): void { try { callback?.(pending); @@ -934,31 +953,51 @@ function notifyReplyPending( function persistChannelUserMessage(input: { readonly store: RunStore; readonly channel: Channel; - readonly input: import("./schemas.js").ChannelMessageRequest; + readonly input: import("./schemas.js").ChannelTurnRequest; readonly requestIdempotencyKey?: string; readonly now: string; }): ChannelMessage { - if (input.input.authorType !== "local-user" || (input.input.kind && input.input.kind !== "message")) { - throw new Error("Channel message ingest accepts local-user messages only"); - } const thread = resolveChannelConversationThread({ store: input.store, channel: input.channel, - surfaceRefId: input.input.threadId, + surface: input.input.surface, now: input.now, }); - const anchor = input.input.threadId ? input.store.getConversationItem(input.input.threadId) : undefined; + const anchor = input.input.surface?.kind === "topic" + ? input.store.getConversationItem(input.input.surface.anchorItemId) + : undefined; + const requestedActiveRunId = input.input.replyDisposition?.mode === "queue_followup" + ? input.input.replyDisposition.activeRunId + : undefined; + const requestedActiveRun = requestedActiveRunId + ? input.store.getRun(requestedActiveRunId) + : undefined; + const requestedActiveWorkUnit = requestedActiveRun?.workUnitId + ? input.store.getWorkUnit(requestedActiveRun.workUnitId) + : undefined; + const requestedSourceTurn = requestedActiveRun + ? input.store.getLingTurnForRun(requestedActiveRun.id) + : undefined; + const validatedFollowupSourceTurn = requestedActiveRun?.channelId === input.channel.id + && requestedActiveRun.threadId === thread.id + && (requestedActiveRun.status === "queued" + || requestedActiveRun.status === "running" + || requestedActiveRun.status === "waiting_for_approval") + && requestedActiveWorkUnit?.kind === "channel_ai_reply" + && requestedActiveWorkUnit.linkedRunId === requestedActiveRun.id + && requestedSourceTurn?.threadId === thread.id + ? requestedSourceTurn + : undefined; const result = input.store.appendConversationUserTurn({ channelId: input.channel.id, threadId: thread.id, - turnKind: input.input.replyDisposition?.mode === "queue_followup" + turnKind: validatedFollowupSourceTurn ? "queued_followup" : thread.nextItemSequence > 1 ? "manual_followup" : "initial_request", title: channelTurnTitle(input.input.body), userInstruction: input.input.body || "Channel input with materialized references.", - ...(input.input.authorId ? { actorId: input.input.authorId } : {}), payload: { body: input.input.body, format: "plain_text", @@ -976,12 +1015,9 @@ function persistChannelUserMessage(input: { fileRefs: input.input.fileRefs, ...(anchor ? { replyToItemId: anchor.id } : {}), }, - domainRefs: [ - ...(input.input.linkedTaskId ? [{ type: "task" as const, id: input.input.linkedTaskId }] : []), - ...(input.input.linkedRunId ? [{ type: "run" as const, id: input.input.linkedRunId }] : []), - ], - ...(input.input.replyDisposition?.activeRunId && input.store.getLingTurnForRun(input.input.replyDisposition.activeRunId) - ? { sourceTurnId: input.store.getLingTurnForRun(input.input.replyDisposition.activeRunId)!.id } + domainRefs: [], + ...(validatedFollowupSourceTurn + ? { sourceTurnId: validatedFollowupSourceTurn.id } : {}), idempotencyKey: input.input.idempotencyKey ?? input.requestIdempotencyKey @@ -990,6 +1026,23 @@ function persistChannelUserMessage(input: { return projectConversationItemToChannelMessage(result.item); } +function readChannelConversationSurfaceQuery(url: URL): ChannelConversationSurface | undefined { + const threadId = url.searchParams.get("threadId"); + const anchorItemId = url.searchParams.get("anchorItemId"); + if (threadId !== null && anchorItemId !== null) { + throw new Error("threadId and anchorItemId cannot be used together"); + } + if (threadId !== null) { + if (!threadId.trim()) throw new Error("threadId must be a non-empty string"); + return { kind: "thread", threadId }; + } + if (anchorItemId !== null) { + if (!anchorItemId.trim()) throw new Error("anchorItemId must be a non-empty string"); + return { kind: "topic", anchorItemId }; + } + return undefined; +} + function readIdempotencyKey(request: IncomingMessage): string | undefined { const value = request.headers["idempotency-key"]; return Array.isArray(value) ? value[0] : value; diff --git a/packages/daemon/src/features/channels/schemas.ts b/packages/daemon/src/features/channels/schemas.ts index 1c1c04c4a..d5b11d74a 100644 --- a/packages/daemon/src/features/channels/schemas.ts +++ b/packages/daemon/src/features/channels/schemas.ts @@ -1,4 +1,4 @@ -import type { ChannelReplyDisposition } from "@ling-stack/contracts"; +import type { ChannelConversationSurface, ChannelReplyDisposition } from "@ling-stack/contracts"; import { channelDecisionStatuses, channelContextSummaryAuthorTypes, @@ -29,9 +29,6 @@ import { type ChannelIntentDecisionAction, type ChannelIntentDecisionStatus, type ChannelMention, - type ChannelMessageAuthorType, - type ChannelMessageIntent, - type ChannelMessageKind, type ChannelMessageRouteDecisionStatus, type ChannelProtocolSourceRef, type ChannelWorkspaceBinding, @@ -166,21 +163,15 @@ export interface ChannelAttachmentRequest { readonly messageId?: string; } -export interface ChannelMessageRequest { +export interface ChannelTurnRequest { readonly idempotencyKey?: string; - readonly threadId?: string; + readonly surface?: ChannelConversationSurface; readonly contextSegmentId?: string; - readonly kind?: ChannelMessageKind; - readonly intent?: ChannelMessageIntent; - readonly authorType: ChannelMessageAuthorType; - readonly authorId?: string; readonly body: string; readonly mentions: readonly ChannelMention[]; readonly attachmentIds: readonly string[]; readonly linkPreviewIds: readonly string[]; readonly fileRefs: readonly ChannelMessageFileRef[]; - readonly linkedTaskId?: string; - readonly linkedRunId?: string; readonly requestAiReply: boolean; readonly replyDisposition?: ChannelReplyDisposition; readonly providerPresetId?: string; @@ -581,25 +572,41 @@ export function parseChannelAttachmentRequest(value: unknown): ChannelAttachment return input; } -export function parseChannelMessageRequest(value: unknown): ChannelMessageRequest { +export function parseChannelTurnRequest(value: unknown): ChannelTurnRequest { if (!isRecord(value)) { - throw new Error("Channel message request must be a JSON object"); + throw new Error("Channel turn request must be a JSON object"); + } + if (value.threadId !== undefined) { + throw new Error("threadId is not supported on channel turn requests; use surface.threadId or surface.anchorItemId"); + } + const allowed = new Set([ + "idempotencyKey", + "surface", + "contextSegmentId", + "body", + "mentions", + "attachmentIds", + "linkPreviewIds", + "fileRefs", + "requestAiReply", + "replyDisposition", + "providerPresetId", + "teammateId", + ]); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + throw new Error(`Channel turn request contains unsupported field: ${key}`); + } } - const input: ChannelMessageRequest = { + const input: ChannelTurnRequest = { idempotencyKey: readOptionalString(value, "idempotencyKey"), - threadId: readOptionalString(value, "threadId"), + surface: readOptionalChannelConversationSurface(value), contextSegmentId: readOptionalString(value, "contextSegmentId"), - kind: readOptionalChannelMessageKind(value, "kind"), - intent: readOptionalChannelMessageIntent(value, "intent") ?? "discuss", - authorType: readOptionalChannelMessageAuthorType(value, "authorType") ?? "local-user", - authorId: readOptionalString(value, "authorId"), body: readStringAllowEmpty(value, "body"), mentions: readOptionalChannelMentions(value, "mentions") ?? [], attachmentIds: readOptionalStringArray(value, "attachmentIds") ?? [], linkPreviewIds: readOptionalStringArray(value, "linkPreviewIds") ?? [], fileRefs: readOptionalChannelMessageFileRefs(value, "fileRefs") ?? [], - linkedTaskId: readOptionalString(value, "linkedTaskId"), - linkedRunId: readOptionalString(value, "linkedRunId"), requestAiReply: readOptionalBoolean(value, "requestAiReply") ?? false, replyDisposition: readOptionalChannelReplyDisposition(value), providerPresetId: readOptionalString(value, "providerPresetId"), @@ -617,6 +624,48 @@ export function parseChannelMessageRequest(value: unknown): ChannelMessageReques return input; } +function readOptionalChannelConversationSurface(value: Record): ChannelConversationSurface | undefined { + const surface = value.surface; + if (surface === undefined) { + return undefined; + } + if (!isRecord(surface)) { + throw new Error("surface must be a JSON object"); + } + const kind = readString(surface, "kind"); + if (kind === "channel_root") { + assertOnlyChannelSurfaceKeys(surface, ["kind"]); + return { kind }; + } + if (kind === "thread") { + assertOnlyChannelSurfaceKeys(surface, ["kind", "threadId"]); + const threadId = readString(surface, "threadId").trim(); + if (!threadId) { + throw new Error("surface.threadId must be a non-empty string"); + } + return { kind, threadId }; + } + if (kind === "topic") { + assertOnlyChannelSurfaceKeys(surface, ["kind", "anchorItemId"]); + const anchorItemId = readString(surface, "anchorItemId").trim(); + if (!anchorItemId) { + throw new Error("surface.anchorItemId must be a non-empty string"); + } + return { kind, anchorItemId }; + } + throw new Error("surface.kind must be channel_root, thread, or topic"); +} + +function assertOnlyChannelSurfaceKeys( + surface: Record, + allowedKeys: readonly string[], +): void { + const allowed = new Set(allowedKeys); + for (const key of Object.keys(surface)) { + if (!allowed.has(key)) throw new Error(`surface contains unsupported field: ${key}`); + } +} + function readOptionalChannelReplyDisposition(value: Record): ChannelReplyDisposition | undefined { const disposition = value.replyDisposition; if (disposition === undefined) { @@ -1046,39 +1095,6 @@ function readChannelWorkflowStepStartMode(record: Record, key: return value; } -function readOptionalChannelMessageKind(record: Record, key: string): ChannelMessageKind | undefined { - const value = readOptionalString(record, key); - if (value === undefined) { - return undefined; - } - if (value !== "message" && value !== "ai_reply" && value !== "system" && value !== "task_link") { - throw new Error(`${key} must be one of: message, ai_reply, system, task_link`); - } - return value; -} - -function readOptionalChannelMessageIntent(record: Record, key: string): ChannelMessageIntent | undefined { - const value = readOptionalString(record, key); - if (value === undefined) { - return undefined; - } - if (value !== "discuss" && value !== "task") { - throw new Error(`${key} must be one of: discuss, task`); - } - return value; -} - -function readOptionalChannelMessageAuthorType(record: Record, key: string): ChannelMessageAuthorType | undefined { - const value = readOptionalString(record, key); - if (value === undefined) { - return undefined; - } - if (value !== "local-user" && value !== "ai-teammate" && value !== "system") { - throw new Error(`${key} must be one of: local-user, ai-teammate, system`); - } - return value; -} - function readOptionalChannelMentions(record: Record, key: string): ChannelMention[] | undefined { const value = record[key]; if (value === undefined) { diff --git a/packages/daemon/src/server/router.ts b/packages/daemon/src/server/router.ts index 69acfcdd3..c20277f26 100644 --- a/packages/daemon/src/server/router.ts +++ b/packages/daemon/src/server/router.ts @@ -35,7 +35,13 @@ import { getChannelMessageView } from "../features/channels/conversation-item-me import { handleChannelCollaborationProtocol } from "../features/channels/collaboration-protocol.js"; import { handleChannelPlaybookCandidates } from "../features/channels/playbooks.js"; import { handleChannelLinkPreviewItem, handleChannelLinkPreviewsCollection } from "../features/channels/link-previews.js"; -import { handleChannelMessagesCollection, handleChannelMessageStream, handleLingAgentChannelMessage } from "../features/channels/messages.js"; +import { + handleChannelItemsCollection, + handleChannelTurnsCollection, + handleChannelTurnStream, + handleLingAgentChannelMessage, +} from "../features/channels/messages.js"; +import { handleChannelTurnContext } from "../features/channels/channel-context-inspect.js"; import { confirmChannelExecutionProposalWorkflow } from "../features/channels/proposal-workflows.js"; import { handleChannelTeamExpertsProjection } from "../features/channels/team-experts.js"; import { handleChannelTeamMetricsProjection } from "../features/channels/team-metrics.js"; @@ -1969,9 +1975,36 @@ export async function routeRequest( await handleChannelProposedPlans(request, response, config, storeRegistry, channelRoute.channelId); return; } - if (channelRoute.action === "messages") { - const channelMessagesCwd = url.searchParams.get("cwd") ?? ""; - await handleChannelMessagesCollection(request, response, config, storeRegistry, channelRoute.channelId, (input) => + if (channelRoute.action === "items") { + await handleChannelItemsCollection( + request, + response, + config, + storeRegistry, + channelRoute.channelId, + channelReplyDispatchCoordinator, + ); + return; + } + if (channelRoute.action === "turn-context" || channelRoute.action === "turn-context-correction") { + if (!channelRoute.turnId) { + writeJson(response, 400, { error: "bad_request", message: "turn id is required" }); + return; + } + await handleChannelTurnContext( + request, + response, + config, + storeRegistry, + channelRoute.channelId, + channelRoute.turnId, + channelRoute.action === "turn-context-correction", + ); + return; + } + if (channelRoute.action === "turns") { + const channelTurnsCwd = url.searchParams.get("cwd") ?? ""; + await handleChannelTurnsCollection(request, response, config, storeRegistry, channelRoute.channelId, (input) => createAiChannelReply({ startRun: startTeammateDirectRun, secretResolver, @@ -1983,7 +2016,7 @@ export async function routeRequest( }), (input) => confirmAndOrchestrateChannelProposal({ store: input.store, - cwd: channelMessagesCwd, + cwd: channelTurnsCwd, proposalId: input.proposalId, }), { @@ -1996,9 +2029,9 @@ export async function routeRequest( ); return; } - if (channelRoute.action === "messages-stream") { - const channelMessagesCwd = url.searchParams.get("cwd") ?? ""; - await handleChannelMessageStream(request, response, config, storeRegistry, channelRoute.channelId, (input) => + if (channelRoute.action === "turns-stream") { + const channelTurnsCwd = url.searchParams.get("cwd") ?? ""; + await handleChannelTurnStream(request, response, config, storeRegistry, channelRoute.channelId, (input) => createAiChannelReply({ startRun: startTeammateDirectRun, secretResolver, @@ -2010,7 +2043,7 @@ export async function routeRequest( }), (input) => confirmAndOrchestrateChannelProposal({ store: input.store, - cwd: channelMessagesCwd, + cwd: channelTurnsCwd, proposalId: input.proposalId, }), { diff --git a/packages/daemon/src/server/routes.ts b/packages/daemon/src/server/routes.ts index b4c69fe5c..721f2db1d 100644 --- a/packages/daemon/src/server/routes.ts +++ b/packages/daemon/src/server/routes.ts @@ -351,8 +351,9 @@ export function parseChannelRoute( pathname: string, ): { readonly channelId: string; - readonly action?: "brief" | "messages" | "messages-stream" | "message-route-decisions" | "intent-decisions" | "proposals" | "attachments" | "link-previews" | "decisions" | "handoffs" | "context-summaries" | "workflows" | "collaboration-cards" | "terminal-reports" | "team-experts" | "team-metrics" | "team-authority-ledger" | "team-patterns" | "team-snapshots" | "team-evaluations" | "workspace" | "protocol" | "playbooks" | "branches" | "council-policy" | "effective-council" | "proposed-plans"; + readonly action?: "brief" | "items" | "turns" | "turns-stream" | "turn-context" | "turn-context-correction" | "message-route-decisions" | "intent-decisions" | "proposals" | "attachments" | "link-previews" | "decisions" | "handoffs" | "context-summaries" | "workflows" | "collaboration-cards" | "terminal-reports" | "team-experts" | "team-metrics" | "team-authority-ledger" | "team-patterns" | "team-snapshots" | "team-evaluations" | "workspace" | "protocol" | "playbooks" | "branches" | "council-policy" | "effective-council" | "proposed-plans"; readonly branchId?: string; + readonly turnId?: string; readonly protocolAction?: "revert" | "drafts" | "apply-draft" | "candidates" | "review-candidate" | "candidate-from-cluster" | "canvas"; readonly protocolDraftId?: string; readonly protocolClusterId?: string; @@ -380,8 +381,14 @@ export function parseChannelRoute( if (segments.length === 3 && segments[0] === "channels" && segments[2] === "terminal-reports") { return { channelId: segments[1], action: "terminal-reports" }; } - if (segments.length === 4 && segments[0] === "channels" && segments[2] === "messages" && segments[3] === "stream") { - return { channelId: segments[1], action: "messages-stream" }; + if (segments.length === 4 && segments[0] === "channels" && segments[2] === "turns" && segments[3] === "stream") { + return { channelId: segments[1], action: "turns-stream" }; + } + if (segments.length === 5 && segments[0] === "channels" && segments[2] === "turns" && segments[4] === "context") { + return { channelId: segments[1], action: "turn-context", turnId: segments[3] }; + } + if (segments.length === 6 && segments[0] === "channels" && segments[2] === "turns" && segments[4] === "context" && segments[5] === "corrections") { + return { channelId: segments[1], action: "turn-context-correction", turnId: segments[3] }; } if (segments.length === 4 && segments[0] === "channels" && segments[2] === "branches") { return { channelId: segments[1], action: "branches", branchId: segments[3] }; @@ -429,7 +436,8 @@ export function parseChannelRoute( segments[0] === "channels" && ( segments[2] === "brief" || - segments[2] === "messages" || + segments[2] === "items" || + segments[2] === "turns" || segments[2] === "message-route-decisions" || segments[2] === "intent-decisions" || segments[2] === "proposals" || diff --git a/packages/daemon/test/agent-context-consistency.test.mjs b/packages/daemon/test/agent-context-consistency.test.mjs index c43b923d6..7ffbce5d8 100644 --- a/packages/daemon/test/agent-context-consistency.test.mjs +++ b/packages/daemon/test/agent-context-consistency.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -180,7 +181,7 @@ test("workflow agents share one projection while private role memory stays owner boundPath: fixture.cwd, allowedTools: ["read"], }, "2026-08-10T01:00:45.000Z"); - const otherSource = fixture.store.createChannelMessage({ + const otherSource = appendChannelItemFixture(fixture.store, { channelId: otherChannel.id, authorType: "local-user", body: "Keep this channel isolated.", @@ -589,7 +590,7 @@ async function createFixture() { boundPath: cwd, allowedTools: ["read"], }, now); - const sourceMessage = store.createChannelMessage({ + const sourceMessage = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "Use continuity-agent-memory evidence to complete this objective.", diff --git a/packages/daemon/test/channel-reply-dispatch.test.mjs b/packages/daemon/test/channel-reply-dispatch.test.mjs index 47b79e95c..f5e6aa575 100644 --- a/packages/daemon/test/channel-reply-dispatch.test.mjs +++ b/packages/daemon/test/channel-reply-dispatch.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -124,9 +125,9 @@ function createActiveReplyRun(store, channelId, teammateId, suffix) { const sourceMessage = createMessage(store, channelId, [], { body: `source ${suffix}`, }); - const replyMessage = store.createChannelMessage({ + const replyMessage = appendChannelItemFixture(store, { channelId, - threadId: sourceMessage.id, + replyToItemId: sourceMessage.id, kind: "ai_reply", authorType: "ai-teammate", authorId: teammateId, @@ -167,7 +168,7 @@ function createActiveReplyRun(store, channelId, teammateId, suffix) { } function createMessage(store, channelId, mentions, overrides = {}) { - return store.createChannelMessage({ + return appendChannelItemFixture(store, { channelId, kind: "message", intent: "discuss", diff --git a/packages/daemon/test/channel-team-authority-ledger-api.test.mjs b/packages/daemon/test/channel-team-authority-ledger-api.test.mjs index 81d9ea769..7055136cb 100644 --- a/packages/daemon/test/channel-team-authority-ledger-api.test.mjs +++ b/packages/daemon/test/channel-team-authority-ledger-api.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -27,11 +28,20 @@ test("channel team authority ledger API exposes read-only authority facts", asyn requestSnapshot: { cwd }, createdAt: "2026-06-27T00:01:00.000Z", }); - const reply = store.createChannelMessage( + const source = appendChannelItemFixture(store, + { + id: "source_authority_message", + channelId: channel.id, + authorType: "local-user", + body: "Source authority request.", + }, + "2026-06-27T00:01:30.000Z", + ); + const reply = appendChannelItemFixture(store, { id: "reply_authority_demo", channelId: channel.id, - threadId: "source_authority_message", + replyToItemId: source.id, kind: "ai_reply", intent: "discuss", authorType: "ai-teammate", diff --git a/packages/daemon/test/channel-team-snapshots-api.test.mjs b/packages/daemon/test/channel-team-snapshots-api.test.mjs index 30a503be5..6f6e914c4 100644 --- a/packages/daemon/test/channel-team-snapshots-api.test.mjs +++ b/packages/daemon/test/channel-team-snapshots-api.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -10,7 +11,7 @@ test("channel team snapshots and evaluations API persist reversible replay recor const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-team-snapshots-api-")); const store = createSqliteRunStore(cwd); const channel = store.createChannel({ name: "Snapshots Channel", boundPath: cwd }, "2026-06-26T00:00:00.000Z"); - store.createChannelMessage( + appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", diff --git a/packages/daemon/test/channel-workspace.test.mjs b/packages/daemon/test/channel-workspace.test.mjs index 6caa6a803..28c0bbcb3 100644 --- a/packages/daemon/test/channel-workspace.test.mjs +++ b/packages/daemon/test/channel-workspace.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; @@ -5,7 +6,13 @@ import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { dirname, resolve } from "node:path"; import test from "node:test"; -import { DEFAULT_WORKSPACE_ID, createCompositeSecretResolver, createSqliteRunStore, resolveStoragePaths } from "../../core/dist/index.js"; +import { + DEFAULT_WORKSPACE_ID, + createCompositeSecretResolver, + createSqliteRunStore, + projectConversationItemToChannelMessage, + resolveStoragePaths, +} from "../../core/dist/index.js"; import { createDaemonServer } from "../dist/index.js"; import { buildContextPack, resolveProviderContextBudget } from "../dist/features/channels/context-packs.js"; import { routeChannelMessageReply } from "../dist/features/channels/channel-message-router.js"; @@ -154,26 +161,26 @@ test("channel branches follow archive restore and delete lifecycle", async () => })).branch; assert.ok(secondBranch.rootThreadId); assert.notEqual(secondBranch.rootThreadId, branch.rootThreadId); - store.createChannelMessage({ + appendChannelItemFixture(store, { channelId: channel.id, threadId: branch.rootThreadId, authorType: "local-user", body: "Message in model routing branch", }, "2026-06-22T00:01:00.000Z"); - store.createChannelMessage({ + appendChannelItemFixture(store, { channelId: channel.id, threadId: secondBranch.rootThreadId, authorType: "local-user", body: "Message in prompt cleanup branch", }, "2026-06-22T00:02:00.000Z"); - const firstBranchMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}&threadId=${encodeURIComponent(branch.rootThreadId)}`); + const firstBranchMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}&threadId=${encodeURIComponent(branch.rootThreadId)}`); assert.deepEqual( firstBranchMessages.messages .filter((message) => message.authorType === "local-user") .map((message) => message.body), ["Message in model routing branch"], ); - const secondBranchMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}&threadId=${encodeURIComponent(secondBranch.rootThreadId)}`); + const secondBranchMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}&threadId=${encodeURIComponent(secondBranch.rootThreadId)}`); assert.deepEqual( secondBranchMessages.messages .filter((message) => message.authorType === "local-user") @@ -412,7 +419,7 @@ test("channel team metrics exposes visible evidence total separately from team e const store = createSqliteRunStore(cwd); try { const { channel, message } = createStoreChannelAndMessage(store, cwd, "collect evidence"); - const webMessage = store.createChannelMessage({ + const webMessage = appendChannelItemFixture(store, { channelId: channel.id, authorType: "ai-teammate", authorId: "ling_agent", @@ -884,7 +891,7 @@ test("ling agent coordinator queues active run follow-up before ordinary routing taskId: "task_ling_agent_coordinator_followup", }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "完成后也更新 docs", replyDisposition: { mode: "queue_followup", activeRunId: run.id }, }); @@ -939,7 +946,7 @@ test("active channel reply does not intercept an unmarked direct workflow reques taskId: "task_active_reply_before_execute", }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: `在 ${cwd} 下创建 README.md`, }); @@ -999,8 +1006,8 @@ test("root reply guidance cannot capture a branch turn and branch continuation s taskId: "task_root_reply_before_branch", }); - const branchSource = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { - threadId: branch.rootThreadId, + const branchSource = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "thread", threadId: branch.rootThreadId }, body: "Start an independent branch reply.", requestAiReply: true, providerPresetId: provider.id, @@ -1017,8 +1024,8 @@ test("root reply guidance cannot capture a branch turn and branch continuation s assert.ok(branchRun); assert.equal(branchRun.threadId, branch.rootThreadId); - const guidance = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { - threadId: branch.rootThreadId, + const guidance = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "thread", threadId: branch.rootThreadId }, body: "Continue only inside this branch.", requestAiReply: true, providerPresetId: provider.id, @@ -1074,7 +1081,7 @@ test("topic channel replies keep canonical LingThread identity and isolate guida const store = stores.get(cwd); assert.ok(store); - const topicRoot = store.createChannelMessage({ + const topicRoot = appendChannelItemFixture(store, { channelId: channel.id, kind: "message", intent: "discuss", @@ -1082,8 +1089,8 @@ test("topic channel replies keep canonical LingThread identity and isolate guida body: "Topic root message.", status: "ready", }, "2026-08-08T00:00:00.000Z"); - const topicSource = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { - threadId: topicRoot.id, + const topicSource = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: topicRoot.id }, body: "Start a reply inside this topic.", requestAiReply: true, providerPresetId: provider.id, @@ -1097,8 +1104,8 @@ test("topic channel replies keep canonical LingThread identity and isolate guida assert.match(topicRun.threadId, /^thread_/); assert.notEqual(topicRun.threadId, topicRoot.id); - const guidance = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { - threadId: topicRoot.id, + const guidance = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "thread", threadId: topicSource.thread.id }, body: "Continue only inside this topic.", requestAiReply: true, providerPresetId: provider.id, @@ -1161,7 +1168,7 @@ test("ling agent coordinator queues attachment-only active run guidance", async contentBase64: Buffer.from("active run follow-up context", "utf8").toString("base64"), })).attachment; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "", attachmentIds: [attachment.id], replyDisposition: { mode: "queue_followup", activeRunId: run.id }, @@ -1210,7 +1217,7 @@ test("ling agent coordinator queues file-ref-only active run guidance", async () taskId: "task_ling_agent_coordinator_file_ref_followup", }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "", fileRefs: [{ path: "README.md", label: "README.md", source: "composer" }], replyDisposition: { mode: "queue_followup", activeRunId: run.id }, @@ -1247,7 +1254,7 @@ test("ling agent turn envelope merges persisted and explicit text file refs safe boundPath: cwd, autoReplyMode: "default_teammate", }, "2026-06-11T00:00:00.000Z"); - const message = store.createChannelMessage({ + const message = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "Also inspect file:README.md and path:/tmp/outside.txt", @@ -1329,7 +1336,7 @@ test("channel input materialization cache upserts and feeds turn envelope", asyn assert.equal(rows[0].truncated, true); assert.deepEqual(first.policyDecisionJson, { source: "test" }); - const message = store.createChannelMessage({ + const message = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "Fix this screenshot", @@ -1369,7 +1376,7 @@ test("inline materialization writes image vision cache with injected summarizer" path: "inline.png", createdAt: "2026-06-11T00:01:00.000Z", }); - const message = store.createChannelMessage({ + const message = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "Please handle this.", @@ -1443,7 +1450,7 @@ test("inline image materialization reuses same image content after provider time path: "first.png", createdAt: "2026-06-11T00:01:00.000Z", }); - const firstMessage = store.createChannelMessage({ + const firstMessage = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "看下这个图片", @@ -1470,7 +1477,7 @@ test("inline image materialization reuses same image content after provider time path: "second.png", createdAt: "2026-06-11T00:04:00.000Z", }); - const secondMessage = store.createChannelMessage({ + const secondMessage = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "同一张图再看一次", @@ -1530,7 +1537,7 @@ test("retryable image materialization failures are not cached permanently", asyn path: "retry.png", createdAt: "2026-06-11T00:01:00.000Z", }); - const message = store.createChannelMessage({ + const message = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "看下这个图片", @@ -1765,7 +1772,7 @@ test("inline materialization writes public link body cache for explicit link req siteName: "Example", status: "ready", }, "2026-06-11T00:01:00.000Z"); - const message = store.createChannelMessage({ + const message = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "Please summarize this link", @@ -1851,7 +1858,7 @@ test("cached public link body lets Ling answer without materialization clarify", tokenEstimate: 12, }, "2026-06-11T00:02:00.000Z"); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Please summarize this link", linkPreviewIds: [linkPreview.id], }); @@ -2003,7 +2010,7 @@ test("inline materialization writes pdf and docx cache for current-turn attachme path: "inline.docx", createdAt: "2026-06-11T00:02:00.000Z", }); - const message = store.createChannelMessage({ + const message = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "Context attached.", @@ -2073,7 +2080,7 @@ test("materialized pdf and docx attachments let Ling answer without materializat contentBase64: (await readDocxFixtureBytes()).toString("base64"), })).attachment; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Please summarize this PDF and this document", attachmentIds: [pdfAttachment.id, docxAttachment.id], }); @@ -2125,7 +2132,7 @@ test("materialized image attachment lets Ling answer without materialization cla contentBase64: Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString("base64"), })).attachment; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Please fix the UI from this screenshot", attachmentIds: [attachment.id], requestAiReply: true, @@ -2193,7 +2200,7 @@ test("provider-backed image materialization works without injected summarizer", contentBase64: Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString("base64"), })).attachment; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "为什么还是这样?", attachmentIds: [attachment.id], requestAiReply: true, @@ -2273,7 +2280,7 @@ test("ling agent coordinator preserves materialized guidance metadata for active status: "ready", }, "2026-06-11T00:02:30.000Z"); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "另外也更新 file:README.md", attachmentIds: [attachment.id], linkPreviewIds: [linkPreview.id], @@ -2329,7 +2336,7 @@ test("ling agent coordinator treats redirect-like text as queued guidance", asyn taskId: "task_ling_agent_coordinator_redirect", }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "停,改用 daemon API", replyDisposition: { mode: "queue_followup", activeRunId: run.id }, }); @@ -2391,7 +2398,7 @@ test("channel reply guidance drains FIFO without interrupting and projects every const store = stores.get(cwd); assert.ok(store); - const source = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const source = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Start a delayed Ling reply.", requestAiReply: true, teammateId: "ling_agent", @@ -2402,7 +2409,7 @@ test("channel reply guidance drains FIFO without interrupting and projects every const sourceRun = store.listRuns({ limit: 20 }) .find((run) => run.channelId === channel.id && !run.parentRunId); assert.ok(sourceRun); - const sourceMessage = store.getChannelMessage(sourceRun.requestSnapshot.sourceMessageId); + const sourceMessage = getChannelItemViewFixture(store, sourceRun.requestSnapshot.sourceMessageId); assert.ok(sourceMessage); assert.equal(findActiveChannelReplyRun({ store, @@ -2425,7 +2432,7 @@ test("channel reply guidance drains FIFO without interrupting and projects every ]; const queued = []; for (const queuedInput of queuedInputs) { - const response = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const response = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: queuedInput.body, mentions: queuedInput.mentions, requestAiReply: true, @@ -2440,7 +2447,7 @@ test("channel reply guidance drains FIFO without interrupting and projects every assert.equal(runner.runs.length, 1); assert.equal(runner.abortCount, 0); const queuedMessageIds = new Set(queued.map((response) => response.message.id)); - const whileQueued = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`); + const whileQueued = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); assert.deepEqual(whileQueued.messages.filter((message) => queuedMessageIds.has(message.id)), []); runner.resolve("Initial channel reply"); @@ -2458,7 +2465,7 @@ test("channel reply guidance drains FIFO without interrupting and projects every assert.equal(firstContinuation.providerPresetId, designerProvider.id); assert.doesNotMatch(runner.runs[1].task.prompt, /Continue a Ling Stack supervised run from preserved context\./); assert.match(runner.runs[1].systemPrompt, /Treat the latest queued follow-up as the current user message/); - const whileFirstRuns = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`); + const whileFirstRuns = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); assert.deepEqual( whileFirstRuns.messages.filter((message) => queuedMessageIds.has(message.id)).map((message) => message.id), [queued[0].message.id], @@ -2489,7 +2496,7 @@ test("channel reply guidance drains FIFO without interrupting and projects every () => store.listRunInterventions({ sourceRunId: queued[0].replyDispatch.sourceRunId }).every((item) => item.completedAt), "channel guidance interventions to complete", ); - const listed = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`); + const listed = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); const visibleAiBodies = listed.messages .filter((message) => message.authorType === "ai-teammate" && message.status === "ready") .map((message) => message.body); @@ -2508,7 +2515,7 @@ test("channel reply guidance drains FIFO without interrupting and projects every assert.equal(continuationRun.status, "succeeded"); assert.equal(continuationWorkUnit.ownerId, response.message.id); assert.ok(continuationWorkUnit.linkedMessageId); - const reply = store.getChannelMessage(continuationWorkUnit.linkedMessageId); + const reply = getChannelItemViewFixture(store, continuationWorkUnit.linkedMessageId); assert.equal(reply.body, `${index === 0 ? "First" : "Second"} channel continuation reply`); assert.equal(reply.linkedRunId, continuationRun.id); } @@ -2549,7 +2556,7 @@ test("channel reply guidance continues draining after the first continuation fai const store = stores.get(cwd); assert.ok(store); - await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Start a delayed reply before draining guidance.", requestAiReply: true, providerPresetId: provider.id, @@ -2572,7 +2579,7 @@ test("channel reply guidance continues draining after the first continuation fai idempotencyKey: "channel_message:msg_missing_guidance_source:queue_followup", requestedBy: "local-user", }, "2026-08-08T00:00:00.000Z"); - const valid = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const valid = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "This second queued message must still run.", requestAiReply: true, providerPresetId: provider.id, @@ -2642,7 +2649,7 @@ test("queued channel source stays hidden while dispatch classification is still autoReplyMode: "mentions_only", allowedTools: ["read"], })).channel; - const source = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const source = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Start a delayed reply before classification visibility is tested.", requestAiReply: true, teammateId: "ling_agent", @@ -2668,7 +2675,7 @@ test("queued channel source stays hidden while dispatch classification is still createdAt: new Date().toISOString(), }); const queuedBody = "Queue this message while dispatch classification is blocked."; - const streamResponse = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages/stream?cwd=${encodeURIComponent(cwd)}`, { + const streamResponse = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/stream?cwd=${encodeURIComponent(cwd)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -2681,7 +2688,7 @@ test("queued channel source stays hidden while dispatch classification is still assert.equal(streamResponse.status, 200); await waitForCondition(() => visionStartedCount === 1, "classification image summarizer to start"); - const whileClassifying = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`); + const whileClassifying = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); assert.equal(whileClassifying.messages.some((message) => message.body === queuedBody), false); releaseVision(); @@ -2689,7 +2696,7 @@ test("queued channel source stays hidden while dispatch classification is still const terminal = events.at(-1); assert.equal(terminal.event, "done"); assert.equal(terminal.data.replyDispatch.disposition, "followup_queued"); - const whileQueued = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`); + const whileQueued = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); assert.equal(whileQueued.messages.some((message) => message.id === terminal.data.message.id), false); } finally { releaseVision?.(); @@ -2757,13 +2764,13 @@ test("unmentioned channel guidance joins the pending Ling reply lane before its }); await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); - const sourcePromise = postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const sourcePromise = postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "查一下最新版本说明。", requestAiReply: true, providerPresetId: provider.id, }); await waitForCondition(() => webSearchCallCount === 1, "source reply web search to start"); - const guidancePromise = postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const guidancePromise = postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "After that, reply only with pending lane guidance.", requestAiReply: true, providerPresetId: provider.id, @@ -2827,13 +2834,13 @@ test("channel messages persist and AI blocker replies do not drop the user messa }, })).channel; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "请 @Engineer 完善频道需求", mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], requestAiReply: true, }); assert.equal(created.message.body, "请 @Engineer 完善频道需求"); - assert.equal(created.aiReply.status, "pending"); + assert.equal(created.aiReply.status, "blocked"); const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "blocked"); assert.deepEqual(listed.messages.map((message) => message.id), [created.message.id, created.aiReply.id]); @@ -2860,7 +2867,7 @@ test("channel discussion uses the mentioned teammate provider preset", async () memberships: [{ channelId: channel.id }], }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "@Designer 你好", mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], requestAiReply: true, @@ -2906,7 +2913,7 @@ test("channel discussion records provider runtime failures as availability incid memberships: [{ channelId: channel.id }], }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "@Engineer 看一下这个实现边界", mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], requestAiReply: true, @@ -2954,7 +2961,7 @@ test("channel observer bus stays inert when the feature flag is disabled", async memberships: [{ channelId: channel.id }], }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "@Engineer 看一下这个实现边界", mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], requestAiReply: true, @@ -2993,7 +3000,7 @@ test("channel observer bus writes bounded relevance evidence without visible obs memberships: [{ channelId: channel.id }], }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "@Engineer 看一下这个实现边界", mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], requestAiReply: true, @@ -3060,7 +3067,7 @@ test("channel observer bus skips unavailable observers without blocking the main "2099-06-27T00:00:00.000Z", ); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "@Engineer 看一下这个实现边界", mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], requestAiReply: true, @@ -3121,7 +3128,7 @@ test("channel observer bus applies provider-level availability incidents to matc "2099-06-27T00:00:00.000Z", ); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "@Engineer 看一下这个实现边界", mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], requestAiReply: true, @@ -3181,7 +3188,7 @@ test("channel observer bus ignores expired provider-level availability incidents "2026-06-26T00:00:00.000Z", ); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "@Engineer 看一下这个实现边界", mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], requestAiReply: true, @@ -3262,7 +3269,7 @@ test("channel discussion injects web search context before runner reply", async memberships: [{ channelId: channel.id }], }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "@Designer 查一下 Ling Stack 最新消息", mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], requestAiReply: true, @@ -3333,7 +3340,7 @@ test("channel discussion blocks runner prompt envelope echoes", async () => { memberships: [{ channelId: channel.id }], }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "@Designer 给我一片关于AI发展的分析报告", mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], requestAiReply: true, @@ -3366,7 +3373,7 @@ test("channel message stream emits visible deltas into a durable AI reply", asyn memberships: [{ channelId: channel.id }], }); - const response = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages/stream?cwd=${encodeURIComponent(cwd)}`, { + const response = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/stream?cwd=${encodeURIComponent(cwd)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -3380,11 +3387,11 @@ test("channel message stream emits visible deltas into a durable AI reply", asyn const events = parseSseEvents(await response.text()); assert.deepEqual(events.map((event) => event.event), ["ready", "reply_pending", "ready", "run_linked", "delta", "done"]); assert.equal(events[0].data.message.body, "@Engineer 请流式回复"); - assert.equal(events[1].data.sourceMessageId, events[0].data.message.id); + assert.equal(events[1].data.sourceItemId, events[0].data.message.id); assert.equal(events[1].data.targetTeammateId, "teammate_builtin_engineer"); assert.ok(events[1].data.startedAt); assert.equal(events[2].data.aiReply.status, "pending"); - assert.equal(events[3].data.messageId, events[2].data.aiReply.id); + assert.equal(events[3].data.itemId, events[2].data.aiReply.id); assert.ok(events[3].data.linkedRunId); const { threadId, sourceMessageId, ...activeReplyWithoutLineage } = events[3].data.activeReply; assert.deepEqual(activeReplyWithoutLineage, { @@ -3406,7 +3413,7 @@ test("channel message stream emits visible deltas into a durable AI reply", asyn assert.equal(events[4].data.body, "Streamed channel reply"); assert.equal(events[5].data.aiReply.body, "Streamed channel reply"); - const listed = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`); + const listed = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); const finalReply = listed.messages.find((message) => message.id === events[2].data.aiReply.id); assert.equal(finalReply.body, "Streamed channel reply"); assert.equal(finalReply.status, "ready"); @@ -3506,7 +3513,7 @@ test("channel message stream does not expose hidden task proposal blocks", async memberships: [{ channelId: channel.id }], }); - const response = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages/stream?cwd=${encodeURIComponent(cwd)}`, { + const response = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/stream?cwd=${encodeURIComponent(cwd)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -3544,7 +3551,7 @@ test("channel AI reply prompt carries projected segment context for short follow const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const first = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const first = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "300字的小说", providerPresetId: provider.id, teammateId: "ling_agent", @@ -3552,7 +3559,7 @@ test("channel AI reply prompt carries projected segment context for short follow }); await waitForChannelMessageStatus(daemon.url, cwd, channel.id, first.aiReply.id, "ready"); - const second = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const second = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "400字", providerPresetId: provider.id, teammateId: "ling_agent", @@ -3589,9 +3596,9 @@ test("channel AI reply injects Plan Mode instructions for plan threads", async ( mode: "plan", }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Plan the next implementation slice.", - threadId: thread.thread.id, + surface: { kind: "thread", threadId: thread.thread.id }, providerPresetId: provider.id, teammateId: "ling_agent", requestAiReply: true, @@ -3633,7 +3640,7 @@ test("channel no-tools AI reply retries once with compacted channel summary afte memberships: [{ channelId: channel.id }], }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "@Engineer 请根据频道上下文回复", mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], requestAiReply: true, @@ -3749,7 +3756,7 @@ test("channel hard summary uses the effective workspace provider before dispatch store.markRunSucceeded(run.id, new Date(Date.parse(createdAt) + 3_000).toISOString()); } - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "@Engineer continue from the durable summary.", mentions: [{ id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }], teammateId: "teammate_builtin_engineer", @@ -3792,11 +3799,11 @@ test("mentions_only creates a blocked Ling Agent reply when the channel has no r const channel = await createChannel(daemon.url, cwd); simulateLegacyChannelWithoutModel(cwd, channel.id); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "这个频道自动回复怎么设计?", }); - assert.equal(created.aiReply.status, "pending"); + assert.equal(created.aiReply.status, "blocked"); assert.equal(created.aiReply.authorId, "ling_agent"); const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "blocked"); const blockedReply = listed.messages.find((message) => message.id === created.aiReply.id); @@ -3836,7 +3843,7 @@ test("default_teammate routes root messages through Ling Agent without proposals allowedTools: ["read"], })).channel; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "这个实现边界怎么处理?", }); assert.equal(created.aiReply.status, "pending"); @@ -3867,7 +3874,7 @@ test("default_teammate routes root messages through Ling Agent without proposals assert.equal(firstReplyRuns[0].status, "succeeded"); assert.equal(firstReplyRuns[0].taskId, created.message.id); - const countQuestion = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const countQuestion = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "现在频道有多少个队友", }); assert.equal(countQuestion.aiReply.status, "pending"); @@ -3915,7 +3922,7 @@ test("channel intent selector creates visible multi-replies for discussion-only }); } - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "所有队友都回复我一下", }); @@ -3997,7 +4004,7 @@ test("channel intent selector creates workflow proposals for execution-shaped al }); } - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "所有队友都回复我一下:Architect 先拆,Engineer 实现,QA 验证", teammateId: "ling_agent", requestAiReply: true, @@ -4022,7 +4029,7 @@ test("channel intent selector creates workflow proposals for execution-shaped al assert.match(proposals.proposals[0].collaborationSteps[0].summary, /拆解实现边界/); assert.match(proposals.proposals[0].riskSummary, /工作流 proposal/); - const listedMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`); + const listedMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); assert.equal(listedMessages.messages.filter((message) => message.kind === "ai_reply").length, 0); const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); @@ -4063,7 +4070,7 @@ test("channel intent selector does not assign specialists outside current channe }); } - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Architect 先拆,Engineer 实现,QA 验证", }); @@ -4115,7 +4122,7 @@ test("channel intent selector uses attached AI council specialists without copyi preferredCouncilId: developmentCouncilId, }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Designer 先 review 交互,QA 验证这个频道设置改动", }); @@ -4192,7 +4199,7 @@ test("channel intent selector asks one clarify question for underspecified work memberships: [{ channelId: channel.id }], }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "做一下吧", }); @@ -4252,14 +4259,14 @@ test("channel intent selector auto-starts explicit direct workflow proposals", a }); } - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "开始跑 Architect -> Engineer -> QA", }); assertCoordinatorWorkflowStartReply(created); assert.equal(created.autoStartedWorkflow.workflowSteps.length, 3); await waitForCondition(() => runner.runs.length === 3, "direct workflow proposal runner sequence"); - const listedMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`); + const listedMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); const terminalReply = assertCoordinatorWorkflowTerminalReply(listedMessages.messages); assert.match(terminalReply.body, /结果:Designer reply/); @@ -4316,7 +4323,7 @@ test("explicit response language overrides workspace default for workflow propos }); } - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Architect 先拆,Engineer 实现,QA 验证。Please respond in English.", }); const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); @@ -4345,7 +4352,7 @@ test("channel response language overrides follow-user inference for workflow pro allowedTools: ["read", "grep"], })).channel; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Architect 先拆,Engineer 实现,QA 验证", }); const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); @@ -4377,7 +4384,7 @@ test("single-step file creation becomes direct workflow with bounded write tools })).channel; await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: `在 ${cwd} 下创建 README.md`, }); @@ -4408,6 +4415,50 @@ test("single-step file creation becomes direct workflow with bounded write tools } }); +test("explicit command request becomes execute with bounded bash tools", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-command-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Command execution", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "teammate_builtin_qa", + autoReplyMode: "default_teammate", + allowedTools: ["read", "bash"], + })).channel; + await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "运行 `pnpm test` 并报告结果。", + }); + + assertCoordinatorWorkflowStartReply(created); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.deepEqual(proposals.proposals[0].requiredTools, ["bash"]); + assert.match(proposals.proposals[0].prompt, /pnpm test/); + + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 2); + assert(decisions.decisions.every((decision) => decision.action === "execute")); + assert(decisions.decisions.every((decision) => decision.execution.kind === "run_command")); + assert(decisions.decisions.every((decision) => decision.execution.targetKind === "command")); + assert.equal(runner.runs.length, 1); + assert.deepEqual(runner.runs[0].tools, ["bash"]); + assert.match(runner.runs[0].task.prompt, /pnpm test/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + test("explicit Ling Agent file request becomes direct workflow instead of discussion reply", async () => { const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-mentioned-ling-file-")); const runner = new RecordingRunner(); @@ -4424,7 +4475,7 @@ test("explicit Ling Agent file request becomes direct workflow instead of discus allowedTools: ["read", "grep", "find", "ls", "write"], })).channel; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: `@Ling Agent 在 ${cwd} 下创建 login.html`, mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], requestAiReply: true, @@ -4476,7 +4527,7 @@ test("workspace follow-up can create the single file named by the previous Ling })).channel; const store = stores.get(cwd); assert.ok(store); - const previousUser = store.createChannelMessage( + const previousUser = appendChannelItemFixture(store, { channelId: channel.id, kind: "message", @@ -4486,7 +4537,7 @@ test("workspace follow-up can create the single file named by the previous Ling }, "2026-06-12T09:00:00.000Z", ); - store.createChannelMessage( + appendChannelItemFixture(store, { channelId: channel.id, threadId: previousUser.threadId, @@ -4500,7 +4551,7 @@ test("workspace follow-up can create the single file named by the previous Ling "2026-06-12T09:01:00.000Z", ); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "可以直接在工作区帮我做好吗?", mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], requestAiReply: true, @@ -4548,7 +4599,7 @@ test("thread follow-up can create the single file named by the previous Ling Age })).channel; const store = stores.get(cwd); assert.ok(store); - const previousUser = store.createChannelMessage( + const previousUser = appendChannelItemFixture(store, { channelId: channel.id, kind: "message", @@ -4558,7 +4609,7 @@ test("thread follow-up can create the single file named by the previous Ling Age }, "2026-06-12T09:00:00.000Z", ); - store.createChannelMessage( + appendChannelItemFixture(store, { channelId: channel.id, threadId: previousUser.threadId, @@ -4579,8 +4630,8 @@ test("thread follow-up can create the single file named by the previous Ling Age "2026-06-12T09:01:00.000Z", ); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { - threadId: previousUser.id, + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: previousUser.id }, body: "你不可以直接帮我写到里面吗?", mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], requestAiReply: true, @@ -4634,7 +4685,7 @@ test("thread follow-up opens the recent HTML target with bounded bash tools", as })).channel; const store = stores.get(cwd); assert.ok(store); - const previousUser = store.createChannelMessage( + const previousUser = appendChannelItemFixture(store, { channelId: channel.id, kind: "message", @@ -4644,7 +4695,7 @@ test("thread follow-up opens the recent HTML target with bounded bash tools", as }, "2026-06-12T09:00:00.000Z", ); - store.createChannelMessage( + appendChannelItemFixture(store, { channelId: channel.id, threadId: previousUser.threadId, @@ -4658,8 +4709,8 @@ test("thread follow-up opens the recent HTML target with bounded bash tools", as "2026-06-12T09:01:00.000Z", ); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { - threadId: previousUser.id, + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: previousUser.id }, body: "可以直接帮我浏览器打开吗?", mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], requestAiReply: true, @@ -4714,7 +4765,7 @@ test("model-assisted thread follow-up opens recent target from typed operation k })).channel; const store = stores.get(cwd); assert.ok(store); - const previousUser = store.createChannelMessage( + const previousUser = appendChannelItemFixture(store, { channelId: channel.id, kind: "message", @@ -4724,7 +4775,7 @@ test("model-assisted thread follow-up opens recent target from typed operation k }, "2026-06-12T09:00:00.000Z", ); - store.createChannelMessage( + appendChannelItemFixture(store, { channelId: channel.id, threadId: previousUser.threadId, @@ -4738,8 +4789,8 @@ test("model-assisted thread follow-up opens recent target from typed operation k "2026-06-12T09:01:00.000Z", ); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { - threadId: previousUser.id, + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: previousUser.id }, body: "可以直接帮我展示一下这个成果吗?", }); @@ -4799,7 +4850,7 @@ test("model-assisted thread follow-up writes the recent local target without phr })).channel; const store = stores.get(cwd); assert.ok(store); - const previousUser = store.createChannelMessage( + const previousUser = appendChannelItemFixture(store, { channelId: channel.id, kind: "message", @@ -4809,7 +4860,7 @@ test("model-assisted thread follow-up writes the recent local target without phr }, "2026-06-12T09:00:00.000Z", ); - store.createChannelMessage( + appendChannelItemFixture(store, { channelId: channel.id, threadId: previousUser.threadId, @@ -4829,8 +4880,8 @@ test("model-assisted thread follow-up writes the recent local target without phr "2026-06-12T09:01:00.000Z", ); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { - threadId: previousUser.id, + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: previousUser.id }, body: "你现在可以帮我写在本地里面了吗?", }); @@ -4893,7 +4944,7 @@ test("model-assisted thread follow-up clarifies instead of creating empty recent })).channel; const store = stores.get(cwd); assert.ok(store); - const previousUser = store.createChannelMessage( + const previousUser = appendChannelItemFixture(store, { channelId: channel.id, kind: "message", @@ -4903,7 +4954,7 @@ test("model-assisted thread follow-up clarifies instead of creating empty recent }, "2026-06-12T09:00:00.000Z", ); - store.createChannelMessage( + appendChannelItemFixture(store, { channelId: channel.id, threadId: previousUser.threadId, @@ -4917,8 +4968,8 @@ test("model-assisted thread follow-up clarifies instead of creating empty recent "2026-06-12T09:01:00.000Z", ); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { - threadId: previousUser.id, + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: previousUser.id }, body: "你现在可以帮我写在本地里面了吗?", }); @@ -4959,7 +5010,7 @@ test("model-assisted local write request clarifies when no target validates", as allowedTools: ["read", "grep", "find", "ls", "write"], })).channel; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "你现在可以帮我写在本地里面了吗?", }); @@ -5011,7 +5062,7 @@ test("workspace follow-up clarifies when the previous Ling Agent reply names mul })).channel; const store = stores.get(cwd); assert.ok(store); - const previousUser = store.createChannelMessage( + const previousUser = appendChannelItemFixture(store, { channelId: channel.id, kind: "message", @@ -5021,7 +5072,7 @@ test("workspace follow-up clarifies when the previous Ling Agent reply names mul }, "2026-06-12T09:00:00.000Z", ); - store.createChannelMessage( + appendChannelItemFixture(store, { channelId: channel.id, threadId: previousUser.threadId, @@ -5035,7 +5086,7 @@ test("workspace follow-up clarifies when the previous Ling Agent reply names mul "2026-06-12T09:01:00.000Z", ); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "可以直接在工作区帮我做好吗?", mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], requestAiReply: true, @@ -5083,7 +5134,7 @@ test("thread follow-up clarifies when the previous Ling Agent reply names multip })).channel; const store = stores.get(cwd); assert.ok(store); - const previousUser = store.createChannelMessage( + const previousUser = appendChannelItemFixture(store, { channelId: channel.id, kind: "message", @@ -5093,7 +5144,7 @@ test("thread follow-up clarifies when the previous Ling Agent reply names multip }, "2026-06-12T09:00:00.000Z", ); - store.createChannelMessage( + appendChannelItemFixture(store, { channelId: channel.id, threadId: previousUser.threadId, @@ -5107,8 +5158,8 @@ test("thread follow-up clarifies when the previous Ling Agent reply names multip "2026-06-12T09:01:00.000Z", ); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { - threadId: previousUser.id, + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: previousUser.id }, body: "你直接帮我写到里面", mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], requestAiReply: true, @@ -5154,7 +5205,7 @@ test("text attachment preview can feed a direct README write workflow", async () contentBase64: Buffer.from("README body from attachment", "utf8").toString("base64"), })).attachment; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "把附件内容写进 README.md", attachmentIds: [attachment.id], }); @@ -5204,7 +5255,7 @@ test("text attachment materialization cache hit avoids rereading attachment file contentBase64: Buffer.from("Cached attachment body", "utf8").toString("base64"), })).attachment; - const first = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const first = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "把附件内容写进 README.md", attachmentIds: [attachment.id], }); @@ -5225,7 +5276,7 @@ test("text attachment materialization cache hit avoids rereading attachment file await rm(resolve(store.paths.attachmentsDir, attachment.path), { force: true }); - const second = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const second = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "把附件内容写进 SECOND.md", attachmentIds: [attachment.id], }); @@ -5268,7 +5319,7 @@ test("attachment-dependent README write blocks when attachment content is not ma contentBase64: Buffer.from(oversized, "utf8").toString("base64"), })).attachment; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "把附件内容写进 README.md", attachmentIds: [attachment.id], }); @@ -5309,7 +5360,7 @@ test("unreadable screenshot task creates Ling materialization clarify reply", as contentBase64: Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString("base64"), })).attachment; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "根据这个截图修 UI", attachmentIds: [attachment.id], }); @@ -5348,7 +5399,7 @@ test("blocked link body request creates Ling materialization clarify reply", asy })).linkPreview; assert.equal(linkPreview.status, "blocked"); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Please summarize this link", linkPreviewIds: [linkPreview.id], }); @@ -5383,7 +5434,7 @@ test("Ling Agent fallback creates file proposal when no Docs teammate can run", allowedTools: ["read", "grep", "find", "ls", "write"], })).channel; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: `在 ${cwd} 下创建 README.md`, }); @@ -5433,7 +5484,7 @@ test("Ling Agent fallback edits code proposal when no Engineer teammate can run" allowedTools: ["read", "grep", "find", "ls", "edit"], })).channel; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: `修改 ${cwd}/src/app.ts,把标题改成 Ling`, }); @@ -5480,7 +5531,7 @@ test("single-step file creation starts with task-scoped write when channel lacks })).channel; await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: `在 ${cwd} 下创建 README.md`, }); @@ -5525,14 +5576,14 @@ test("single-step file edit inherits target path from recent workflow context", })).channel; await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: `在 ${cwd} 下创建 README.md`, }); const createProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); assert.equal(createProposals.proposals.length, 1); assert.equal(createProposals.proposals[0].status, "accepted"); - const edited = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const edited = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "在 README.md 中添加一句测试语句", }); @@ -5573,7 +5624,7 @@ test("single-step file edit normalizes Users path without leading slash", async })).channel; await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); - const edited = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const edited = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Users/tianxiao/ling-stack-test 下面,在 README.md 中添加一句测试语句", }); @@ -5603,7 +5654,7 @@ test("file creation how-to question remains ordinary Ling chat intent", async () allowedTools: ["read", "grep", "find", "ls", "write"], })).channel; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "如何创建 README.md?", }); @@ -5614,7 +5665,7 @@ test("file creation how-to question remains ordinary Ling chat intent", async () const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); assertSingleReplyIntentDecision(decisions.decisions, created.message.id); - const whyQuestion = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const whyQuestion = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "为什么不能创建 README.md?", }); assert.equal(whyQuestion.aiReply.status, "pending"); @@ -5642,7 +5693,7 @@ test("single-step test request becomes direct workflow with bounded bash tools", })).channel; await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "开始执行测试", }); @@ -5679,7 +5730,7 @@ test("high-risk push merge request auto-starts when required tools are not denie allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], })).channel; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "帮我 push 合并代码", }); @@ -5727,7 +5778,7 @@ test("high-risk thread follow-up creates a proposal without auto-starting", asyn })).channel; const store = stores.get(cwd); assert.ok(store); - const previousUser = store.createChannelMessage( + const previousUser = appendChannelItemFixture(store, { channelId: channel.id, kind: "message", @@ -5738,8 +5789,8 @@ test("high-risk thread follow-up creates a proposal without auto-starting", asyn "2026-06-12T09:00:00.000Z", ); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { - threadId: previousUser.id, + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: previousUser.id }, body: "帮我 push 合并代码", mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], requestAiReply: true, @@ -5784,7 +5835,7 @@ test("high-risk push merge request blocks before run when required tool is denie allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], })).channel; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "帮我 push 合并代码", }); @@ -5831,7 +5882,7 @@ test("model-assisted channel intent no_reply falls through to ordinary Ling repl memberships: [{ channelId: channel.id }], }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "这个实现边界怎么处理?", }); @@ -5887,7 +5938,7 @@ test("model-assisted channel intent selector does not fall back to phrase-rule w }); } - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Architect 先拆,Engineer 实现,QA 验证", }); @@ -5934,14 +5985,14 @@ test("issue #421 article continuation stays reply while an explicit README save allowedTools: ["read", "grep", "find", "ls", "write"], })).channel; - const article = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const article = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "帮我写一篇关于可恢复多 Agent 记忆的长文,先给提纲。", teammateId: "ling_agent", requestAiReply: true, }); await waitForChannelMessageStatus(daemon.url, cwd, channel.id, article.aiReply.id, "ready"); - const continuation = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const continuation = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "需要,开始吧", teammateId: "ling_agent", requestAiReply: true, @@ -5968,7 +6019,7 @@ test("issue #421 article continuation stays reply while an explicit README save const continuationProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(continuation.message.id)}`); assert.deepEqual(continuationProposals.proposals, []); - const save = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const save = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "把刚才的文章保存到 README.md。", teammateId: "ling_agent", requestAiReply: true, @@ -6030,7 +6081,7 @@ test("issue #421 unrelated snake-game request escapes an old target clarificatio allowedTools: ["read", "grep", "find", "ls", "write"], })).channel; - const oldRequest = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const oldRequest = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "把这段内容写到本地文件里。", teammateId: "ling_agent", requestAiReply: true, @@ -6042,7 +6093,7 @@ test("issue #421 unrelated snake-game request escapes an old target clarificatio assert.equal(oldDecisions.decisions[0].action, "clarify"); assert.deepEqual(oldDecisions.decisions[0].clarification.missingFields, ["target"]); - const snake = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const snake = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "再做一个贪吃蛇小游戏的方案,先讲玩法。", teammateId: "ling_agent", requestAiReply: true, @@ -6099,7 +6150,7 @@ test("default_teammate route suppresses duplicate Ling Agent replies for the sam }, now, ); - const sourceMessage = store.createChannelMessage( + const sourceMessage = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", @@ -6107,10 +6158,10 @@ test("default_teammate route suppresses duplicate Ling Agent replies for the sam }, now, ); - store.createChannelMessage( + appendChannelItemFixture(store, { channelId: channel.id, - threadId: sourceMessage.id, + replyToItemId: sourceMessage.id, kind: "ai_reply", authorType: "ai-teammate", authorId: "ling_agent", @@ -6181,7 +6232,7 @@ test("default_teammate route treats channel branches and topic replies as separa title: "Root", objective: "Root conversation", }, now).thread; - const rootMessage = store.createChannelMessage({ + const rootMessage = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "Create a branch", @@ -6216,7 +6267,7 @@ test("default_teammate route treats channel branches and topic replies as separa }, now, ); - const branchMessage = store.createChannelMessage( + const branchMessage = appendChannelItemFixture(store, { channelId: channel.id, threadId: branch.rootThreadId, @@ -6225,7 +6276,7 @@ test("default_teammate route treats channel branches and topic replies as separa }, now, ); - const topicSource = store.createChannelMessage( + const topicSource = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", @@ -6233,10 +6284,10 @@ test("default_teammate route treats channel branches and topic replies as separa }, now, ); - const topicReply = store.createChannelMessage( + const topicReply = appendChannelItemFixture(store, { channelId: channel.id, - threadId: topicSource.id, + topicAnchorItemId: topicSource.id, authorType: "local-user", body: "这是 topic reply,不应该自动拉起 agent。", }, @@ -6302,7 +6353,7 @@ test("Ling Agent replies to greetings, repeated greetings, punctuation, and ackn allowedTools: ["read"], })).channel; - const preConfigGreeting = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const preConfigGreeting = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "你好?", }); assert.equal(preConfigGreeting.aiReply.status, "pending"); @@ -6313,7 +6364,7 @@ test("Ling Agent replies to greetings, repeated greetings, punctuation, and ackn autoReplyMode: "default_teammate", }); - const repeatedGreeting = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const repeatedGreeting = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "你好?", }); assert.equal(repeatedGreeting.aiReply.status, "pending"); @@ -6325,7 +6376,7 @@ test("Ling Agent replies to greetings, repeated greetings, punctuation, and ackn assert.equal(decisions.decisions[0].status, "accepted"); assert.equal(decisions.decisions[0].replyMessageId, repeatedGreeting.aiReply.id); - const eveningGreeting = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const eveningGreeting = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "晚上好", }); assert.equal(eveningGreeting.aiReply.status, "pending"); @@ -6333,7 +6384,7 @@ test("Ling Agent replies to greetings, repeated greetings, punctuation, and ackn const eveningReply = eveningListed.messages.find((message) => message.id === eveningGreeting.aiReply.id); assert.equal(eveningReply.body, "晚上好,我在。"); - const punctuation = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const punctuation = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "。。", }); assert.equal(punctuation.aiReply.status, "pending"); @@ -6341,7 +6392,7 @@ test("Ling Agent replies to greetings, repeated greetings, punctuation, and ackn const punctuationReply = punctuationListed.messages.find((message) => message.id === punctuation.aiReply.id); assert.equal(punctuationReply.body, "看到了,继续说。"); - const acknowledgement = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const acknowledgement = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "好的", }); assert.equal(acknowledgement.aiReply.status, "pending"); @@ -6368,11 +6419,11 @@ test("default_teammate creates a blocked Ling Agent reply when the channel has n })).channel; simulateLegacyChannelWithoutModel(cwd, channel.id); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "这个问题谁来回复?", }); - assert.equal(created.aiReply.status, "pending"); + assert.equal(created.aiReply.status, "blocked"); assert.equal(created.aiReply.authorId, "ling_agent"); const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "blocked"); const blockedReply = listed.messages.find((message) => message.id === created.aiReply.id); @@ -6406,7 +6457,7 @@ test("explicit mentions still reply and keep proposal extraction enabled", async providerPresetId: provider.id, }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "@Designer 这个明确任务可以处理吗?", mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], requestAiReply: true, @@ -6530,7 +6581,7 @@ test("auto_route uses the model selector and records accepted route evidence", a }); const store = stores.get(cwd); assert.ok(store); - const oldGuidanceMessage = store.createChannelMessage( + const oldGuidanceMessage = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", @@ -6562,7 +6613,7 @@ test("auto_route uses the model selector and records accepted route evidence", a "2026-06-12T08:00:02.000Z", ); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "这个设置文案应该怎么改?", }); @@ -6635,7 +6686,7 @@ test("auto_route falls back to Ling Agent replies on selector failure", async () try { const channel = await createAutoRouteTestChannel(daemon.url, cwd); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "这个交给谁回复?", }); @@ -6655,7 +6706,7 @@ test("auto_route falls back to Ling Agent replies on selector failure", async () reason: "Unsure.", alternativesConsidered: [], }), "Ling fallback reply 2"); - const lowConfidence = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const lowConfidence = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "这个 UI 问题可以自动判断吗?", }); assert.equal(lowConfidence.aiReply.status, "pending"); @@ -6672,7 +6723,7 @@ test("auto_route falls back to Ling Agent replies on selector failure", async () reason: "Wrapped JSON should fail strict parsing.", alternativesConsidered: [], })}`, "Ling fallback reply 3"); - const wrappedJson = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const wrappedJson = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "这个带解释的模型输出应该失败吗?", }); assert.equal(wrappedJson.aiReply.status, "pending"); @@ -6689,7 +6740,7 @@ test("auto_route falls back to Ling Agent replies on selector failure", async () reason: "Unknown teammate should fail closed.", alternativesConsidered: [], }), "Ling fallback reply 4"); - const unknownTeammate = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const unknownTeammate = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "这个未知 teammate 应该失败吗?", }); assert.equal(unknownTeammate.aiReply.status, "pending"); @@ -6717,7 +6768,7 @@ test("channel AI replies return pending message before runner finishes", async ( providerPresetId: provider.id, }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "@Designer async please", mentions: [{ id: teammateId, label: "Designer", kind: "ai-teammate" }], requestAiReply: true, @@ -6729,7 +6780,7 @@ test("channel AI replies return pending message before runner finishes", async ( await waitForCondition(() => runner.runs.length === 1, "runner to start channel AI reply"); assert.equal(runner.runs.length, 1); assert.equal(runner.completed, false); - const pending = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`); + const pending = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); assert.equal(pending.messages.find((message) => message.id === created.aiReply.id).status, "pending"); runner.resolve("Delayed reply"); @@ -6805,7 +6856,7 @@ test("attachment-only channel turn reaches Ling Agent with metadata and preview" contentBase64: Buffer.from("hello channel attachment", "utf8").toString("base64"), })).attachment; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "", attachmentIds: [attachment.id], }); @@ -6836,7 +6887,7 @@ test("channel asset references must stay within the same channel", async () => { try { const first = await createChannel(daemon.url, cwd); const second = await createChannel(daemon.url, cwd); - const message = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const message = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "source message", }); const attachment = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { @@ -6860,7 +6911,7 @@ test("channel asset references must stay within the same channel", async () => { assert.equal(crossUpload.status, 400); assert.equal((await crossUpload.json()).error, "message_channel_mismatch"); - const crossMessage = await fetch(`${daemon.url}/channels/${encodeURIComponent(second.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const crossMessage = await fetch(`${daemon.url}/channels/${encodeURIComponent(second.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -6966,10 +7017,10 @@ test("channel collaboration protocol drafts apply protocol-only overrides with s try { const first = await createChannel(daemon.url, cwd); const second = await createChannel(daemon.url, cwd); - const message = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const message = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Draft a protocol update.", }); - const otherMessage = await postJson(`${daemon.url}/channels/${encodeURIComponent(second.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const otherMessage = await postJson(`${daemon.url}/channels/${encodeURIComponent(second.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Wrong channel source.", }); @@ -7077,10 +7128,10 @@ test("channel collaboration protocol endpoints persist state and reject cross-ch try { const first = await createChannel(daemon.url, cwd); const second = await createChannel(daemon.url, cwd); - const message = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const message = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Use manual handoffs for v1.1.", }); - const otherMessage = await postJson(`${daemon.url}/channels/${encodeURIComponent(second.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const otherMessage = await postJson(`${daemon.url}/channels/${encodeURIComponent(second.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Other channel.", }); const task = (await postJson(`${daemon.url}/tasks?cwd=${encodeURIComponent(cwd)}`, { @@ -7356,7 +7407,7 @@ test("queued workflow runs keep private role memory out of durable snapshots", a assert.ok(store); store.updateChannel(channel.id, { defaultProviderPresetId: provider.id }, "2026-08-10T03:10:00.000Z"); - await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "Hold the channel runtime slot.", requestAiReply: true, providerPresetId: provider.id, @@ -9920,7 +9971,7 @@ test("memory API scopes lexical recall and channel replies audit used memory", a assert.equal(recall.memories.some((item) => item.memory.ownerId === "teammate_builtin_engineer"), false); assert.equal(recall.memories.some((item) => item.memory.sensitivity === "secret-adjacent"), false); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "@Designer fix Model Workbench width", mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], requestAiReply: true, @@ -11003,7 +11054,7 @@ test("procedure observation API routes structured observations before candidate assert.equal(canvas.candidateClusters.some((cluster) => cluster.id === playbookClusters.clusters[0].id), true); assert.equal(canvas.sections.some((section) => section.kind === "candidate_clusters" && section.status === "needs_review"), true); - const protocolSourceMessage = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + const protocolSourceMessage = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { body: "This channel needs reviewer signoff before implementation handoff.", }); const protocolSourceDecision = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/decisions?cwd=${encodeURIComponent(cwd)}`, { @@ -12800,7 +12851,7 @@ function createStoreChannelAndMessage(store, cwd, body) { boundPath: cwd, allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], }, now); - const message = store.createChannelMessage({ + const message = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body, @@ -12810,7 +12861,7 @@ function createStoreChannelAndMessage(store, cwd, body) { function createActiveChannelReplyRunFixture(store, input) { const teammateId = input.teammateId ?? "ling_agent"; - const sourceMessage = store.createChannelMessage({ + const sourceMessage = appendChannelItemFixture(store, { channelId: input.channelId, kind: "message", intent: "discuss", @@ -12818,9 +12869,9 @@ function createActiveChannelReplyRunFixture(store, input) { body: "Source message for the active channel reply.", status: "ready", }, "2026-06-11T00:00:55.000Z"); - const replyMessage = store.createChannelMessage({ + const replyMessage = appendChannelItemFixture(store, { channelId: input.channelId, - threadId: sourceMessage.id, + replyToItemId: sourceMessage.id, kind: "ai_reply", intent: "discuss", authorType: "ai-teammate", @@ -12847,6 +12898,7 @@ function createActiveChannelReplyRunFixture(store, input) { cwd: input.cwd, taskId: input.taskId, channelId: input.channelId, + threadId: sourceMessage.threadId, teammateId, providerName: "test-provider", providerModel: "test-model", @@ -12861,7 +12913,7 @@ function createActiveChannelReplyRunFixture(store, input) { createdAt: "2026-06-11T00:01:00.000Z", }); store.linkRunToWorkUnit(run.id, workUnit.id, "2026-06-11T00:01:30.000Z"); - store.updateChannelMessageRuntime(replyMessage.id, { linkedRunId: run.id }, "2026-06-11T00:01:31.000Z"); + updateChannelItemFixture(store, replyMessage.id, { linkedRunId: run.id }, "2026-06-11T00:01:31.000Z"); store.markRunStarted(run.id, "2026-06-11T00:02:00.000Z"); return store.getRun(run.id); } @@ -13024,7 +13076,7 @@ function parseSseEvents(text) { } return { event: eventLine.slice("event:".length).trim(), - data: JSON.parse(dataLine.slice("data:".length).trim()), + data: projectCanonicalChannelConversationForTest(JSON.parse(dataLine.slice("data:".length).trim())), }; }) .filter(Boolean); @@ -13035,7 +13087,7 @@ async function getJson(url) { if (!response.ok) { assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); } - return await response.json(); + return projectCanonicalChannelConversationForTest(await response.json()); } async function getArtifactJson(baseUrl, cwd, artifactId) { @@ -13135,7 +13187,7 @@ async function waitForChannelMessageStatus(baseUrl, cwd, channelId, messageId, s const deadline = Date.now() + 2_000; let latest; while (Date.now() < deadline) { - latest = await getJson(`${baseUrl}/channels/${encodeURIComponent(channelId)}/messages?cwd=${encodeURIComponent(cwd)}${threadId ? `&threadId=${encodeURIComponent(threadId)}` : ""}`); + latest = await getJson(`${baseUrl}/channels/${encodeURIComponent(channelId)}/items?cwd=${encodeURIComponent(cwd)}${threadId ? `&threadId=${encodeURIComponent(threadId)}` : ""}`); const message = latest.messages.find((item) => item.id === messageId); if (message?.status === status) { return latest; @@ -13250,7 +13302,43 @@ async function postJson(url, body) { if (!response.ok) { assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); } - return await response.json(); + return projectCanonicalChannelConversationForTest(await response.json()); +} + +function projectCanonicalChannelConversationForTest(payload) { + if (!payload || typeof payload !== "object") return payload; + const decorations = Array.isArray(payload.decorations) ? payload.decorations : []; + const decorationByItemId = new Map(decorations.map((decoration) => [decoration.itemId, decoration])); + const projectItem = (item) => { + const decoration = decorationByItemId.get(item.id); + return { + ...projectConversationItemToChannelMessage(item, { + ...(decoration?.linkedRunId ? { linkedRunId: decoration.linkedRunId } : {}), + }), + ...(decoration?.editedAt ? { editedAt: decoration.editedAt } : {}), + ...(decoration?.pendingReplacement ? { pendingReplacement: decoration.pendingReplacement } : {}), + ...(decoration?.webEvidence ? { webEvidence: decoration.webEvidence } : {}), + }; + }; + if (payload.userItem) { + const message = projectItem(payload.userItem); + const aiReplies = (payload.assistantItems ?? []).map(projectItem); + return { + ...payload, + message, + aiReplies, + ...(aiReplies[0] ? { aiReply: aiReplies[0] } : {}), + }; + } + if (Array.isArray(payload.items)) { + return { + ...payload, + messages: payload.items + .filter((item) => item.kind === "user_message" || item.kind === "assistant_message" || item.kind === "system_event") + .map(projectItem), + }; + } + return payload; } async function putJson(url, body) { diff --git a/packages/daemon/test/conversation-context-projector.test.mjs b/packages/daemon/test/conversation-context-projector.test.mjs index cc9fc559f..b478acc7f 100644 --- a/packages/daemon/test/conversation-context-projector.test.mjs +++ b/packages/daemon/test/conversation-context-projector.test.mjs @@ -39,6 +39,12 @@ test("segmentation policy uses structural inheritance, validates model ids, and visibilityOwnerId: "teammate_private", payload: { eventType: "private_note", summary: "private evidence" }, }, "2026-08-10T02:00:30.000Z"); + const publicFirstProjection = buildConversationContextProjection({ + store: fixture.store, + sourceItemId: first.item.id, + view: "selector", + }); + assert.deepEqual(publicFirstProjection.items.map((item) => item.id), [first.item.id]); const second = appendUserTurn(fixture, "unrelated second topic", secondAt); const secondResolution = await assignContextSegmentForTurn({ diff --git a/packages/daemon/test/conversation-message-edits.test.mjs b/packages/daemon/test/conversation-message-edits.test.mjs index a7e3a4754..44cf86d71 100644 --- a/packages/daemon/test/conversation-message-edits.test.mjs +++ b/packages/daemon/test/conversation-message-edits.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -286,7 +287,7 @@ test("channel and teammate-direct edits update their native messages before repl const activeRuns = new ActiveRunRegistry(); try { const channel = store.createChannel({ name: "Engineering", boundPath: cwd }, "2026-07-12T06:00:00.000Z"); - const channelMessage = store.createChannelMessage({ + const channelMessage = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "Channel old", @@ -302,7 +303,7 @@ test("channel and teammate-direct edits update their native messages before repl activeRuns, startReplacement: async ({ editable }) => { assert.equal(editable.surface, "channel_root"); - assert.equal(store.getChannelMessage(editable.messageId)?.body, "Channel revised"); + assert.equal(getChannelItemViewFixture(store, editable.messageId)?.body, "Channel revised"); return {}; }, }); @@ -419,10 +420,10 @@ test("default projections hide superseded turns and response messages while audi assert.equal(store.getLingTurn(oldTurn.id)?.status, "superseded"); const channel = store.createChannel({ name: "Projection channel", boundPath: cwd }, "2026-07-12T07:01:00.000Z"); - const source = store.createChannelMessage({ channelId: channel.id, authorType: "local-user", body: "Source" }, "2026-07-12T07:01:01.000Z"); - const oldReply = store.createChannelMessage({ + const source = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "Source" }, "2026-07-12T07:01:01.000Z"); + const oldReply = appendChannelItemFixture(store, { channelId: channel.id, - threadId: source.id, + replyToItemId: source.id, authorType: "ai-teammate", authorId: LING_AGENT_ID, body: "Old reply", @@ -443,7 +444,7 @@ test("default projections hide superseded turns and response messages while audi ownerId: channel.id, }); assert.deepEqual(filterSupersededChannelMessages([source, oldReply], channelProjection).map((message) => message.id), [source.id]); - assert.equal(store.getChannelMessage(oldReply.id)?.body, "Old reply"); + assert.equal(getChannelItemViewFixture(store, oldReply.id)?.body, "Old reply"); const teammateId = createBuiltinAiTeammateId("designer"); const directThread = store.getOrCreateTeammateDirectThread(teammateId, "2026-07-12T07:02:00.000Z"); diff --git a/packages/daemon/test/ling-turns-api.test.mjs b/packages/daemon/test/ling-turns-api.test.mjs index 106a388c2..407068ff2 100644 --- a/packages/daemon/test/ling-turns-api.test.mjs +++ b/packages/daemon/test/ling-turns-api.test.mjs @@ -85,6 +85,201 @@ test("daemon API exposes durable Ling turns and links runs to turns", async () = } }); +test("channel conversation API pages canonical items by stable thread sequence", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-items-api-")); + const stores = new Map(); + const daemon = await startDaemonForTest(new SuccessfulRunner(), stores); + try { + const channel = (await createChannel(daemon.url, cwd, { autoReplyMode: "mentions_only" })).channel; + const first = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "first canonical item", + requestAiReply: false, + idempotencyKey: "channel-turn-first", + }); + const second = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "thread", threadId: first.thread.id }, + body: "second canonical item", + requestAiReply: false, + idempotencyKey: "channel-turn-second", + }); + assert.equal(first.userItem.kind, "user_message"); + assert.equal(first.turn.sourceItemId, first.userItem.id); + assert.equal(second.userItem.sequence > first.userItem.sequence, true); + assert.equal("message" in second, false); + + const topic = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: first.userItem.id }, + body: "topic canonical item", + requestAiReply: false, + }); + assert.equal(topic.thread.surfaceKind, "topic"); + assert.equal(topic.thread.anchorItemId, first.userItem.id); + assert.notEqual(topic.thread.id, first.userItem.id); + const topicContinuation = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "thread", threadId: topic.thread.id }, + body: "topic continuation", + requestAiReply: false, + }); + assert.equal(topicContinuation.thread.id, topic.thread.id); + assert.equal(topicContinuation.userItem.threadId, topic.thread.id); + + const topicPage = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}&anchorItemId=${encodeURIComponent(first.userItem.id)}`); + assert.equal(topicPage.thread.id, topic.thread.id); + assert.deepEqual(topicPage.items.map((item) => item.id), [topic.userItem.id, topicContinuation.userItem.id]); + + const conflictingSurface = await getJsonAllowError(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}&threadId=${encodeURIComponent(first.thread.id)}&anchorItemId=${encodeURIComponent(first.userItem.id)}`); + assert.equal(conflictingSurface.status, 400); + assert.match(conflictingSurface.body.message, /cannot be used together/); + const legacyTurnSelector = await postJsonAllowError(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + threadId: first.thread.id, + body: "legacy overloaded selector", + }); + assert.equal(legacyTurnSelector.status, 400); + assert.match(legacyTurnSelector.body.message, /threadId is not supported/); + const forbiddenAuthorityFields = { + authorType: "system", + authorId: "forged_actor", + kind: "system", + intent: "task", + linkedTaskId: "task_forged", + linkedRunId: "run_forged", + }; + for (const [field, value] of Object.entries(forbiddenAuthorityFields)) { + const forgedLinkage = await postJsonAllowError(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: `forged authority linkage: ${field}`, + [field]: value, + }); + assert.equal(forgedLinkage.status, 400); + assert.match(forgedLinkage.body.message, new RegExp(`unsupported field: ${field}`)); + } + const forgedSurface = await postJsonAllowError(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "thread", threadId: first.thread.id, anchorItemId: first.userItem.id }, + body: "forged mixed surface", + }); + assert.equal(forgedSurface.status, 400); + assert.match(forgedSurface.body.message, /surface contains unsupported field: anchorItemId/); + const itemAsThread = await postJsonAllowError(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "thread", threadId: first.userItem.id }, + body: "item id is not a thread id", + }); + assert.equal(itemAsThread.status, 400); + assert.match(itemAsThread.body.message, /Conversation thread not found/); + + const pagedItems = []; + let beforeSequence; + for (let pageNumber = 0; pageNumber < 10; pageNumber += 1) { + const page = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}&threadId=${encodeURIComponent(first.thread.id)}&limit=1${beforeSequence === undefined ? "" : `&beforeSequence=${beforeSequence}`}`); + assert.equal(page.items.length, 1); + pagedItems.push(page.items[0]); + if (!page.hasMore) break; + assert.equal(Number.isSafeInteger(page.nextBeforeSequence), true); + beforeSequence = page.nextBeforeSequence; + } + assert.equal(pagedItems.some((item) => item.id === first.userItem.id), true); + assert.equal(pagedItems.some((item) => item.id === second.userItem.id), true); + assert.deepEqual( + pagedItems.map((item) => item.sequence), + [...pagedItems].map((item) => item.sequence).sort((left, right) => right - left), + ); + assert.equal(new Set(pagedItems.map((item) => item.id)).size, pagedItems.length); + + const legacy = await getJsonAllowError(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`); + assert.equal(legacy.status, 404); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel context inspect is metadata-only and corrections are revision fenced", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-context-inspect-")); + const stores = new Map(); + const daemon = await startDaemonForTest(new SuccessfulRunner(), stores); + try { + const channel = (await createChannel(daemon.url, cwd, { autoReplyMode: "mentions_only" })).channel; + const first = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "architecture baseline", + requestAiReply: false, + }); + const store = stores.get(cwd); + assert.ok(store); + store.appendConversationItem({ + channelId: channel.id, + threadId: first.thread.id, + turnId: first.turn.id, + kind: "system_event", + status: "committed", + actorType: "system", + visibility: "agent_private", + visibilityOwnerId: "teammate_private", + payload: { + eventType: "private_context_fixture", + summary: "RAW_PROMPT_SENTINEL HIDDEN_REASONING_SENTINEL PRIVATE_MEMORY_SENTINEL SECRET_VALUE_SENTINEL", + }, + }, "2026-08-10T05:59:59.000Z"); + const second = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "thread", threadId: first.thread.id }, + body: "unrelated implementation topic", + requestAiReply: false, + }); + const firstAssignment = store.getEffectiveTurnContextAssignment(first.turn.id); + const secondAssignment = store.getEffectiveTurnContextAssignment(second.turn.id); + assert.ok(firstAssignment); + assert.ok(secondAssignment); + const moved = store.applyTurnContextAssignment({ + turnId: second.turn.id, + target: { kind: "new", state: "active", label: "Implementation" }, + status: "committed", + decisionSource: "model_policy", + evidenceRefs: [{ type: "conversation_item", id: second.userItem.id }], + policyVersion: "continuity-v2-segmentation-v1", + }, "2026-08-10T06:00:00.000Z"); + store.applyTurnContextAssignment({ + turnId: first.turn.id, + target: { kind: "existing", segmentId: moved.segment.id }, + status: "committed", + decisionSource: "model_policy", + evidenceRefs: [{ type: "turn", id: second.turn.id }], + policyVersion: "continuity-v2-segmentation-v1", + }, "2026-08-10T06:00:00.100Z"); + + const inspect = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/${encodeURIComponent(second.turn.id)}/context?cwd=${encodeURIComponent(cwd)}`); + assert.equal(inspect.assignment.id, moved.assignment.id); + assert.equal(inspect.projection.segmentId, moved.segment.id); + assert.deepEqual(inspect.safety, { + rawPromptTextIncluded: false, + hiddenReasoningIncluded: false, + privateMemoryBodiesIncluded: false, + secretValuesIncluded: false, + }); + const serializedInspect = JSON.stringify(inspect); + assert.equal(serializedInspect.includes("RAW_PROMPT_SENTINEL"), false); + assert.equal(serializedInspect.includes("HIDDEN_REASONING_SENTINEL"), false); + assert.equal(serializedInspect.includes("PRIVATE_MEMORY_SENTINEL"), false); + assert.equal(serializedInspect.includes("SECRET_VALUE_SENTINEL"), false); + assert.equal(inspect.redactedItemCount, 1); + + const corrected = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/${encodeURIComponent(second.turn.id)}/context/corrections?cwd=${encodeURIComponent(cwd)}`, { + segmentId: firstAssignment.segmentId, + expectedAssignmentRevision: moved.assignment.revision, + }); + assert.equal(corrected.assignment.segmentId, firstAssignment.segmentId); + assert.equal(corrected.assignment.decisionSource, "user"); + assert.equal(corrected.previousAssignment.id, moved.assignment.id); + assert.equal(corrected.projection.segmentId, firstAssignment.segmentId); + + const stale = await postJsonAllowError(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/${encodeURIComponent(second.turn.id)}/context/corrections?cwd=${encodeURIComponent(cwd)}`, { + segmentId: moved.segment.id, + expectedAssignmentRevision: moved.assignment.revision, + }); + assert.equal(stale.status, 409); + assert.equal(stale.body.error, "context_assignment_revision_conflict"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + test("daemon resume preview and execute can anchor to a durable Ling turn", async () => { const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-turn-resume-")); const stores = new Map(); @@ -1593,12 +1788,13 @@ async function postRunAndCollectEvents(baseUrl, body) { }; } -async function createChannel(baseUrl, cwd) { +async function createChannel(baseUrl, cwd, overrides = {}) { return await postJson(`${baseUrl}/channels?cwd=${encodeURIComponent(cwd)}`, { name: "Fork test", status: "active", boundPath: cwd, allowedTools: ["read"], + ...overrides, }); } diff --git a/packages/daemon/test/remote-conversation-facade.test.mjs b/packages/daemon/test/remote-conversation-facade.test.mjs index 8c29c3a09..5bb5f42c9 100644 --- a/packages/daemon/test/remote-conversation-facade.test.mjs +++ b/packages/daemon/test/remote-conversation-facade.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -36,7 +37,7 @@ test("remote facade lists and opens only normalized channel, normal, and teammat const store = createSqliteRunStore(cwd); try { const channel = store.createChannel({ name: "Mobile Channel", boundPath: cwd }, "2026-07-31T05:00:00.000Z"); - store.createChannelMessage({ + appendChannelItemFixture(store, { channelId: channel.id, authorType: "ai-teammate", authorId: LING_AGENT_ID, @@ -128,21 +129,21 @@ test("remote projections keep advancing after more than one thousand conversatio try { const channel = store.createChannel({ name: "Large Outbox", boundPath: cwd }, "2026-07-31T05:00:00.000Z"); for (let index = 0; index < 1_000; index += 1) { - store.createChannelMessage({ + appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", authorId: "local-user", body: `Filler ${index}`, }, new Date(Date.parse("2026-07-31T05:00:01.000Z") + index).toISOString()); } - const target = store.createChannelMessage({ + const target = appendChannelItemFixture(store, { channelId: channel.id, authorType: "ai-teammate", authorId: LING_AGENT_ID, body: "Initial projected reply.", status: "pending", }, "2026-07-31T05:01:00.000Z"); - store.updateChannelMessageRuntime(target.id, { + updateChannelItemFixture(store, target.id, { body: "Updated projected reply.", }, "2026-07-31T05:01:01.000Z"); @@ -190,7 +191,7 @@ test("remote send is at-most-once and rejects a second command while dispatch is const first = facade.sendText(command, context); const replay = facade.sendText(command, context); assert.deepEqual(replay, first); - assert.equal(store.listChannelMessages({ channelId: channel.id }).filter((message) => message.id === first.messageId).length, 1); + assert.equal(listChannelItemViewsFixture(store, { channelId: channel.id }).filter((message) => message.id === first.messageId).length, 1); assert.throws( () => facade.sendText({ ...command, commandId: "second_command", body: "Do not queue this." }, { @@ -211,7 +212,7 @@ test("facade mark-read and profile update expose the narrow Mobile fields", asyn const store = createSqliteRunStore(cwd); try { const channel = store.createChannel({ name: "Read", boundPath: cwd }, "2026-07-31T05:00:00.000Z"); - store.createChannelMessage({ + appendChannelItemFixture(store, { channelId: channel.id, authorType: "ai-teammate", authorId: LING_AGENT_ID, @@ -264,7 +265,7 @@ test("projection worker acknowledges rows only after sanitized events publish", const store = createSqliteRunStore(cwd); try { const channel = store.createChannel({ name: "Outbox", boundPath: cwd }, "2026-07-31T05:00:00.000Z"); - store.createChannelMessage({ + appendChannelItemFixture(store, { channelId: channel.id, authorType: "ai-teammate", authorId: LING_AGENT_ID, diff --git a/packages/daemon/test/terminal-report-coordinator-fixture.mjs b/packages/daemon/test/terminal-report-coordinator-fixture.mjs index 59a14e85e..0999a6b16 100644 --- a/packages/daemon/test/terminal-report-coordinator-fixture.mjs +++ b/packages/daemon/test/terminal-report-coordinator-fixture.mjs @@ -1,7 +1,8 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; export function createTerminalWorkflowFixture(store, cwd, suffix = "default") { const now = "2026-07-11T05:00:00.000Z"; const channel = store.createChannel({ name: `Terminal report ${suffix}`, boundPath: cwd }, now); - const sourceMessage = store.createChannelMessage({ + const sourceMessage = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "实现 TerminalReport", diff --git a/packages/daemon/test/terminal-report-coordinator.test.mjs b/packages/daemon/test/terminal-report-coordinator.test.mjs index a411872ac..23bfb3911 100644 --- a/packages/daemon/test/terminal-report-coordinator.test.mjs +++ b/packages/daemon/test/terminal-report-coordinator.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -120,7 +121,7 @@ test("terminal report coordinator commits fallback, publishes once, and closes i }); assert.equal(repeated.record.id, result.record.id); assert.equal(repeated.message?.id, result.message?.id); - assert.equal(store.listChannelMessages({ channelId: fixture.channel.id, limit: 100 }).filter((message) => message.id === result.message?.id).length, 1); + assert.equal(listChannelItemViewsFixture(store, { channelId: fixture.channel.id, limit: 100 }).filter((message) => message.id === result.message?.id).length, 1); assert.equal(store.listTerminalReports({ sourceKind: "workflow", sourceId: fixture.workflow.id }).length, 1); } finally { store.close(); diff --git a/packages/daemon/test/terminal-report-evidence.test.mjs b/packages/daemon/test/terminal-report-evidence.test.mjs index e24902a9c..1b75a56df 100644 --- a/packages/daemon/test/terminal-report-evidence.test.mjs +++ b/packages/daemon/test/terminal-report-evidence.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -20,7 +21,7 @@ test("workflow terminal evidence aggregates rounds, actions, checks, artifacts, try { const now = "2026-07-11T02:00:00.000Z"; const channel = store.createChannel({ name: "Terminal Report", boundPath: cwd }, now); - const sourceMessage = store.createChannelMessage({ + const sourceMessage = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "实现并验证 TerminalReport", diff --git a/packages/daemon/test/terminal-report-publication.test.mjs b/packages/daemon/test/terminal-report-publication.test.mjs index bc6b94fb6..d740b6eff 100644 --- a/packages/daemon/test/terminal-report-publication.test.mjs +++ b/packages/daemon/test/terminal-report-publication.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -44,7 +45,7 @@ test("terminal report publication uses a deterministic message id across mark-pu const message = publishWorkflowTerminalReport({ store, report, now }); assert.equal(message?.id, terminalReportMessageId(report.id)); assert.equal(publishWorkflowTerminalReport({ store, report, now: "2026-07-11T05:05:00.000Z" })?.id, message?.id); - assert.equal(store.listChannelMessages({ channelId: fixture.channel.id, limit: 100 }).filter((item) => item.id === message?.id).length, 1); + assert.equal(listChannelItemViewsFixture(store, { channelId: fixture.channel.id, limit: 100 }).filter((item) => item.id === message?.id).length, 1); } finally { store.close(); await rm(cwd, { recursive: true, force: true }); diff --git a/packages/daemon/test/terminal-report-recovery.test.mjs b/packages/daemon/test/terminal-report-recovery.test.mjs index d062c9ea3..802e9137c 100644 --- a/packages/daemon/test/terminal-report-recovery.test.mjs +++ b/packages/daemon/test/terminal-report-recovery.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -47,7 +48,7 @@ test("terminal report recovery publishes committed reports without duplicating t const recovered = store.getTerminalReport(draft.id); assert.ok(recovered?.publishedMessageId); await recoverWorkflowTerminalReports(store); - assert.equal(store.listChannelMessages({ channelId: fixture.channel.id, limit: 100 }).filter((message) => message.id === recovered?.publishedMessageId).length, 1); + assert.equal(listChannelItemViewsFixture(store, { channelId: fixture.channel.id, limit: 100 }).filter((message) => message.id === recovered?.publishedMessageId).length, 1); } finally { store.close(); await rm(cwd, { recursive: true, force: true }); @@ -72,7 +73,7 @@ test("terminal report recovery does not backfill workflows created before rollou await recoverWorkflowTerminalReports(store); assert.equal(store.listTerminalReports({ sourceKind: "workflow", sourceId: workflow.id, limit: 100 }).length, 0); - assert.equal(store.listChannelMessages({ channelId: channel.id, limit: 100 }).length, 0); + assert.equal(listChannelItemViewsFixture(store, { channelId: channel.id, limit: 100 }).length, 0); } finally { store.close(); await rm(cwd, { recursive: true, force: true }); diff --git a/packages/daemon/test/workflow-terminal-summary.test.mjs b/packages/daemon/test/workflow-terminal-summary.test.mjs index c5631e1a7..740ed389d 100644 --- a/packages/daemon/test/workflow-terminal-summary.test.mjs +++ b/packages/daemon/test/workflow-terminal-summary.test.mjs @@ -1,3 +1,4 @@ +import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -23,7 +24,7 @@ test("workflow terminal reply preserves full step result text", async () => { boundPath: cwd, allowedTools: ["read", "write"], }, now); - const sourceMessage = store.createChannelMessage({ + const sourceMessage = appendChannelItemFixture(store, { channelId: channel.id, authorType: "local-user", body: "创建 index.html", @@ -90,7 +91,7 @@ test("workflow terminal reply preserves full step result text", async () => { const completedStep = store.getChannelWorkflowStep(step.id); assert.equal(completedStep?.status, "succeeded"); assert.equal(completedStep?.resultSummary, longResult); - const reply = store.listChannelMessages({ channelId: channel.id, limit: 20 }).find((message) => message.kind === "ai_reply"); + const reply = listChannelItemViewsFixture(store, { channelId: channel.id, limit: 20 }).find((message) => message.kind === "ai_reply"); assert.ok(reply); assert.match(reply.body, /完成:「Create index\.html」/u); assert.match(reply.body, /END_MARKER_完整结尾/u);