From f3a07fab28d30df13a7b3a4058d350b60d7b8a2f Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Mon, 10 Aug 2026 06:18:09 +0800 Subject: [PATCH] feat(channels): cut intent decisions to continuity v2 --- apps/web/e2e/channel.spec.ts | 24 +- apps/web/e2e/support/mock-daemon.ts | 55 ++- apps/web/src/api/channels.ts | 2 +- .../workspace/ChannelInspectorPanels.tsx | 63 ++- apps/web/src/i18n/locales/en-US.ts | 10 + apps/web/src/i18n/locales/zh-CN.ts | 9 + .../src/lib/conversation-turn-projector.ts | 2 +- ...ation-continuity-v2-implementation-plan.md | 18 +- .../channel-conversation-continuity-v2.md | 2 +- .../channel-intent-workflow-selector.md | 2 +- docs/design/implementation-status.md | 2 +- packages/api-client/src/endpoints/channels.ts | 4 +- packages/contracts/src/channels.ts | 44 +-- packages/core/src/storage/sqlite-run-store.ts | 49 +-- .../sqlite/channel-intent-decision-store.ts | 118 +++--- .../sqlite/channel-intent-v2-migration.ts | 105 +++++ .../storage/sqlite/channel-routing-mappers.ts | 43 +- .../core/src/storage/sqlite/migrations.ts | 36 +- .../core/src/storage/sqlite/select-sql.ts | 13 +- packages/core/src/workspace-types.ts | 78 ++-- .../test/channel-intent-v2-migration.test.mjs | 97 +++++ .../core/test/channel-runtime-store.test.mjs | 34 +- .../test/continuity-v2-migration.test.mjs | 39 +- .../channels/channel-intent-selector.ts | 374 +++++++++++++----- .../src/features/channels/context-packs.ts | 12 +- .../channels/ling-agent-auto-start.ts | 4 +- .../daemon/src/features/channels/messages.ts | 63 +-- .../daemon/src/features/channels/routes.ts | 8 +- .../daemon/test/channel-workspace.test.mjs | 332 ++++++++++++---- 29 files changed, 1168 insertions(+), 474 deletions(-) create mode 100644 packages/core/src/storage/sqlite/channel-intent-v2-migration.ts create mode 100644 packages/core/test/channel-intent-v2-migration.test.mjs diff --git a/apps/web/e2e/channel.spec.ts b/apps/web/e2e/channel.spec.ts index 21958191f..c3b7f4b52 100644 --- a/apps/web/e2e/channel.spec.ts +++ b/apps/web/e2e/channel.spec.ts @@ -2356,7 +2356,7 @@ test("Ling Agent session acceptance matrix auto-starts executable messages", asy await expect.poll(() => { const decision = state.channelIntentDecisions[0]; return isRecord(decision) ? [decision.action, decision.status, decision.createdBy].join("|") : ""; - }).toBe("direct_workflow|accepted|deterministic"); + }).toBe("execute|accepted|deterministic"); }); test("Ling Agent session keeps ordinary questions on chat routing", async ({ page }) => { @@ -2412,7 +2412,7 @@ test("ordinary channel feed hides routing and intent decision internals", async updatedAt: "2026-05-25T00:10:00.000Z", }, { - id: "msg_direct_workflow", + id: "msg_execute", workspaceId: "workspace_default", channelId: "channel_engineering", kind: "message", @@ -2447,10 +2447,10 @@ test("ordinary channel feed hides routing and intent decision internals", async ]; state.channelExecutionProposals = [ { - id: "proposal_direct_workflow", + id: "proposal_execute", workspaceId: "workspace_default", channelId: "channel_engineering", - sourceMessageId: "msg_direct_workflow", + sourceMessageId: "msg_execute", proposedByTeammateId: "ling_agent", title: "Edit README.md", prompt: "Edit README.md with one test sentence.", @@ -2467,13 +2467,19 @@ test("ordinary channel feed hides routing and intent decision internals", async ]; state.channelIntentDecisions = [ { - schemaVersion: 1, - id: "intent_direct_workflow", + schemaVersion: 2, + id: "intent_execute", workspaceId: "workspace_default", channelId: "channel_engineering", - sourceMessageId: "msg_direct_workflow", - action: "direct_workflow", - proposalId: "proposal_direct_workflow", + sourceTurnId: "turn_msg_execute", + sourceItemId: "msg_execute", + contextSegmentId: "segment_channel_engineering", + contextProjectionVersion: "ctxv2_mock_msg_execute", + action: "execute", + selectedTeammateIds: [], + proposalId: "proposal_execute", + resultItemIds: [], + execution: { kind: "file_edit", targetKind: "file" }, reason: "Direct workflow proposal created by mock intent selector.", status: "accepted", createdBy: "deterministic", diff --git a/apps/web/e2e/support/mock-daemon.ts b/apps/web/e2e/support/mock-daemon.ts index bb8acbbe3..2d00d64c9 100644 --- a/apps/web/e2e/support/mock-daemon.ts +++ b/apps/web/e2e/support/mock-daemon.ts @@ -5809,13 +5809,12 @@ function maybeCreateMockWorkflowProposal( }; state.channelExecutionProposals.unshift(proposal); state.channelIntentDecisions.push({ - schemaVersion: 1, + ...mockChannelIntentDecisionContext(channelId, message), id: `intent_${state.channelIntentDecisions.length + 1}`, - workspaceId: "workspace_default", - channelId, - sourceMessageId: typeof message.id === "string" ? message.id : "", action: "workflow_proposal", + selectedTeammateIds: finalSelectedTeammates.flatMap((teammate) => typeof teammate.id === "string" ? [teammate.id] : []), proposalId: proposal.id, + resultItemIds: [], reason: "Workflow proposal created from channel intent.", status: "accepted", createdBy: "deterministic", @@ -5913,14 +5912,12 @@ function maybeCreateMockMaterializationClarifyReply( }; state.channelMessages.push(aiReply); state.channelIntentDecisions.push({ - schemaVersion: 1, + ...mockChannelIntentDecisionContext(channelId, message), id: `intent_${state.channelIntentDecisions.length + 1}`, - workspaceId: "workspace_default", - channelId, - sourceMessageId: typeof message.id === "string" ? message.id : "", action: "clarify", - selectedTeammateId: "ling_agent", - replyMessageId: aiReply.id, + selectedTeammateIds: ["ling_agent"], + resultItemIds: [aiReply.id], + clarification: { missingFields: ["materialized_input"], question: replyBody }, reason: decisionReason, status: "accepted", createdBy: "deterministic", @@ -5987,13 +5984,13 @@ function maybeCreateMockSingleStepExecutableProposal( }; state.channelExecutionProposals.unshift(proposal); state.channelIntentDecisions.push({ - schemaVersion: 1, + ...mockChannelIntentDecisionContext(channelId, message), id: `intent_${state.channelIntentDecisions.length + 1}`, - workspaceId: "workspace_default", - channelId, - sourceMessageId: typeof message.id === "string" ? message.id : "", - action: "direct_workflow", + action: "execute", + selectedTeammateIds: [], proposalId: proposal.id, + resultItemIds: [], + execution: { kind: isEdit ? "file_edit" : "file_create", targetKind: "file" }, reason: "Direct single-step executable proposal created by mock intent selector.", status: "accepted", createdBy: "deterministic", @@ -6004,6 +6001,30 @@ function maybeCreateMockSingleStepExecutableProposal( return { autoStartedWorkflow }; } +function mockChannelIntentDecisionContext( + channelId: string, + message: Record, +): { + readonly schemaVersion: 2; + readonly workspaceId: string; + readonly channelId: string; + readonly sourceTurnId: string; + readonly sourceItemId: string; + readonly contextSegmentId: string; + readonly contextProjectionVersion: string; +} { + const sourceItemId = typeof message.id === "string" ? message.id : "unknown_item"; + return { + schemaVersion: 2, + workspaceId: "workspace_default", + channelId, + sourceTurnId: `turn_${sourceItemId}`, + sourceItemId, + contextSegmentId: `segment_${channelId}`, + contextProjectionVersion: `ctxv2_mock_${sourceItemId}`, + }; +} + function mockExecutableFileTarget(state: MockState, channelId: string, body: string): string | undefined { const explicitPath = body.match(/(?:^|[\s"'`([{(,。::])((?:\/(?!\/)|\.{1,2}\/|Users\/)[^\s"'`,。;;::!?!?))\]}]*?[\w.-]+\.(?:md|txt|json|ts|tsx|js|jsx|py|css|html|yml|yaml))/iu)?.[1]; if (explicitPath) { @@ -10952,7 +10973,7 @@ export async function handleDaemonRoute( const channelIntentDecisionsMatch = /^\/channels\/([^/]+)\/intent-decisions$/.exec(path); if (channelIntentDecisionsMatch && request.method() === "GET") { const channelId = decodeURIComponent(channelIntentDecisionsMatch[1]); - const sourceMessageId = url.searchParams.get("sourceMessageId"); + const sourceItemId = url.searchParams.get("sourceItemId"); await route.fulfill({ json: { cwd, @@ -10960,7 +10981,7 @@ export async function handleDaemonRoute( decisions: state.channelIntentDecisions.filter((decision) => isRecord(decision) && decision.channelId === channelId && - (!sourceMessageId || decision.sourceMessageId === sourceMessageId)), + (!sourceItemId || decision.sourceItemId === sourceItemId)), }, }); return; diff --git a/apps/web/src/api/channels.ts b/apps/web/src/api/channels.ts index dbafa47d8..0588b16bd 100644 --- a/apps/web/src/api/channels.ts +++ b/apps/web/src/api/channels.ts @@ -315,7 +315,7 @@ export async function listChannelMessageRouteDecisions( export async function listChannelIntentDecisions( cwd: string, channelId: string, - options: { readonly sourceMessageId?: string; readonly action?: string; readonly status?: string; readonly limit?: number } = {}, + options: { readonly sourceItemId?: string; readonly action?: string; readonly status?: string; readonly limit?: number } = {}, ): Promise { return daemonClient.listChannelIntentDecisions(cwd, channelId, options); } diff --git a/apps/web/src/features/workspace/ChannelInspectorPanels.tsx b/apps/web/src/features/workspace/ChannelInspectorPanels.tsx index b8ea5d94d..2c5e19b24 100644 --- a/apps/web/src/features/workspace/ChannelInspectorPanels.tsx +++ b/apps/web/src/features/workspace/ChannelInspectorPanels.tsx @@ -770,7 +770,6 @@ function ChannelIntentInspectContent({ readonly teammates: readonly ChannelMention[]; readonly t: Translate; }): JSX.Element { - const replyMessageIds = uniqueStrings([decision.replyMessageId, ...(decision.replyMessageIds ?? [])].filter((id): id is string => Boolean(id))); return ( <>

{channelIntentDecisionUserReason(decision, t)}

@@ -792,13 +791,49 @@ function ChannelIntentInspectContent({
{channelIntentDecisionCreatedByLabel(decision, t)}
-
{t("Source message")}
-
{decision.sourceMessageId}
+
{t("Source item")}
+
{decision.sourceItemId}
+
+
+
{t("Source turn")}
+
{decision.sourceTurnId}
+
+
+
{t("Context segment")}
+
{decision.contextSegmentId}
+
+
+
{t("Projection version")}
+
{decision.contextProjectionVersion}
-
{t("Reply messages")}
-
{formatOptionalIdList(replyMessageIds, t)}
+
{t("Result items")}
+
{formatOptionalIdList(decision.resultItemIds, t)}
+ {decision.clarification ? ( + <> +
+
{t("Missing fields")}
+
{formatOptionalIdList(decision.clarification.missingFields, t)}
+
+
+
{t("Clarification question")}
+
{decision.clarification.question}
+
+ + ) : null} + {decision.execution ? ( + <> +
+
{t("Execution kind")}
+
{decision.execution.kind}
+
+
+
{t("Target kind")}
+
{decision.execution.targetKind}
+
+ + ) : null}
{t("Proposal")}
{decision.proposalId || t("None")}
@@ -807,10 +842,10 @@ function ChannelIntentInspectContent({
{t("Workflow")}
{decision.workflowId || t("None")}
- {decision.errorMessage ? ( + {decision.errorCode ? (
{t("Error")}
-
{decision.errorMessage}
+
{decision.errorCode}
) : null} @@ -913,25 +948,25 @@ function channelIntentDecisionActionLabel(action: ChannelIntentDecision["action" switch (action) { case "no_reply": return t("No reply"); - case "single_reply": - return t("Single reply"); + case "reply": + return t("Reply"); case "visible_multi_reply": return t("Visible multi-reply"); case "clarify": return t("Clarify"); case "workflow_proposal": return t("Workflow proposal"); - case "direct_workflow": - return t("Direct workflow proposal"); + case "execute": + return t("Execute"); } } function channelIntentDecisionCreatedByLabel(decision: ChannelIntentDecision, t: Translate): string { - return decision.createdBy === "model" ? t("Model selector") : t("Deterministic rule"); + return decision.createdBy === "model_policy" ? t("Model selector") : t("Deterministic rule"); } function channelIntentDecisionUserReason(decision: ChannelIntentDecision, t: Translate): string { - if (decision.errorMessage) { + if (decision.errorCode) { return t("Ling could not safely handle this channel intent."); } return decision.reason || channelIntentDecisionActionLabel(decision.action, t); @@ -949,7 +984,7 @@ function formatIntentDecisionTeammates( teammates: readonly ChannelMention[], t: Translate, ): string { - const ids = uniqueStrings([decision.selectedTeammateId, ...(decision.selectedTeammateIds ?? [])].filter((id): id is string => Boolean(id))); + const ids = decision.selectedTeammateIds; if (ids.length === 0) { return t("None"); } diff --git a/apps/web/src/i18n/locales/en-US.ts b/apps/web/src/i18n/locales/en-US.ts index b1c96effc..c9b021b57 100644 --- a/apps/web/src/i18n/locales/en-US.ts +++ b/apps/web/src/i18n/locales/en-US.ts @@ -4264,4 +4264,14 @@ export const enUS = { "Generate a fresh five-minute QR code, then confirm the phone on this PC.": "Generate a fresh five-minute QR code, then confirm the phone on this PC.", "Listener": "Listener", "Generate pairing code": "Generate pairing code", + "Source item": "Source item", + "Source turn": "Source turn", + "Context segment": "Context segment", + "Projection version": "Projection version", + "Result items": "Result items", + "Execute": "Execute", + "Missing fields": "Missing fields", + "Clarification question": "Clarification question", + "Execution kind": "Execution kind", + "Target kind": "Target kind", } as const satisfies Readonly>; diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index eb361c54b..9c6fdca2a 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -6103,4 +6103,13 @@ export const zhCN = { "Generate a fresh five-minute QR code, then confirm the phone on this PC.": "生成一个新的五分钟配对码,然后在这台 PC 上确认手机。", "Listener": "监听器", "Generate pairing code": "生成配对码", + "Source item": "来源条目", + "Context segment": "上下文分段", + "Projection version": "投影版本", + "Result items": "结果条目", + "Execute": "执行", + "Missing fields": "缺失字段", + "Clarification question": "澄清问题", + "Execution kind": "执行类型", + "Target kind": "目标类型", } as const satisfies Readonly>; diff --git a/apps/web/src/lib/conversation-turn-projector.ts b/apps/web/src/lib/conversation-turn-projector.ts index b1302183f..0610d1aa1 100644 --- a/apps/web/src/lib/conversation-turn-projector.ts +++ b/apps/web/src/lib/conversation-turn-projector.ts @@ -91,7 +91,7 @@ export function buildConversationTurnProjection(input: BuildConversationTurnProj ); const intentDecisionsBySourceMessageId = groupBy( input.intentDecisions.filter((decision) => decision.channelId === input.channelId && decision.status === "accepted"), - (decision) => decision.sourceMessageId, + (decision) => decision.sourceItemId, ); const workflowById = new Map(input.workflows.filter((workflow) => workflow.channelId === input.channelId).map((workflow) => [workflow.id, workflow])); const runsById = new Map(input.runs.map((run) => [run.id, run])); diff --git a/docs/design/channel-conversation-continuity-v2-implementation-plan.md b/docs/design/channel-conversation-continuity-v2-implementation-plan.md index 80c7a8d5a..480abee71 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 and S3 implemented and locally verified; S4-S7 pending +Status: G0 complete; S-1 and S0 merged; S1+S2, S3, and S4 implemented and locally verified; S5-S7 pending Owner: conversation, channel context, Ling Agent Session, and storage maintainers Date: 2026-08-08 Last verified: 2026-08-10 @@ -447,6 +447,14 @@ context. Paging for UI remains allowed; context construction goes through the pr ## Slice S4: Intent v2 And Issue #421 Closure +Status: implemented on `codex/continuity-v2-context-projector`. The clean-break SQLite +migration, contracts, API/client filters, selector, structured clarification, typed +execution, Web diagnostics, and Issue #421 regression fixtures are locally verified. +The focused channel workspace suite passes 186/186, including article continuation, +explicit README execution, and unrelated-request segment isolation. S4-specific Web E2E +passes 3/3. The broader `channel.spec.ts` run is 108/112; four failures outside the S4 +touch set remain part of the final S7/full-program E2E gate. + ### Changes - Replace v1 selector parsing and persistence with `ChannelIntentDecisionV2`. @@ -792,9 +800,9 @@ interfaces at the end of S3 and keep implementation files separate before parall idempotency, paging, and supersession tests. - [x] **T3 (P1, human: ~2 days / Codex: ~3-4 hours)**: create every user `LingTurn` and map root/topic/branch explicitly. - Verify: daemon ingest and thread-lineage tests. -- [ ] **T4 (P1, human: ~3 days / Codex: ~5-7 hours)**: implement segmentation policy and shared projector; remove raw fixed-window consumers. +- [x] **T4 (P1, human: ~3 days / Codex: ~5-7 hours)**: implement segmentation policy and shared projector; remove raw fixed-window consumers. - Verify: projection consistency, permission, correction, and consumer adapter tests. -- [ ] **T5 (P1, human: ~2 days / Codex: ~3-4 hours)**: cut intent decisions to v2 and close issue #421. +- [x] **T5 (P1, human: ~2 days / Codex: ~3-4 hours)**: cut intent decisions to v2 and satisfy issue #421 closure criteria. - Verify: daemon regression suite, explicit file safety, clarification actionability, and eval cases. - [ ] **T6 (P2, human: ~2 days / Codex: ~3-5 hours)**: add proactive summaries, coverage watermarks, stale-result rejection, and fork snapshots. - Verify: budget, race, invalidation, reconstruction, and fallback tests. @@ -848,7 +856,7 @@ 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 and S3 are implemented and -locally verified; S4-S7 retain the reviewed delivery order and exit criteria. +**VERDICT:** ENG CLEARED. S-1 and S0 are merged; S1+S2, S3, and S4 are implemented +and locally verified; S5-S7 retain the reviewed delivery order and exit criteria. NO UNRESOLVED DECISIONS diff --git a/docs/design/channel-conversation-continuity-v2.md b/docs/design/channel-conversation-continuity-v2.md index 6994cf3d3..a4da37185 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 and S3 implemented and locally verified; S4-S7 pending +Status: approved design baseline; S-1 and S0 merged; S1+S2, S3, and S4 implemented and locally verified; S5-S7 pending Date: 2026-08-08 Last verified: 2026-08-10 Scope: channel conversation persistence, Thread/Turn/Item topology, context segmentation, diff --git a/docs/design/channel-intent-workflow-selector.md b/docs/design/channel-intent-workflow-selector.md index 092440c67..b93a47078 100644 --- a/docs/design/channel-intent-workflow-selector.md +++ b/docs/design/channel-intent-workflow-selector.md @@ -1,6 +1,6 @@ # Channel Intent To Workflow Selector -Status: current v1 implementation reference; v2 clean-break contract approved in Channel Conversation Continuity v2 +Status: superseded v1 implementation reference; S4 runtime now uses the v2 clean-break contract in Channel Conversation Continuity v2 Date: 2026-06-10 Related docs: diff --git a/docs/design/implementation-status.md b/docs/design/implementation-status.md index 16663e5b8..3e7652e97 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 and S0 merged; S1+S2 and S3 implemented and locally verified | S4-S7, final migration, recovery, performance, security, eval, and Web E2E gates pass | +| [Channel Conversation Continuity v2](channel-conversation-continuity-v2-implementation-plan.md) | G0 and S0 merged; S1+S2, S3, and S4 implemented and locally verified | S5-S7, final migration, recovery, performance, security, eval, and Web E2E gates pass | | [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/packages/api-client/src/endpoints/channels.ts b/packages/api-client/src/endpoints/channels.ts index c18860ba9..18382df4d 100644 --- a/packages/api-client/src/endpoints/channels.ts +++ b/packages/api-client/src/endpoints/channels.ts @@ -349,11 +349,11 @@ export const channelEndpoints = { async listChannelIntentDecisions(this: LingDaemonEndpointContext, cwd: string, channelId: string, - options: { readonly sourceMessageId?: string; readonly action?: string; readonly status?: string; readonly limit?: number } = {}, + options: { readonly sourceItemId?: string; readonly action?: string; readonly status?: string; readonly limit?: number } = {}, ): Promise { return this._get(`/channels/${encodeURIComponent(channelId)}/intent-decisions`, { cwd, - sourceMessageId: options.sourceMessageId, + sourceItemId: options.sourceItemId, action: options.action, status: options.status, limit: options.limit, diff --git a/packages/contracts/src/channels.ts b/packages/contracts/src/channels.ts index a3fa29512..82de261e0 100644 --- a/packages/contracts/src/channels.ts +++ b/packages/contracts/src/channels.ts @@ -1,6 +1,12 @@ import type { ConversationPendingReplacement, RunTool, ToolName } from "./shared.js"; import type { ConversationActiveReplyTarget, RunIntervention } from "./runtime.js"; -import type { ConversationContextProjectionMetadata } from "./conversation-continuity.js"; +import type { + ChannelIntentDecisionV2, + ChannelIntentDecisionV2Action, + ChannelIntentDecisionV2CreatedBy, + ChannelIntentDecisionV2Status, + ConversationContextProjectionMetadata, +} from "./conversation-continuity.js"; import type { ResponseLanguage, ResponseLanguagePreference } from "./settings.js"; import type { ProcedureObservationCluster, @@ -672,38 +678,10 @@ export interface ChannelMessageRouteDecisionListResponse { readonly decisions: readonly ChannelMessageRouteDecision[]; } -export type ChannelIntentDecisionAction = - | "no_reply" - | "single_reply" - | "visible_multi_reply" - | "clarify" - | "workflow_proposal" - | "direct_workflow"; - -export type ChannelIntentDecisionStatus = "accepted" | "suppressed" | "failed"; - -export type ChannelIntentDecisionCreatedBy = "deterministic" | "model"; - -export interface ChannelIntentDecision { - readonly schemaVersion: 1; - readonly id: string; - readonly workspaceId: string; - readonly channelId: string; - readonly sourceMessageId: string; - readonly action: ChannelIntentDecisionAction; - readonly selectedTeammateId?: string; - readonly selectedTeammateIds?: readonly string[]; - readonly proposalId?: string; - readonly replyMessageId?: string; - readonly replyMessageIds?: readonly string[]; - readonly workflowId?: string; - readonly reason: string; - readonly status: ChannelIntentDecisionStatus; - readonly createdBy: ChannelIntentDecisionCreatedBy; - readonly errorMessage?: string; - readonly createdAt: string; - readonly updatedAt: string; -} +export type ChannelIntentDecisionAction = ChannelIntentDecisionV2Action; +export type ChannelIntentDecisionStatus = ChannelIntentDecisionV2Status; +export type ChannelIntentDecisionCreatedBy = ChannelIntentDecisionV2CreatedBy; +export type ChannelIntentDecision = ChannelIntentDecisionV2; export interface ChannelIntentDecisionListResponse { readonly cwd: string; diff --git a/packages/core/src/storage/sqlite-run-store.ts b/packages/core/src/storage/sqlite-run-store.ts index 883e6bfc7..79990370f 100644 --- a/packages/core/src/storage/sqlite-run-store.ts +++ b/packages/core/src/storage/sqlite-run-store.ts @@ -78,7 +78,6 @@ import { createChannelDecisionId, createChannelExecutionProposalId, createChannelHandoffId, - createChannelIntentDecisionId, createChannelLinkPreviewId, createChannelMessageRouteDecisionId, type AiCouncil, @@ -497,6 +496,7 @@ import { applyContinuityV2StorageMigration, CONTINUITY_V2_STORAGE_MIGRATION_VERSION, } from "./sqlite/continuity-v2-migration.js"; +import { applyChannelIntentV2Migration } from "./sqlite/channel-intent-v2-migration.js"; import { isJsonRecord, isRecord, @@ -939,6 +939,8 @@ export class SqliteRunStore implements RunStore { assertProposalInChannel: (channelId, proposalId) => this.assertProposalInChannel(channelId, proposalId), assertWorkflowInChannel: (channelId, workflowId) => this.assertWorkflowInChannel(channelId, workflowId), hasAiTeammate: (id) => Boolean(this.getAiTeammate(id)), + getConversationItem: (id) => this.getConversationItem(id), + getEffectiveTurnContextAssignment: (turnId) => this.getEffectiveTurnContextAssignment(turnId), }, ); this.channelProposals = new SqliteChannelProposalStore( @@ -1097,6 +1099,12 @@ export class SqliteRunStore implements RunStore { }); if (!options.deferContinuityV2MigrationForTest) { this.applyContinuityV2AuthorityCutover(); + applyChannelIntentV2Migration({ + database: this.database, + appliedAt: new Date().toISOString(), + hasMigration: (version) => this.hasMigration(version), + recordMigration: (version, name) => this.recordMigration(version, name), + }); } }); } catch (error) { @@ -2778,7 +2786,9 @@ export class SqliteRunStore implements RunStore { } createChannelIntentDecision(input: CreateChannelIntentDecisionInput, now: string): ChannelIntentDecision { - if (this.legacyContinuityFixtureMode) return this.createLegacyIntentDecisionFixture(input, now); + if (this.legacyContinuityFixtureMode) { + throw new Error("Channel Intent v2 is unavailable before the Continuity v2 authority cutover."); + } return this.channelIntentDecisions.createChannelIntentDecision(input, now); } @@ -4017,41 +4027,6 @@ export class SqliteRunStore implements RunStore { return proposal; } - private createLegacyIntentDecisionFixture( - input: CreateChannelIntentDecisionInput, - now: string, - ): ChannelIntentDecision { - const decision: ChannelIntentDecision = { - schemaVersion: 1, id: createChannelIntentDecisionId(), workspaceId: DEFAULT_WORKSPACE_ID, - channelId: input.channelId, sourceMessageId: input.sourceMessageId, action: input.action, - ...(input.selectedTeammateId ? { selectedTeammateId: input.selectedTeammateId } : {}), - ...(input.selectedTeammateIds ? { selectedTeammateIds: input.selectedTeammateIds } : {}), - ...(input.proposalId ? { proposalId: input.proposalId } : {}), - ...(input.replyMessageId ? { replyMessageId: input.replyMessageId } : {}), - ...(input.replyMessageIds ? { replyMessageIds: input.replyMessageIds } : {}), - ...(input.workflowId ? { workflowId: input.workflowId } : {}), - reason: input.reason, status: input.status, createdBy: input.createdBy, - ...(input.errorMessage ? { errorMessage: input.errorMessage } : {}), createdAt: now, updatedAt: now, - }; - this.database.prepare(` - INSERT INTO channel_intent_decisions ( - id, schema_version, workspace_id, channel_id, source_message_id, action, - selected_teammate_id, selected_teammate_ids_json, proposal_id, reply_message_id, - reply_message_ids_json, workflow_id, reason, status, created_by, error_message, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - decision.id, decision.schemaVersion, decision.workspaceId, decision.channelId, - decision.sourceMessageId, decision.action, decision.selectedTeammateId ?? null, - decision.selectedTeammateIds ? JSON.stringify(decision.selectedTeammateIds) : null, - decision.proposalId ?? null, decision.replyMessageId ?? null, - decision.replyMessageIds ? JSON.stringify(decision.replyMessageIds) : null, - decision.workflowId ?? null, decision.reason, decision.status, decision.createdBy, - decision.errorMessage ?? null, now, now, - ); - return decision; - } - private createLegacyRouteDecisionFixture( input: CreateChannelMessageRouteDecisionInput, now: string, diff --git a/packages/core/src/storage/sqlite/channel-intent-decision-store.ts b/packages/core/src/storage/sqlite/channel-intent-decision-store.ts index 11f301c8f..f3308e261 100644 --- a/packages/core/src/storage/sqlite/channel-intent-decision-store.ts +++ b/packages/core/src/storage/sqlite/channel-intent-decision-store.ts @@ -1,9 +1,12 @@ import type { SQLOutputValue } from "node:sqlite"; +import { + isChannelIntentDecisionV2, + isConversationContextProjectionMetadata, + type ConversationItemV1, + type TurnContextAssignment, +} from "@ling-stack/contracts"; import { DEFAULT_WORKSPACE_ID, - channelIntentDecisionActions, - channelIntentDecisionCreatedByValues, - channelIntentDecisionStatuses, createChannelIntentDecisionId, type ChannelIntentDecision, type CreateChannelIntentDecisionInput, @@ -19,6 +22,8 @@ export interface SqliteChannelIntentDecisionStorePorts { readonly assertProposalInChannel: (channelId: string, proposalId: string) => void; readonly assertWorkflowInChannel: (channelId: string, workflowId: string) => void; readonly hasAiTeammate: (id: string) => boolean; + readonly getConversationItem: (id: string) => ConversationItemV1 | undefined; + readonly getEffectiveTurnContextAssignment: (turnId: string) => TurnContextAssignment | undefined; } export class SqliteChannelIntentDecisionStore { @@ -28,25 +33,47 @@ export class SqliteChannelIntentDecisionStore { ) {} createChannelIntentDecision(input: CreateChannelIntentDecisionInput, now: string): ChannelIntentDecision { - this.ports.assertItemInChannel(input.channelId, input.sourceMessageId); + this.ports.assertItemInChannel(input.channelId, input.sourceItemId); + const sourceItem = this.ports.getConversationItem(input.sourceItemId); + if (!sourceItem?.turnId || sourceItem.status !== "committed") { + throw new Error(`Channel Intent v2 source must be a committed turn item: ${input.sourceItemId}`); + } + if (!isConversationContextProjectionMetadata(input.contextProjection)) { + throw new Error("Channel Intent v2 requires valid context projection metadata."); + } + const assignment = this.ports.getEffectiveTurnContextAssignment(sourceItem.turnId); + if (!assignment) { + throw new Error(`Channel Intent v2 source turn has no context assignment: ${sourceItem.turnId}`); + } + if ( + input.contextProjection.threadId !== sourceItem.threadId + || input.contextProjection.turnId !== sourceItem.turnId + || input.contextProjection.segmentId !== assignment.segmentId + || input.contextProjection.throughItemSequence !== sourceItem.sequence + ) { + throw new Error("Channel Intent v2 projection does not match the source turn assignment."); + } this.assertChannelIntentDecisionRefsInChannel(input.channelId, input); const decision: ChannelIntentDecision = { - schemaVersion: 1, + schemaVersion: 2, id: createChannelIntentDecisionId(), workspaceId: DEFAULT_WORKSPACE_ID, channelId: input.channelId, - sourceMessageId: input.sourceMessageId, + sourceTurnId: sourceItem.turnId, + sourceItemId: sourceItem.id, + contextSegmentId: assignment.segmentId, + contextProjectionVersion: input.contextProjection.projectionVersion, action: input.action, - selectedTeammateId: input.selectedTeammateId, - selectedTeammateIds: input.selectedTeammateIds, + selectedTeammateIds: input.selectedTeammateIds ?? [], proposalId: input.proposalId, - replyMessageId: input.replyMessageId, - replyMessageIds: input.replyMessageIds, + resultItemIds: input.resultItemIds ?? [], workflowId: input.workflowId, reason: input.reason, + clarification: input.clarification, + execution: input.execution, status: input.status, createdBy: input.createdBy, - errorMessage: input.errorMessage, + errorCode: input.errorCode, createdAt: now, updatedAt: now, }; @@ -58,39 +85,45 @@ export class SqliteChannelIntentDecisionStore { schema_version, workspace_id, channel_id, + source_turn_id, source_item_id, + context_segment_id, + context_projection_version, action, - selected_teammate_id, selected_teammate_ids_json, proposal_id, - reply_item_id, - reply_item_ids_json, + result_item_ids_json, workflow_id, reason, + clarification_json, + execution_json, status, created_by, - error_message, + error_code, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) .run( decision.id, decision.schemaVersion, decision.workspaceId, decision.channelId, - decision.sourceMessageId, + decision.sourceTurnId, + decision.sourceItemId, + decision.contextSegmentId, + decision.contextProjectionVersion, decision.action, - decision.selectedTeammateId ?? null, - stringifyJson(decision.selectedTeammateIds ?? []), + stringifyJson(decision.selectedTeammateIds), decision.proposalId ?? null, - decision.replyMessageId ?? null, - stringifyJson(decision.replyMessageIds ?? []), + stringifyJson(decision.resultItemIds), decision.workflowId ?? null, decision.reason, + decision.clarification ? stringifyJson(decision.clarification) : null, + decision.execution ? stringifyJson(decision.execution) : null, decision.status, decision.createdBy, - decision.errorMessage ?? null, + decision.errorCode ?? null, decision.createdAt, decision.updatedAt, ); @@ -113,9 +146,9 @@ export class SqliteChannelIntentDecisionStore { clauses.push("channel_id = ?"); params.push(options.channelId); } - if (options.sourceMessageId) { + if (options.sourceItemId) { clauses.push("source_item_id = ?"); - params.push(options.sourceMessageId); + params.push(options.sourceItemId); } if (options.action) { clauses.push("action = ?"); @@ -135,11 +168,8 @@ export class SqliteChannelIntentDecisionStore { private assertChannelIntentDecisionRefsInChannel( channelId: string, - input: Pick, + input: Pick, ): void { - if (input.selectedTeammateId && !this.ports.hasAiTeammate(input.selectedTeammateId)) { - throw new Error(`AI teammate not found: ${input.selectedTeammateId}`); - } for (const teammateId of input.selectedTeammateIds ?? []) { if (!this.ports.hasAiTeammate(teammateId)) { throw new Error(`AI teammate not found: ${teammateId}`); @@ -148,35 +178,21 @@ export class SqliteChannelIntentDecisionStore { if (input.proposalId) { this.ports.assertProposalInChannel(channelId, input.proposalId); } - if (input.replyMessageId) { - this.ports.assertItemInChannel(channelId, input.replyMessageId); - } - for (const replyMessageId of input.replyMessageIds ?? []) { - this.ports.assertItemInChannel(channelId, replyMessageId); + for (const resultItemId of input.resultItemIds ?? []) { + this.ports.assertItemInChannel(channelId, resultItemId); } if (input.workflowId) { this.ports.assertWorkflowInChannel(channelId, input.workflowId); } + const targetRef = input.execution?.targetRef; + if (targetRef && (targetRef.type === "conversation_item" || targetRef.type === "message")) { + this.ports.assertItemInChannel(channelId, targetRef.id); + } } } -function validateChannelIntentDecisionInput( - decision: Pick, -): void { - if (!channelIntentDecisionActions.includes(decision.action)) { - throw new Error("channel intent decision action is not supported"); - } - if (!channelIntentDecisionStatuses.includes(decision.status)) { - throw new Error("channel intent decision status is not supported"); - } - if (!channelIntentDecisionCreatedByValues.includes(decision.createdBy)) { - throw new Error("channel intent decision createdBy is not supported"); - } - validateNonEmptyString(decision.reason, "channel intent decision reason"); -} - -function validateNonEmptyString(value: string, label: string): void { - if (!value.trim()) { - throw new Error(`${label} must not be empty`); +function validateChannelIntentDecisionInput(decision: ChannelIntentDecision): void { + if (!isChannelIntentDecisionV2(decision)) { + throw new Error("Channel Intent v2 decision failed schema validation."); } } diff --git a/packages/core/src/storage/sqlite/channel-intent-v2-migration.ts b/packages/core/src/storage/sqlite/channel-intent-v2-migration.ts new file mode 100644 index 000000000..d564cdb57 --- /dev/null +++ b/packages/core/src/storage/sqlite/channel-intent-v2-migration.ts @@ -0,0 +1,105 @@ +import type { DatabaseSync } from "node:sqlite"; + +export const CHANNEL_INTENT_V2_MIGRATION_VERSION = 111; +export const CHANNEL_INTENT_V2_MIGRATION_NAME = "replace_channel_intent_decisions_with_v2"; + +export interface ChannelIntentV2MigrationContext { + readonly database: DatabaseSync; + readonly appliedAt: string; + readonly hasMigration: (version: number) => boolean; + readonly recordMigration: (version: number, name: string) => void; +} + +export function applyChannelIntentV2Migration(input: ChannelIntentV2MigrationContext): boolean { + if (input.hasMigration(CHANNEL_INTENT_V2_MIGRATION_VERSION)) return false; + assertCanonicalTimestamp(input.appliedAt); + assertContinuityV2Authority(input.database); + + input.database.exec("BEGIN IMMEDIATE;"); + try { + input.database.exec(` + DROP INDEX IF EXISTS idx_channel_intent_decisions_channel_created; + DROP INDEX IF EXISTS idx_channel_intent_decisions_source; + DROP INDEX IF EXISTS idx_channel_intent_decisions_status; + + ALTER TABLE channel_intent_decisions RENAME TO channel_intent_decisions_v1_removed; + + CREATE TABLE channel_intent_decisions ( + id TEXT PRIMARY KEY, + schema_version INTEGER NOT NULL CHECK (schema_version = 2), + workspace_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + source_turn_id TEXT NOT NULL, + source_item_id TEXT NOT NULL, + context_segment_id TEXT NOT NULL, + context_projection_version TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ( + 'no_reply', 'reply', 'visible_multi_reply', 'clarify', 'workflow_proposal', 'execute' + )), + selected_teammate_ids_json TEXT NOT NULL, + proposal_id TEXT, + result_item_ids_json TEXT NOT NULL, + workflow_id TEXT, + reason TEXT NOT NULL, + clarification_json TEXT, + execution_json TEXT, + status TEXT NOT NULL CHECK (status IN ('accepted', 'suppressed', 'failed')), + created_by TEXT NOT NULL CHECK (created_by IN ('deterministic', 'model_policy')), + error_code TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + FOREIGN KEY(channel_id) REFERENCES channels(id) ON DELETE CASCADE, + FOREIGN KEY(source_turn_id) REFERENCES ling_turns(id) ON DELETE CASCADE, + FOREIGN KEY(source_item_id) REFERENCES conversation_items(id) ON DELETE CASCADE, + FOREIGN KEY(context_segment_id) REFERENCES context_segments(id) ON DELETE RESTRICT, + FOREIGN KEY(proposal_id) REFERENCES channel_execution_proposals(id) ON DELETE SET NULL, + FOREIGN KEY(workflow_id) REFERENCES channel_workflows(id) ON DELETE SET NULL + ); + + CREATE INDEX idx_channel_intent_decisions_channel_created + ON channel_intent_decisions(workspace_id, channel_id, created_at); + + CREATE INDEX idx_channel_intent_decisions_source + ON channel_intent_decisions(workspace_id, source_item_id); + + CREATE INDEX idx_channel_intent_decisions_turn + ON channel_intent_decisions(workspace_id, source_turn_id, created_at); + + CREATE INDEX idx_channel_intent_decisions_segment + ON channel_intent_decisions(workspace_id, context_segment_id, created_at); + + CREATE INDEX idx_channel_intent_decisions_status + ON channel_intent_decisions(workspace_id, channel_id, status, updated_at); + + DROP TABLE channel_intent_decisions_v1_removed; + `); + input.recordMigration(CHANNEL_INTENT_V2_MIGRATION_VERSION, CHANNEL_INTENT_V2_MIGRATION_NAME); + input.database.exec("COMMIT;"); + return true; + } catch (error) { + if (input.database.isTransaction) input.database.exec("ROLLBACK;"); + throw error; + } +} + +function assertContinuityV2Authority(database: DatabaseSync): void { + for (const table of [ + "channel_intent_decisions", + "conversation_items", + "ling_turns", + "context_segments", + ]) { + const exists = database + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(table); + if (!exists) throw new Error(`Channel Intent v2 migration requires table: ${table}`); + } +} + +function assertCanonicalTimestamp(value: string): void { + const parsed = Date.parse(value); + if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== value) { + throw new Error(`Expected canonical ISO timestamp: ${value}`); + } +} diff --git a/packages/core/src/storage/sqlite/channel-routing-mappers.ts b/packages/core/src/storage/sqlite/channel-routing-mappers.ts index 5d0eb9bc6..30a7f9ef0 100644 --- a/packages/core/src/storage/sqlite/channel-routing-mappers.ts +++ b/packages/core/src/storage/sqlite/channel-routing-mappers.ts @@ -1,9 +1,12 @@ import type { SQLOutputValue } from "node:sqlite"; -import { isConversationContextProjectionMetadata } from "@ling-stack/contracts"; +import { + isChannelIntentDecisionV2, + isConversationContextProjectionMetadata, + type ChannelIntentClarificationV2, + type ChannelIntentExecutionV2, +} from "@ling-stack/contracts"; import { type ChannelIntentDecision, - type ChannelIntentDecisionAction, - type ChannelIntentDecisionStatus, type ChannelMessageRouteAlternative, type ChannelMessageRouteContextPackMetadata, type ChannelMessageRouteDecision, @@ -45,26 +48,42 @@ export function mapChannelMessageRouteDecision(row: Record): ChannelIntentDecision { - return omitUndefined({ - schemaVersion: readNumber(row, "schema_version") as 1, + const decision = omitUndefined({ + schemaVersion: readNumber(row, "schema_version") as 2, id: readString(row, "id"), workspaceId: readString(row, "workspace_id"), channelId: readString(row, "channel_id"), - sourceMessageId: readString(row, "source_message_id"), - action: readString(row, "action") as ChannelIntentDecisionAction, - selectedTeammateId: readNullableString(row, "selected_teammate_id"), + sourceTurnId: readString(row, "source_turn_id"), + sourceItemId: readString(row, "source_item_id"), + contextSegmentId: readString(row, "context_segment_id"), + contextProjectionVersion: readString(row, "context_projection_version"), + action: readString(row, "action") as ChannelIntentDecision["action"], selectedTeammateIds: parseJsonStringArray(readNullableString(row, "selected_teammate_ids_json") ?? "[]"), proposalId: readNullableString(row, "proposal_id"), - replyMessageId: readNullableString(row, "reply_message_id"), - replyMessageIds: parseJsonStringArray(readNullableString(row, "reply_message_ids_json") ?? "[]"), + resultItemIds: parseJsonStringArray(readNullableString(row, "result_item_ids_json") ?? "[]"), workflowId: readNullableString(row, "workflow_id"), reason: readString(row, "reason"), - status: readString(row, "status") as ChannelIntentDecisionStatus, + clarification: parseOptionalIntentObject(readNullableString(row, "clarification_json")), + execution: parseOptionalIntentObject(readNullableString(row, "execution_json")), + status: readString(row, "status") as ChannelIntentDecision["status"], createdBy: readString(row, "created_by") as ChannelIntentDecision["createdBy"], - errorMessage: readNullableString(row, "error_message"), + errorCode: readNullableString(row, "error_code"), createdAt: readString(row, "created_at"), updatedAt: readString(row, "updated_at"), }); + if (!isChannelIntentDecisionV2(decision)) { + throw new Error(`Invalid Channel Intent v2 row: ${readString(row, "id")}`); + } + return decision; +} + +function parseOptionalIntentObject(value: string | undefined): T | undefined { + if (!value) return undefined; + const parsed = parseJson(value); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("Expected Channel Intent v2 structured object from SQLite"); + } + return parsed as T; } function parseChannelMessageRouteAlternatives(value: string): ChannelMessageRouteAlternative[] { diff --git a/packages/core/src/storage/sqlite/migrations.ts b/packages/core/src/storage/sqlite/migrations.ts index 16cb8c1ba..37e5236e3 100644 --- a/packages/core/src/storage/sqlite/migrations.ts +++ b/packages/core/src/storage/sqlite/migrations.ts @@ -1514,8 +1514,9 @@ export function applySqliteRunStoreMigrations(context: SqliteRunStoreMigrationCo } } - const hasChannelIntentDecisionsMigration = context.hasMigration(CHANNEL_INTENT_DECISIONS_MIGRATION_VERSION); - if (!hasChannelIntentDecisionsMigration || !context.hasTable("channel_intent_decisions")) { + if (!continuityV2Active) { + const hasChannelIntentDecisionsMigration = context.hasMigration(CHANNEL_INTENT_DECISIONS_MIGRATION_VERSION); + if (!hasChannelIntentDecisionsMigration || !context.hasTable("channel_intent_decisions")) { database.exec(` CREATE TABLE IF NOT EXISTS channel_intent_decisions ( id TEXT PRIMARY KEY, @@ -1556,22 +1557,23 @@ export function applySqliteRunStoreMigrations(context: SqliteRunStoreMigrationCo if (!hasChannelIntentDecisionsMigration) { context.recordMigration(CHANNEL_INTENT_DECISIONS_MIGRATION_VERSION, CHANNEL_INTENT_DECISIONS_MIGRATION_NAME); } - } - - const hasChannelVisibleMultiReplyIntentMigration = context.hasMigration(CHANNEL_VISIBLE_MULTI_REPLY_INTENT_MIGRATION_VERSION); - if ( - !hasChannelVisibleMultiReplyIntentMigration || - !context.hasColumn("channel_intent_decisions", "selected_teammate_ids_json") || - !context.hasColumn("channel_intent_decisions", "reply_message_ids_json") - ) { - if (context.hasTable("channel_intent_decisions") && !context.hasColumn("channel_intent_decisions", "selected_teammate_ids_json")) { - database.exec("ALTER TABLE channel_intent_decisions ADD COLUMN selected_teammate_ids_json TEXT;"); - } - if (context.hasTable("channel_intent_decisions") && !context.hasColumn("channel_intent_decisions", "reply_message_ids_json")) { - database.exec("ALTER TABLE channel_intent_decisions ADD COLUMN reply_message_ids_json TEXT;"); } - if (!hasChannelVisibleMultiReplyIntentMigration) { - context.recordMigration(CHANNEL_VISIBLE_MULTI_REPLY_INTENT_MIGRATION_VERSION, CHANNEL_VISIBLE_MULTI_REPLY_INTENT_MIGRATION_NAME); + + const hasChannelVisibleMultiReplyIntentMigration = context.hasMigration(CHANNEL_VISIBLE_MULTI_REPLY_INTENT_MIGRATION_VERSION); + if ( + !hasChannelVisibleMultiReplyIntentMigration || + !context.hasColumn("channel_intent_decisions", "selected_teammate_ids_json") || + !context.hasColumn("channel_intent_decisions", "reply_message_ids_json") + ) { + if (context.hasTable("channel_intent_decisions") && !context.hasColumn("channel_intent_decisions", "selected_teammate_ids_json")) { + database.exec("ALTER TABLE channel_intent_decisions ADD COLUMN selected_teammate_ids_json TEXT;"); + } + if (context.hasTable("channel_intent_decisions") && !context.hasColumn("channel_intent_decisions", "reply_message_ids_json")) { + database.exec("ALTER TABLE channel_intent_decisions ADD COLUMN reply_message_ids_json TEXT;"); + } + if (!hasChannelVisibleMultiReplyIntentMigration) { + context.recordMigration(CHANNEL_VISIBLE_MULTI_REPLY_INTENT_MIGRATION_VERSION, CHANNEL_VISIBLE_MULTI_REPLY_INTENT_MIGRATION_NAME); + } } } diff --git a/packages/core/src/storage/sqlite/select-sql.ts b/packages/core/src/storage/sqlite/select-sql.ts index 42c299adf..6398ba1ef 100644 --- a/packages/core/src/storage/sqlite/select-sql.ts +++ b/packages/core/src/storage/sqlite/select-sql.ts @@ -1073,18 +1073,21 @@ export function channelIntentDecisionSelectSql(whereClause: string): string { id, workspace_id, channel_id, - source_item_id AS source_message_id, + source_turn_id, + source_item_id, + context_segment_id, + context_projection_version, action, - selected_teammate_id, selected_teammate_ids_json, proposal_id, - reply_item_id AS reply_message_id, - reply_item_ids_json AS reply_message_ids_json, + result_item_ids_json, workflow_id, reason, + clarification_json, + execution_json, status, created_by, - error_message, + error_code, created_at, updated_at FROM channel_intent_decisions diff --git a/packages/core/src/workspace-types.ts b/packages/core/src/workspace-types.ts index 80b9f7348..3487426df 100644 --- a/packages/core/src/workspace-types.ts +++ b/packages/core/src/workspace-types.ts @@ -6,7 +6,18 @@ import type { AuthorizationMode, PermissionPreset, SessionPermissionMode, Worksp import type { SkillCandidateSourceRef } from "./skill-candidate-types.js"; import type { AiTeammateRoleKey, SkillApprovalProfile } from "./skill-types.js"; import type { WorkflowTerminationPolicy } from "./workflow-graph-types.js"; -import type { ConversationContextProjectionMetadata } from "@ling-stack/contracts"; +import { + channelIntentDecisionV2Actions, + channelIntentDecisionV2CreatedByValues, + channelIntentDecisionV2Statuses, + type ChannelIntentClarificationV2, + type ChannelIntentDecisionV2, + type ChannelIntentDecisionV2Action, + type ChannelIntentDecisionV2CreatedBy, + type ChannelIntentDecisionV2Status, + type ChannelIntentExecutionV2, + type ConversationContextProjectionMetadata, +} from "@ling-stack/contracts"; export const DEFAULT_WORKSPACE_ID = "workspace_default"; @@ -1418,71 +1429,34 @@ export interface ListChannelMessageRouteDecisionsOptions { readonly limit?: number; } -export type ChannelIntentDecisionAction = - | "no_reply" - | "single_reply" - | "visible_multi_reply" - | "clarify" - | "workflow_proposal" - | "direct_workflow"; - -export const channelIntentDecisionActions: readonly ChannelIntentDecisionAction[] = [ - "no_reply", - "single_reply", - "visible_multi_reply", - "clarify", - "workflow_proposal", - "direct_workflow", -]; - -export type ChannelIntentDecisionStatus = "accepted" | "suppressed" | "failed"; - -export const channelIntentDecisionStatuses: readonly ChannelIntentDecisionStatus[] = ["accepted", "suppressed", "failed"]; - -export type ChannelIntentDecisionCreatedBy = "deterministic" | "model"; - -export const channelIntentDecisionCreatedByValues: readonly ChannelIntentDecisionCreatedBy[] = ["deterministic", "model"]; - -export interface ChannelIntentDecision { - readonly schemaVersion: 1; - readonly id: string; - readonly workspaceId: string; - readonly channelId: string; - readonly sourceMessageId: string; - readonly action: ChannelIntentDecisionAction; - readonly selectedTeammateId?: string; - readonly selectedTeammateIds?: readonly string[]; - readonly proposalId?: string; - readonly replyMessageId?: string; - readonly replyMessageIds?: readonly string[]; - readonly workflowId?: string; - readonly reason: string; - readonly status: ChannelIntentDecisionStatus; - readonly createdBy: ChannelIntentDecisionCreatedBy; - readonly errorMessage?: string; - readonly createdAt: string; - readonly updatedAt: string; -} +export type ChannelIntentDecisionAction = ChannelIntentDecisionV2Action; +export const channelIntentDecisionActions: readonly ChannelIntentDecisionAction[] = channelIntentDecisionV2Actions; +export type ChannelIntentDecisionStatus = ChannelIntentDecisionV2Status; +export const channelIntentDecisionStatuses: readonly ChannelIntentDecisionStatus[] = channelIntentDecisionV2Statuses; +export type ChannelIntentDecisionCreatedBy = ChannelIntentDecisionV2CreatedBy; +export const channelIntentDecisionCreatedByValues: readonly ChannelIntentDecisionCreatedBy[] = channelIntentDecisionV2CreatedByValues; +export type ChannelIntentDecision = ChannelIntentDecisionV2; export interface CreateChannelIntentDecisionInput { readonly channelId: string; - readonly sourceMessageId: string; + readonly sourceItemId: string; + readonly contextProjection: ConversationContextProjectionMetadata; readonly action: ChannelIntentDecisionAction; - readonly selectedTeammateId?: string; readonly selectedTeammateIds?: readonly string[]; readonly proposalId?: string; - readonly replyMessageId?: string; - readonly replyMessageIds?: readonly string[]; + readonly resultItemIds?: readonly string[]; readonly workflowId?: string; readonly reason: string; + readonly clarification?: ChannelIntentClarificationV2; + readonly execution?: ChannelIntentExecutionV2; readonly status: ChannelIntentDecisionStatus; readonly createdBy: ChannelIntentDecisionCreatedBy; - readonly errorMessage?: string; + readonly errorCode?: string; } export interface ListChannelIntentDecisionsOptions { readonly channelId?: string; - readonly sourceMessageId?: string; + readonly sourceItemId?: string; readonly action?: ChannelIntentDecisionAction; readonly status?: ChannelIntentDecisionStatus; readonly limit?: number; diff --git a/packages/core/test/channel-intent-v2-migration.test.mjs b/packages/core/test/channel-intent-v2-migration.test.mjs new file mode 100644 index 000000000..87faa5d40 --- /dev/null +++ b/packages/core/test/channel-intent-v2-migration.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import test from "node:test"; +import { + createLegacySqliteRunStoreForMigrationTest, + createSqliteRunStore, + resolveRuntimePaths, +} from "../dist/index.js"; +import { CHANNEL_INTENT_V2_MIGRATION_VERSION } from "../dist/storage/sqlite/channel-intent-v2-migration.js"; + +test("Channel Intent v2 migration deletes v1 rows and preserves new v2 decisions on reopen", async (t) => { + const cwd = await mkdtemp(join(tmpdir(), "ling-channel-intent-v2-")); + 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({ + channelId: channel.id, + authorType: "local-user", + body: "Write an article", + }, "2026-08-10T10:01:00.000Z"); + const legacyDatabase = new DatabaseSync(resolveRuntimePaths(cwd).databasePath); + legacyDatabase.prepare(` + INSERT INTO channel_intent_decisions ( + id, schema_version, workspace_id, channel_id, source_message_id, action, + selected_teammate_id, selected_teammate_ids_json, proposal_id, reply_message_id, + reply_message_ids_json, workflow_id, reason, status, created_by, error_message, + created_at, updated_at + ) VALUES ( + 'legacy-intent', 1, 'workspace_default', ?, ?, 'single_reply', + 'ling_agent', '[]', NULL, NULL, '[]', NULL, 'legacy', 'accepted', + 'deterministic', NULL, '2026-08-10T10:01:01.000Z', '2026-08-10T10:01:01.000Z' + ) + `).run(channel.id, message.id); + legacyDatabase.close(); + legacy.close(); + + let store = createSqliteRunStore(cwd); + assert.deepEqual(store.listChannelIntentDecisions({ channelId: channel.id }), []); + const sourceItem = store.getConversationItem(message.id); + assert.ok(sourceItem?.turnId); + const assignment = store.getEffectiveTurnContextAssignment(sourceItem.turnId); + assert.ok(assignment); + const decision = store.createChannelIntentDecision({ + channelId: channel.id, + sourceItemId: sourceItem.id, + contextProjection: projectionFor(sourceItem, assignment), + action: "reply", + reason: "Ordinary content reply.", + status: "accepted", + createdBy: "deterministic", + }, "2026-08-10T10:02:00.000Z"); + assert.equal(decision.schemaVersion, 2); + assert.equal(decision.action, "reply"); + assert.deepEqual(decision.selectedTeammateIds, []); + assert.deepEqual(decision.resultItemIds, []); + store.close(); + + store = createSqliteRunStore(cwd); + assert.equal(store.getChannelIntentDecision(decision.id)?.id, decision.id); + store.close(); + + const database = new DatabaseSync(resolveRuntimePaths(cwd).databasePath, { readOnly: true }); + const columns = database.prepare("PRAGMA table_info(channel_intent_decisions)").all().map((row) => row.name); + assert.equal(columns.includes("source_turn_id"), true); + assert.equal(columns.includes("context_segment_id"), true); + assert.equal(columns.includes("context_projection_version"), true); + assert.equal(columns.includes("clarification_json"), true); + assert.equal(columns.includes("execution_json"), true); + assert.equal(columns.includes("source_message_id"), false); + assert.equal(columns.includes("selected_teammate_id"), false); + assert.equal(columns.includes("reply_message_ids_json"), false); + assert.ok(database.prepare("SELECT 1 FROM schema_migrations WHERE version = ?").get(CHANNEL_INTENT_V2_MIGRATION_VERSION)); + assert.deepEqual(database.prepare("PRAGMA foreign_key_check").all(), []); + assert.equal(database.prepare("PRAGMA integrity_check").get().integrity_check, "ok"); + database.close(); +}); + +function projectionFor(item, assignment) { + return { + projectionVersion: `projection:${item.id}:${assignment.revision}`, + threadId: item.threadId, + turnId: item.turnId, + segmentId: assignment.segmentId, + throughItemSequence: item.sequence, + summaryIds: [], + sourceRefs: [ + { type: "thread", id: item.threadId }, + { type: "turn", id: item.turnId }, + { type: "conversation_item", id: item.id }, + ], + policyVersion: "test-projector-v1", + visibilityFingerprint: "channel_shared", + }; +} diff --git a/packages/core/test/channel-runtime-store.test.mjs b/packages/core/test/channel-runtime-store.test.mjs index 7d7ba85a9..a4c01c231 100644 --- a/packages/core/test/channel-runtime-store.test.mjs +++ b/packages/core/test/channel-runtime-store.test.mjs @@ -341,10 +341,11 @@ test("sqlite channel intent decisions round trip visible multi-reply ids", async const decision = store.createChannelIntentDecision( { channelId: channel.id, - sourceMessageId: message.id, + sourceItemId: message.id, + contextProjection: contextProjectionForItem(store, message.id), action: "visible_multi_reply", selectedTeammateIds: ["teammate_builtin_architect", "teammate_builtin_engineer"], - replyMessageIds: [architectReply.id, engineerReply.id], + resultItemIds: [architectReply.id, engineerReply.id], reason: "Visible multi-reply created for discussion.", status: "accepted", createdBy: "deterministic", @@ -354,17 +355,40 @@ test("sqlite channel intent decisions round trip visible multi-reply ids", async assert.equal(decision.action, "visible_multi_reply"); assert.deepEqual(decision.selectedTeammateIds, ["teammate_builtin_architect", "teammate_builtin_engineer"]); - assert.deepEqual(decision.replyMessageIds, [architectReply.id, engineerReply.id]); - const [listed] = store.listChannelIntentDecisions({ channelId: channel.id, sourceMessageId: message.id }); + assert.deepEqual(decision.resultItemIds, [architectReply.id, engineerReply.id]); + assert.equal(decision.schemaVersion, 2); + const [listed] = store.listChannelIntentDecisions({ channelId: channel.id, sourceItemId: message.id }); assert.equal(listed.id, decision.id); assert.deepEqual(listed.selectedTeammateIds, decision.selectedTeammateIds); - assert.deepEqual(listed.replyMessageIds, decision.replyMessageIds); + assert.deepEqual(listed.resultItemIds, decision.resultItemIds); } finally { store.close(); await rm(cwd, { recursive: true, force: true }); } }); +function contextProjectionForItem(store, itemId) { + const item = store.getConversationItem(itemId); + assert.ok(item?.turnId); + const assignment = store.getEffectiveTurnContextAssignment(item.turnId); + assert.ok(assignment); + return { + projectionVersion: `projection:${item.id}:${assignment.revision}`, + threadId: item.threadId, + turnId: item.turnId, + segmentId: assignment.segmentId, + throughItemSequence: item.sequence, + summaryIds: [], + sourceRefs: [ + { type: "thread", id: item.threadId }, + { type: "turn", id: item.turnId }, + { type: "conversation_item", id: item.id }, + ], + policyVersion: "test-projector-v1", + visibilityFingerprint: "channel_shared", + }; +} + test("sqlite channels persist workspace binding and workspace settings", async () => { const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workspace-binding-")); const now = "2026-06-09T00:00:00.000Z"; diff --git a/packages/core/test/continuity-v2-migration.test.mjs b/packages/core/test/continuity-v2-migration.test.mjs index b76e12ec7..39e9f2674 100644 --- a/packages/core/test/continuity-v2-migration.test.mjs +++ b/packages/core/test/continuity-v2-migration.test.mjs @@ -194,7 +194,8 @@ test("Continuity v2 migration atomically imports root, topic and branch timeline requiredTools: ["write"], status: "draft", }, rootUser.createdAt); - store.createChannelIntentDecision({ + insertLegacyIntentDecision(cwd, { + id: "intent-root-reply", channelId: channel.id, sourceMessageId: rootUser.id, action: "single_reply", @@ -204,8 +205,10 @@ test("Continuity v2 migration atomically imports root, topic and branch timeline reason: "Ling replied.", status: "accepted", createdBy: "deterministic", - }, rootReply.createdAt); - store.createChannelIntentDecision({ + createdAt: rootReply.createdAt, + }); + insertLegacyIntentDecision(cwd, { + id: "intent-topic-reply", channelId: channel.id, sourceMessageId: topicUser.id, action: "single_reply", @@ -214,7 +217,8 @@ test("Continuity v2 migration atomically imports root, topic and branch timeline reason: "Architect replied.", status: "accepted", createdBy: "deterministic", - }, topicReply.createdAt); + createdAt: topicReply.createdAt, + }); const route = store.createChannelMessageRouteDecision({ channelId: channel.id, sourceMessageId: branchUser.id, @@ -485,6 +489,33 @@ function migrate(database) { }); } +function insertLegacyIntentDecision(cwd, input) { + const database = new DatabaseSync(resolveRuntimePaths(cwd).databasePath); + try { + database.prepare(` + INSERT INTO channel_intent_decisions ( + id, schema_version, workspace_id, channel_id, source_message_id, action, + selected_teammate_id, selected_teammate_ids_json, proposal_id, reply_message_id, + reply_message_ids_json, workflow_id, reason, status, created_by, error_message, + created_at, updated_at + ) VALUES (?, 1, 'workspace_default', ?, ?, ?, ?, '[]', ?, ?, '[]', NULL, ?, 'accepted', 'deterministic', NULL, ?, ?) + `).run( + input.id, + input.channelId, + input.sourceMessageId, + input.action, + input.selectedTeammateId ?? null, + input.proposalId ?? null, + input.replyMessageId ?? null, + input.reason, + input.createdAt, + input.createdAt, + ); + } finally { + database.close(); + } +} + function tableNames(database, names) { return database.prepare(` SELECT name FROM sqlite_master diff --git a/packages/daemon/src/features/channels/channel-intent-selector.ts b/packages/daemon/src/features/channels/channel-intent-selector.ts index 35e9a55fd..aa69b31e2 100644 --- a/packages/daemon/src/features/channels/channel-intent-selector.ts +++ b/packages/daemon/src/features/channels/channel-intent-selector.ts @@ -12,6 +12,10 @@ import type { RunStore, SecretResolver, } from "@ling-stack/core"; +import type { + ChannelIntentClarificationV2, + ChannelIntentExecutionV2, +} from "@ling-stack/contracts"; import type { ResolvedDaemonConfig } from "../../config.js"; import type { RunnerRegistry } from "../../runtime/runners.js"; import { extractAssistantText } from "../../shared/assistant-text.js"; @@ -74,7 +78,7 @@ export type ChannelIntentSelectorResult = readonly action: "visible_multi_reply"; readonly teammateIds: readonly string[]; readonly reason: string; - readonly createdBy: "deterministic" | "model"; + readonly createdBy: "deterministic" | "model_policy"; readonly aiReplies?: readonly ChannelMessage[]; readonly intentDecisionId?: string; } @@ -104,18 +108,12 @@ interface MaterializationClarifyReason { } interface ChannelIntentModelOutput { - readonly action: "no_reply" | "single_reply" | "visible_multi_reply" | "clarify" | "workflow_proposal" | "direct_workflow"; + readonly action: "no_reply" | "reply" | "visible_multi_reply" | "clarify" | "workflow_proposal" | "execute"; readonly reason: string; - readonly operationKind?: ChannelIntentModelOperationKind; + readonly clarification?: ChannelIntentClarificationV2; + readonly execution?: ChannelIntentExecutionV2; } -type ChannelIntentModelOperationKind = - | "file_create" - | "file_edit" - | "open_local_target" - | "run_command" - | "unknown"; - interface RoleMatch { readonly roleKey: AiTeammate["roleKey"]; readonly summary: string; @@ -132,6 +130,7 @@ type ProposalParticipantSelection = { interface SingleStepExecutableIntent { readonly kind: "file_create" | "file_edit" | "command_run"; + readonly execution: ChannelIntentExecutionV2; readonly title: string; readonly prompt: string; readonly actionSummary: string; @@ -149,6 +148,7 @@ interface SingleStepExecutableBlocker { interface SingleStepExecutableClarify { readonly reasonCode: ClarifyReasonCode; + readonly missingFields?: readonly string[]; readonly userMessage?: string; readonly technicalReason: string; } @@ -224,7 +224,7 @@ export async function selectChannelWorkflowIntent(input: ChannelIntentSelectorIn return modelIntent; } if (input.config?.channels.intentSelector) { - return { action: "continue" }; + return createReplyIntentResult(input, "Intent model routing was unavailable; ordinary reply fallback applied."); } const executableClarify = detectSingleStepExecutableClarify(input); if (executableClarify) { @@ -234,6 +234,12 @@ export async function selectChannelWorkflowIntent(input: ChannelIntentSelectorIn "deterministic", executableClarify.technicalReason, executableClarify.userMessage, + executableClarify.userMessage + ? { + missingFields: executableClarify.missingFields ?? missingFieldsForClarifyReason(executableClarify.reasonCode), + question: executableClarify.userMessage, + } + : undefined, ); } const executableIntent = detectSingleStepExecutableIntent(input); @@ -249,7 +255,7 @@ export async function selectChannelWorkflowIntent(input: ChannelIntentSelectorIn return { action: "continue" }; } if (resolveChannelConversationSurface(input.store, input.channel.id, input.sourceMessage).kind !== "root") { - return { action: "continue" }; + return createReplyIntentResult(input, "Non-root Ling turn remains an ordinary reply."); } return selectDeterministicChannelWorkflowIntent(input); } @@ -271,7 +277,7 @@ function selectDeterministicChannelWorkflowIntent(input: ChannelIntentSelectorIn if (!reasonCode) { const clarifyReasonCode = detectClarifyIntentReason(input.sourceMessage); if (!clarifyReasonCode) { - return { action: "continue" }; + return createReplyIntentResult(input, "Deterministic fallback kept the turn as an ordinary reply."); } return createClarifyReply(input, clarifyReasonCode); } @@ -281,7 +287,7 @@ function selectDeterministicChannelWorkflowIntent(input: ChannelIntentSelectorIn function createWorkflowIntentResult( input: ChannelIntentSelectorInput, reasonCode: IntentReasonCode, - createdBy: "deterministic" | "model", + createdBy: "deterministic" | "model_policy", decisionReason?: string, ): ChannelIntentSelectorResult { const existingProposal = input.store @@ -314,38 +320,59 @@ async function maybeSelectModelAssistedIntent(input: ChannelIntentSelectorInput) try { const output = await requestModelIntent(input); return applyModelIntentOutput(input, output); - } catch { - return undefined; + } catch (error) { + return createReplyIntentResult( + input, + `Intent model output was unavailable or invalid; fail-open to an ordinary reply. ${error instanceof Error ? error.message : String(error)}`, + ); } } +function createReplyIntentResult( + input: ChannelIntentSelectorInput, + reason: string, + createdBy: "deterministic" | "model_policy" = "deterministic", +): ChannelIntentSelectorResult { + persistIntentDecision(input, { + action: "reply", + reason, + status: "accepted", + createdBy, + }); + return { action: "continue" }; +} + function applyModelIntentOutput(input: ChannelIntentSelectorInput, output: ChannelIntentModelOutput): ChannelIntentSelectorResult | undefined { - if (output.action === "single_reply") { - persistIntentDecision(input, { - action: "single_reply", - reason: output.reason, - status: "accepted", - createdBy: "model", - }); - return { action: "continue" }; + if (output.action === "reply") { + return createReplyIntentResult(input, output.reason, "model_policy"); } if (output.action === "no_reply") { const decision = persistIntentDecision(input, { action: "no_reply", reason: output.reason, status: "suppressed", - createdBy: "model", + createdBy: "model_policy", }); return { action: "no_reply", intentDecisionId: decision.id }; } if (output.action === "clarify") { - return createClarifyReply(input, clarifyReasonFromMessage(input.sourceMessage), "model", output.reason); + const clarification = output.clarification; + if (!clarification) throw new Error("Intent model clarify output omitted structured clarification."); + validateClarificationForCurrentTurn(input, clarification); + return createClarifyReply( + input, + clarifyReasonFromMissingFields(clarification.missingFields), + "model_policy", + output.reason, + clarification.question, + clarification, + ); } if (output.action === "visible_multi_reply") { - return selectVisibleMultiReply(input, "model", output.reason); + return selectVisibleMultiReply(input, "model_policy", output.reason); } - if (output.action === "direct_workflow") { - return createModelAssistedDirectWorkflowResult(input, output); + if (output.action === "execute") { + return createModelAssistedExecuteResult(input, output); } const reasonCode = detectWorkflowIntentReason(input.sourceMessage) ?? "ordered_work_request"; @@ -353,28 +380,31 @@ function applyModelIntentOutput(input: ChannelIntentSelectorInput, output: Chann if (participants.length === 0) { return undefined; } - return createWorkflowProposal(input, reasonCode, output.action, "model", output.reason, participants); + return createWorkflowProposal(input, reasonCode, output.action, "model_policy", output.reason, participants); } -function createModelAssistedDirectWorkflowResult(input: ChannelIntentSelectorInput, output: ChannelIntentModelOutput): ChannelIntentSelectorResult { - const operationKind = output.operationKind ?? "unknown"; - if (operationKind === "unknown") { - return createClarifyReply( - input, - "missing_target", - "model", - `Model classified the turn as direct_workflow but did not provide a supported operationKind. Model reason: ${output.reason}`, - ); - } - if (operationKind === "run_command") { +function createModelAssistedExecuteResult(input: ChannelIntentSelectorInput, output: ChannelIntentModelOutput): ChannelIntentSelectorResult { + const execution = output.execution; + if (!execution) { return createClarifyReply( input, "missing_target", - "model", - `Model classified the turn as direct_workflow/run_command, but the typed operation path requires a separate command materialization contract. Model reason: ${output.reason}`, + "model_policy", + `Model classified the turn as execute but omitted its typed execution contract. Model reason: ${output.reason}`, ); } const text = normalizeWhitespace(input.sourceMessage.body); + if (execution.kind === "run_command") { + const executableIntent = detectReleaseExecutableIntent(text) ?? detectCommandExecutableIntent(text); + return executableIntent + ? createSingleStepExecutableProposal(input, executableIntent, "model_policy", `Model-assisted execute/run_command validated. ${output.reason}`) + : createClarifyReply( + input, + "missing_target", + "model_policy", + `Model classified the turn as execute/run_command, but no bounded command or check was materialized. Model reason: ${output.reason}`, + ); + } const explicitTarget = extractFileTargetCandidate(text); const targetResolution = resolveExecutableFileTarget({ store: input.store, @@ -392,8 +422,8 @@ function createModelAssistedDirectWorkflowResult(input: ChannelIntentSelectorInp return createClarifyReply( input, "ambiguous_target", - "model", - `Model classified the turn as direct_workflow/${operationKind}, but target validation found multiple candidates. ${targetResolution.reason} Model reason: ${output.reason}`, + "model_policy", + `Model classified the turn as execute/${execution.kind}, but target validation found multiple candidates. ${targetResolution.reason} Model reason: ${output.reason}`, buildAmbiguousFileTargetClarifyBody(targetResolution.candidates, responseLanguage), ); } @@ -401,19 +431,19 @@ function createModelAssistedDirectWorkflowResult(input: ChannelIntentSelectorInp return createClarifyReply( input, "missing_target", - "model", - `Model classified the turn as direct_workflow/${operationKind}, but target validation failed: ${targetResolution.reason} Model reason: ${output.reason}`, + "model_policy", + `Model classified the turn as execute/${execution.kind}, but target validation failed: ${targetResolution.reason} Model reason: ${output.reason}`, ); } - if (operationKind === "open_local_target") { + if (execution.kind === "open_local_target") { return createSingleStepExecutableProposal( input, buildOpenLocalTargetExecutableIntent(targetResolution.target), - "model", - `Model-assisted direct_workflow/open_local_target validated against TargetResolver. ${output.reason}`, + "model_policy", + `Model-assisted execute/open_local_target validated against TargetResolver. ${output.reason}`, ); } - const fileAction = operationKind === "file_edit" ? "file_edit" : "file_create"; + const fileAction = execution.kind === "file_edit" ? "file_edit" : "file_create"; const content = resolveFileContentForRequest(input, text, targetResolution.target); if (fileAction === "file_create" && !hasConcreteFileWorkInput(content) && requiresConcreteFileContent(text, targetResolution.source)) { const responseLanguage = resolveChannelResponseLanguage({ @@ -421,19 +451,21 @@ function createModelAssistedDirectWorkflowResult(input: ChannelIntentSelectorInp channel: input.channel, sourceText: input.sourceMessage.body, }).language; + const question = buildMissingFileContentClarifyBody(targetResolution.target, responseLanguage); return createClarifyReply( input, "missing_acceptance", - "model", - `Model classified the turn as direct_workflow/${operationKind}, but validated target ${targetResolution.target} has no concrete content or objective source and the current turn is only a follow-up/capability check. Model reason: ${output.reason}`, - buildMissingFileContentClarifyBody(targetResolution.target, responseLanguage), + "model_policy", + `Model classified the turn as execute/${execution.kind}, but validated target ${targetResolution.target} has no concrete content or objective source and the current turn is only a follow-up/capability check. Model reason: ${output.reason}`, + question, + { missingFields: ["content"], question }, ); } return createSingleStepExecutableProposal( input, buildFileExecutableIntent(input, text, targetResolution.target, fileAction, content), - "model", - `Model-assisted direct_workflow/${operationKind} validated against TargetResolver. ${output.reason}`, + "model_policy", + `Model-assisted execute/${execution.kind} validated against TargetResolver. ${output.reason}`, ); } @@ -574,34 +606,74 @@ function parseModelIntentOutput(text: string): ChannelIntentModelOutput { const reason = record.reason; if ( (action !== "no_reply" && - action !== "single_reply" && + action !== "reply" && action !== "visible_multi_reply" && action !== "clarify" && action !== "workflow_proposal" && - action !== "direct_workflow") || + action !== "execute") || typeof reason !== "string" || reason.trim().length === 0 ) { throw new Error("Intent model output schema is invalid."); } - const operationKind = parseModelOperationKind(record.operationKind); + const allowedKeys = action === "clarify" + ? new Set(["action", "reason", "clarification"]) + : action === "execute" + ? new Set(["action", "reason", "execution"]) + : new Set(["action", "reason"]); + if (Object.keys(record).some((key) => !allowedKeys.has(key))) { + throw new Error("Intent model output contains unsupported fields."); + } return { action, reason: reason.trim(), - ...(action === "direct_workflow" ? { operationKind } : {}), + ...(action === "clarify" ? { clarification: parseModelClarification(record.clarification) } : {}), + ...(action === "execute" ? { execution: parseModelExecution(record.execution) } : {}), }; } -function parseModelOperationKind(value: unknown): ChannelIntentModelOperationKind { +function parseModelClarification(value: unknown): ChannelIntentClarificationV2 { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Intent model clarify output must include a structured clarification object."); + } + const record = value as Record; + const missingFields = record.missingFields; + const question = record.question; if ( - value === "file_create" || - value === "file_edit" || - value === "open_local_target" || - value === "run_command" + Object.keys(record).some((key) => key !== "missingFields" && key !== "question") + || !Array.isArray(missingFields) + || missingFields.length === 0 + || missingFields.some((field) => typeof field !== "string" || !field.trim()) + || new Set(missingFields).size !== missingFields.length + || typeof question !== "string" + || !question.trim() ) { - return value; + throw new Error("Intent model clarification schema is invalid."); + } + return { missingFields: missingFields.map((field) => field.trim()), question: question.trim() }; +} + +function parseModelExecution(value: unknown): ChannelIntentExecutionV2 { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Intent model execute output must include a structured execution object."); + } + const record = value as Record; + const kind = record.kind; + const targetKind = record.targetKind; + if ( + Object.keys(record).some((key) => key !== "kind" && key !== "targetKind") + || (kind !== "file_create" && kind !== "file_edit" && kind !== "open_local_target" && kind !== "run_command") + || (targetKind !== "file" && targetKind !== "directory" && targetKind !== "command" && targetKind !== "workspace") + ) { + throw new Error("Intent model execution schema is invalid."); + } + if ((kind === "file_create" || kind === "file_edit" || kind === "open_local_target") && targetKind !== "file") { + throw new Error("Intent model file execution must use targetKind=file."); + } + if (kind === "run_command" && targetKind !== "command" && targetKind !== "workspace") { + throw new Error("Intent model command execution must use targetKind=command or workspace."); } - return "unknown"; + return { kind, targetKind }; } function readStrictJsonObject(text: string): string { @@ -614,7 +686,7 @@ function readStrictJsonObject(text: string): string { function selectVisibleMultiReply( input: ChannelIntentSelectorInput, - createdBy: "deterministic" | "model", + createdBy: "deterministic" | "model_policy", modelReason?: string, ): ChannelIntentSelectorResult | undefined { if (!detectVisibleMultiReplyIntent(input.sourceMessage)) { @@ -624,19 +696,19 @@ function selectVisibleMultiReply( const existingDecision = input.store .listChannelIntentDecisions({ channelId: input.channel.id, - sourceMessageId: input.sourceMessage.id, + sourceItemId: input.sourceMessage.id, action: "visible_multi_reply", status: "accepted", limit: 1, }) [0]; if (existingDecision) { - const aiReplies = (existingDecision.replyMessageIds ?? []) + const aiReplies = existingDecision.resultItemIds .map((replyMessageId) => getChannelMessageView(input.store, replyMessageId)) .filter((message): message is ChannelMessage => Boolean(message && message.channelId === input.channel.id)); return { action: "visible_multi_reply", - teammateIds: existingDecision.selectedTeammateIds ?? [], + teammateIds: existingDecision.selectedTeammateIds, reason: existingDecision.reason, createdBy: existingDecision.createdBy, aiReplies, @@ -659,8 +731,8 @@ function selectVisibleMultiReply( function createWorkflowProposal( input: ChannelIntentSelectorInput, reasonCode: IntentReasonCode, - decisionAction: "workflow_proposal" | "direct_workflow", - createdBy: "deterministic" | "model", + decisionAction: "workflow_proposal" | "execute", + createdBy: "deterministic" | "model_policy", decisionReason: string | undefined, participants: readonly ProposalParticipantSelection[], ): ChannelIntentSelectorResult { @@ -671,6 +743,9 @@ function createWorkflowProposal( const decision = persistIntentDecision(input, { action: decisionAction, proposalId: existingProposal.id, + ...(decisionAction === "execute" + ? { execution: { kind: "run_command" as const, targetKind: "workspace" as const } } + : {}), reason: `Existing ${decisionAction} proposal already covers this message: ${existingProposal.id}`, status: "suppressed", createdBy, @@ -722,7 +797,10 @@ function createWorkflowProposal( const decision = persistIntentDecision(input, { action: decisionAction, proposalId: proposal.id, - reason: decisionReason ?? `${decisionAction === "direct_workflow" ? "Direct workflow proposal" : "Workflow proposal"} created from channel intent: ${reasonCode}.`, + ...(decisionAction === "execute" + ? { execution: { kind: "run_command" as const, targetKind: "workspace" as const } } + : {}), + reason: decisionReason ?? `${decisionAction === "execute" ? "Executable workflow proposal" : "Workflow proposal"} created from channel intent: ${reasonCode}.`, status: "accepted", createdBy, }); @@ -732,7 +810,7 @@ function createWorkflowProposal( function createSingleStepExecutableProposal( input: ChannelIntentSelectorInput, executableIntent: SingleStepExecutableIntent, - createdBy: "deterministic" | "model", + createdBy: "deterministic" | "model_policy", decisionReason?: string, ): ChannelIntentSelectorResult { const existingProposal = input.store @@ -740,9 +818,10 @@ function createSingleStepExecutableProposal( .find((proposal) => proposal.status !== "cancelled" && proposal.status !== "superseded"); if (existingProposal) { const decision = persistIntentDecision(input, { - action: "direct_workflow", + action: "execute", proposalId: existingProposal.id, - reason: `Existing direct_workflow proposal already covers this message: ${existingProposal.id}`, + execution: executableIntent.execution, + reason: `Existing execute proposal already covers this message: ${existingProposal.id}`, status: "suppressed", createdBy, }); @@ -793,8 +872,9 @@ function createSingleStepExecutableProposal( now, ); const decision = persistIntentDecision(input, { - action: "direct_workflow", + action: "execute", proposalId: proposal.id, + execution: executableIntent.execution, reason: blocker?.technicalReason ? `Direct single-step executable proposal blocked for ${executableIntent.kind}: ${blocker.technicalReason}` : decisionReason ?? `Direct single-step executable proposal created for ${executableIntent.kind}.`, @@ -829,10 +909,22 @@ function buildProposalShaping( function createClarifyReply( input: ChannelIntentSelectorInput, reasonCode: ClarifyReasonCode, - createdBy: "deterministic" | "model" = "deterministic", + createdBy: "deterministic" | "model_policy" = "deterministic", modelReason?: string, bodyOverride?: string, + clarificationOverride?: ChannelIntentClarificationV2, ): ChannelIntentSelectorResult { + const responseLanguage = resolveChannelResponseLanguage({ + store: input.store, + channel: input.channel, + sourceText: input.sourceMessage.body, + }).language; + const question = bodyOverride ?? buildClarifyQuestion(reasonCode, responseLanguage); + const clarification = clarificationOverride ?? { + missingFields: missingFieldsForClarifyReason(reasonCode), + question, + }; + validateClarificationShape(clarification); const existingReply = findChannelAssistantReplyForSource({ store: input.store, sourceItemId: input.sourceMessage.id, @@ -840,8 +932,9 @@ function createClarifyReply( if (existingReply) { const decision = persistIntentDecision(input, { action: "clarify", - selectedTeammateId: existingReply.authorId, - replyMessageId: existingReply.id, + selectedTeammateIds: existingReply.authorId ? [existingReply.authorId] : [], + resultItemIds: [existingReply.id], + clarification, reason: `Existing clarify reply already covers this message: ${existingReply.id}`, status: "suppressed", createdBy, @@ -853,24 +946,20 @@ function createClarifyReply( if (!teammate) { return { action: "continue" }; } - const responseLanguage = resolveChannelResponseLanguage({ - store: input.store, - channel: input.channel, - sourceText: input.sourceMessage.body, - }).language; const now = new Date().toISOString(); const aiReply = appendChannelAssistantMessage({ store: input.store, sourceItemId: input.sourceMessage.id, actorId: teammate.id, - body: bodyOverride ?? buildClarifyQuestion(reasonCode, responseLanguage), + body: question, idempotencyKey: `clarify:${input.sourceMessage.id}:${reasonCode}`, now, }); const decision = persistIntentDecision(input, { action: "clarify", - selectedTeammateId: teammate.id, - replyMessageId: aiReply.id, + selectedTeammateIds: [teammate.id], + resultItemIds: [aiReply.id], + clarification, reason: modelReason ?? `Clarify question created from channel intent: ${reasonCode}.`, status: "accepted", createdBy, @@ -881,8 +970,13 @@ function createClarifyReply( function createMaterializationClarifyReply( input: ChannelIntentSelectorInput, clarify: MaterializationClarifyReason, - createdBy: "deterministic" | "model" = "deterministic", + createdBy: "deterministic" | "model_policy" = "deterministic", ): ChannelIntentSelectorResult { + const clarification: ChannelIntentClarificationV2 = { + missingFields: ["materialized_input"], + question: clarify.body, + }; + validateClarificationShape(clarification); const existingReply = findChannelAssistantReplyForSource({ store: input.store, sourceItemId: input.sourceMessage.id, @@ -890,8 +984,9 @@ function createMaterializationClarifyReply( if (existingReply) { const decision = persistIntentDecision(input, { action: "clarify", - selectedTeammateId: existingReply.authorId, - replyMessageId: existingReply.id, + selectedTeammateIds: existingReply.authorId ? [existingReply.authorId] : [], + resultItemIds: [existingReply.id], + clarification, reason: `Existing materialization clarify reply already covers this message: ${existingReply.id}`, status: "suppressed", createdBy, @@ -914,8 +1009,9 @@ function createMaterializationClarifyReply( }); const decision = persistIntentDecision(input, { action: "clarify", - selectedTeammateId: teammate.id, - replyMessageId: aiReply.id, + selectedTeammateIds: [teammate.id], + resultItemIds: [aiReply.id], + clarification, reason: clarify.technicalReason, status: "accepted", createdBy, @@ -946,7 +1042,7 @@ function hasNonLingExplicitAiMention(message: ChannelMessage): boolean { return message.mentions.some((mention) => mention.kind === "ai-teammate" && !isLingAgentExecutorId(mention.id)); } -function detectWorkflowIntentReason(message: ChannelMessage): IntentReasonCode | undefined { +function detectWorkflowIntentReason(message: Pick): IntentReasonCode | undefined { const text = message.body.trim(); const lower = text.toLowerCase(); if ( @@ -1038,6 +1134,7 @@ function detectSingleStepExecutableClarify(input: ChannelIntentSelectorInput): S }).language; return { reasonCode: "missing_acceptance", + missingFields: ["content"], userMessage: buildMissingFileContentClarifyBody(targetResolution.target, responseLanguage), technicalReason: `Resolved file target ${targetResolution.target}, but no concrete content or objective source was available for a follow-up/capability-style create request.`, }; @@ -1133,6 +1230,7 @@ function buildOpenLocalTargetExecutableIntent(target: string): SingleStepExecuta const rolePreferences: readonly AiTeammate["roleKey"][] = ["engineer", "qa"]; return { kind: "command_run", + execution: { kind: "open_local_target", targetKind: "file" }, title, prompt: [ "Open the requested local target for the user.", @@ -1168,6 +1266,7 @@ function buildFileExecutableIntent( const title = truncateTitle(`${fileAction === "file_edit" ? "Edit" : "Create"} ${target}`); return { kind: fileAction, + execution: { kind: fileAction, targetKind: "file" }, title, prompt: [ `${fileAction === "file_edit" ? "Edit" : "Create"} the requested file.`, @@ -1380,6 +1479,7 @@ function detectCommandExecutableIntent(text: string): SingleStepExecutableIntent const rolePreferences: readonly AiTeammate["roleKey"][] = looksLikeTest ? ["qa", "engineer"] : ["engineer", "qa"]; return { kind: "command_run", + execution: { kind: "run_command", targetKind: "command" }, title, prompt: [ "Run the requested local command or project check.", @@ -1407,6 +1507,7 @@ function detectReleaseExecutableIntent(text: string): SingleStepExecutableIntent const target = "Git push/merge workflow"; return { kind: "command_run", + execution: { kind: "run_command", targetKind: "workspace" }, title: "Prepare Git push/merge", prompt: [ "Prepare and run the requested Git push/merge workflow.", @@ -1689,8 +1790,8 @@ function looksExecutionAllTeammatesRequest(text: string): boolean { return /(开始|启动|执行|开跑|工作流|workflow|协作|实现|修复|验证|验收|测试|跑测试|e2e|push|合并|发布|部署|ship|release|deploy|改代码|写代码|处理)/iu.test(text); } -function intentDecisionActionForReason(reasonCode: IntentReasonCode): "workflow_proposal" | "direct_workflow" { - return reasonCode === "explicit_workflow_start" ? "direct_workflow" : "workflow_proposal"; +function intentDecisionActionForReason(reasonCode: IntentReasonCode): "workflow_proposal" | "execute" { + return reasonCode === "explicit_workflow_start" ? "execute" : "workflow_proposal"; } function detectClarifyIntentReason(message: ChannelMessage): ClarifyReasonCode | undefined { @@ -1714,6 +1815,67 @@ function clarifyReasonFromMessage(message: ChannelMessage): ClarifyReasonCode { return detectClarifyIntentReason(message) ?? "missing_acceptance"; } +function clarifyReasonFromMissingFields(fields: readonly string[]): ClarifyReasonCode { + if (fields.includes("approval_scope")) return "approval_sensitive_target"; + if (fields.includes("target") && fields.length > 1) return "ambiguous_target"; + if (fields.includes("target")) return "missing_target"; + return "missing_acceptance"; +} + +function missingFieldsForClarifyReason(reasonCode: ClarifyReasonCode): readonly string[] { + if (reasonCode === "approval_sensitive_target") { + return ["target", "approval_scope", "acceptance_criteria"]; + } + if (reasonCode === "missing_acceptance") return ["acceptance_criteria"]; + return ["target"]; +} + +function validateClarificationForCurrentTurn( + input: ChannelIntentSelectorInput, + clarification: ChannelIntentClarificationV2, +): void { + validateClarificationShape(clarification); + if (clarification.missingFields.includes("target") && !currentTurnMayRequireExecutionTarget(input.sourceMessage.body)) { + throw new Error("Intent model cannot request a local target for an independently answerable content turn."); + } +} + +function validateClarificationShape(clarification: ChannelIntentClarificationV2): void { + if (clarification.missingFields.length === 0 || !clarification.question.trim()) { + throw new Error("Channel clarification requires missingFields and a specific question."); + } + const question = clarification.question.toLowerCase(); + for (const field of clarification.missingFields) { + const pattern = clarificationQuestionPattern(field); + if (!pattern.test(question)) { + throw new Error(`Channel clarification question does not name missing field: ${field}`); + } + } +} + +function clarificationQuestionPattern(field: string): RegExp { + if (field === "target") return /(target|file|path|directory|branch|pr|对象|目标|文件|路径|目录|分支)/iu; + if (field === "content") return /(content|body|text|page|feature|内容|正文|文本|页面|功能)/iu; + if (field === "acceptance_criteria") return /(acceptance|criteria|check|done|verify|验收|检查|完成|通过|验证)/iu; + if (field === "approval_scope") return /(approval|permission|allow|remote|授权|审批|权限|允许|远端)/iu; + if (field === "materialized_input") return /(attachment|link|input|file|image|document|附件|链接|输入|文件|图片|文档)/iu; + if (field === "command") return /(command|check|test|命令|检查|测试)/iu; + return new RegExp(field.replace(/[_-]+/gu, "[ _-]+"), "iu"); +} + +function currentTurnMayRequireExecutionTarget(body: string): boolean { + const text = normalizeWhitespace(body); + return Boolean( + detectFileAction(text) + || detectImplicitRecentFileAction(text) + || mightRequestBrowserOpen(text) + || detectReleaseExecutableIntent(text) + || detectCommandExecutableIntent(text) + || detectWorkflowIntentReason({ body: text }) + || looksWorkShapedButUnderspecified(text) + ); +} + function looksWorkShapedButUnderspecified(text: string): boolean { const normalized = normalizeWhitespace(text); if (normalized.length === 0) { @@ -2197,22 +2359,28 @@ function persistIntentDecision( decision: Pick< Parameters[0], "action" | "reason" | "status" | "createdBy" - > & Partial[0], "selectedTeammateId" | "selectedTeammateIds" | "proposalId" | "replyMessageId" | "replyMessageIds" | "workflowId" | "errorMessage">>, + > & Partial[0], "selectedTeammateIds" | "proposalId" | "resultItemIds" | "workflowId" | "clarification" | "execution" | "errorCode">>, ): ChannelIntentDecision { + const projection = buildConversationContextProjection({ + store: input.store, + sourceItemId: input.sourceMessage.id, + view: "selector", + }); return input.store.createChannelIntentDecision({ channelId: input.channel.id, - sourceMessageId: input.sourceMessage.id, + sourceItemId: input.sourceMessage.id, + contextProjection: projection.metadata, action: decision.action, - selectedTeammateId: decision.selectedTeammateId, selectedTeammateIds: decision.selectedTeammateIds, proposalId: decision.proposalId, - replyMessageId: decision.replyMessageId, - replyMessageIds: decision.replyMessageIds, + resultItemIds: decision.resultItemIds, workflowId: decision.workflowId, reason: decision.reason, + clarification: decision.clarification, + execution: decision.execution, status: decision.status, createdBy: decision.createdBy, - errorMessage: decision.errorMessage, + errorCode: decision.errorCode, }, new Date().toISOString()); } diff --git a/packages/daemon/src/features/channels/context-packs.ts b/packages/daemon/src/features/channels/context-packs.ts index 89c82864e..ade5fd958 100644 --- a/packages/daemon/src/features/channels/context-packs.ts +++ b/packages/daemon/src/features/channels/context-packs.ts @@ -486,15 +486,15 @@ export function buildChannelIntentSelectorContextPack(input: ChannelIntentSelect priority: "must_keep", text: [ "Classify the latest user channel message into exactly one Ling intent.", - "Return strict JSON only: {\"action\":\"no_reply|single_reply|visible_multi_reply|clarify|workflow_proposal|direct_workflow\",\"reason\":\"short reason\",\"operationKind\":\"file_create|file_edit|open_local_target|run_command\"}.", - "Set operationKind only when action is direct_workflow. Omit it for every other action.", - "Use single_reply for ordinary questions one teammate should answer.", + "Return strict JSON only. For reply/no_reply/visible_multi_reply/workflow_proposal: {\"action\":\"...\",\"reason\":\"short reason\"}. For clarify add {\"clarification\":{\"missingFields\":[\"field\"],\"question\":\"specific question\"}}. For execute add {\"execution\":{\"kind\":\"file_create|file_edit|open_local_target|run_command\",\"targetKind\":\"file|directory|command|workspace\"}}.", + "Use reply for ordinary questions and in-conversation content generation.", "Use visible_multi_reply only for explicit discussion-only requests asking multiple teammates for visible opinions; do not use it for implementation, validation, publishing, merge, deploy, or tool-running work.", "Use clarify for work-shaped requests missing target, scope, or acceptance checks.", "Use workflow_proposal for multi-role work that needs proposal evidence before execution.", - "Use direct_workflow only when the user explicitly asks to start/run bounded known work; Ling Agent Session may auto-start after proposal evidence when scope is clear and required tools are not denied.", - "For direct_workflow, classify the operation semantically from the full turn and recent same-thread context: file_create creates a new local file, file_edit changes an existing/local target, open_local_target opens or previews a local target, and run_command runs a bounded command/check.", - "A follow-up asking whether Ling can now write, save, create, edit, run, open, preview, or put work into the local workspace is direct_workflow when recent same-thread context contains one clear target.", + "Use execute only when the user explicitly requests bounded file, command, workspace, or other external side-effect work; approval and tool policy still apply.", + "For execute, classify the operation semantically from the current turn plus the projected same-segment context: file_create creates a new local file, file_edit changes an existing target, open_local_target opens or previews a local target, and run_command runs a bounded command/check.", + "A follow-up asking Ling to write, save, create, edit, run, open, preview, or put work into the local workspace is execute only when that side effect is explicit and the projected context contains one clear target.", + "Use clarify only when a current product decision is genuinely missing. missingFields must be non-empty and the question must name the missing decision. Never ask for a local target for an independently answerable content request.", "Use no_reply only to mean the message is not executable or collaborative work; Ling Agent still falls through to ordinary reply handling.", ].join("\n"), }, diff --git a/packages/daemon/src/features/channels/ling-agent-auto-start.ts b/packages/daemon/src/features/channels/ling-agent-auto-start.ts index ca6a3ab03..973bafc08 100644 --- a/packages/daemon/src/features/channels/ling-agent-auto-start.ts +++ b/packages/daemon/src/features/channels/ling-agent-auto-start.ts @@ -147,8 +147,8 @@ function isDirectWorkflowProposal( return store .listChannelIntentDecisions({ channelId: proposal.channelId, - sourceMessageId: sourceMessage.id, - action: "direct_workflow", + sourceItemId: sourceMessage.id, + action: "execute", status: "accepted", limit: 10, }) diff --git a/packages/daemon/src/features/channels/messages.ts b/packages/daemon/src/features/channels/messages.ts index 84dce27c8..0b745e817 100644 --- a/packages/daemon/src/features/channels/messages.ts +++ b/packages/daemon/src/features/channels/messages.ts @@ -42,6 +42,7 @@ import { } 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"; @@ -579,6 +580,8 @@ async function dispatchLingAgentChannelMessage( sourceMessage: input.sourceMessage, proposal: workflowIntent.proposal, }); + const typedExecution = input.store.getChannelIntentDecision(workflowIntent.intentDecisionId)?.execution + ?? { kind: "run_command" as const, targetKind: "workspace" as const }; if (autoStartDecision.allowed && input.startExecutionProposalWorkflow) { let coordinatorReply = createCoordinatorWorkflowStartReply({ store: input.store, @@ -599,16 +602,16 @@ async function dispatchLingAgentChannelMessage( status: "ready", now: new Date().toISOString(), }); - input.store.createChannelIntentDecision({ - channelId: input.channel.id, - sourceMessageId: input.sourceMessage.id, - action: "direct_workflow", + persistChannelIntentDecision(input.store, input.channel, input.sourceMessage, { + action: "execute", proposalId: autoStartedWorkflow.proposal.id, workflowId: autoStartedWorkflow.workflow.id, + resultItemIds: [coordinatorReply.id], + execution: typedExecution, reason: `Ling Agent Session auto-started proposal ${autoStartedWorkflow.proposal.id}. ${autoStartDecision.reason}`, status: "accepted", createdBy: "deterministic", - }, new Date().toISOString()); + }); return { aiReply: coordinatorReply, autoStartedWorkflow }; } catch (error) { coordinatorReply = updatePendingAssistantMessage({ @@ -619,30 +622,29 @@ async function dispatchLingAgentChannelMessage( errorMessage: error instanceof Error ? error.message : String(error), now: new Date().toISOString(), }); - input.store.createChannelIntentDecision({ - channelId: input.channel.id, - sourceMessageId: input.sourceMessage.id, - action: "direct_workflow", + persistChannelIntentDecision(input.store, input.channel, input.sourceMessage, { + action: "execute", proposalId: workflowIntent.proposal.id, + resultItemIds: [coordinatorReply.id], + execution: typedExecution, reason: `Ling Agent Session auto-start failed; proposal remains available for confirmation. ${autoStartDecision.reason}`, status: "failed", createdBy: "deterministic", - errorMessage: error instanceof Error ? error.message : String(error), - }, new Date().toISOString()); + errorCode: "auto_start_failed", + }); return { aiReply: coordinatorReply }; } } if (!autoStartDecision.allowed && autoStartDecision.blockReason === "tool_denied") { - input.store.createChannelIntentDecision({ - channelId: input.channel.id, - sourceMessageId: input.sourceMessage.id, - action: "direct_workflow", + persistChannelIntentDecision(input.store, input.channel, input.sourceMessage, { + action: "execute", proposalId: workflowIntent.proposal.id, + execution: typedExecution, reason: `Ling Agent Session auto-start blocked by approval policy. ${autoStartDecision.reason}`, status: "failed", createdBy: "deterministic", - errorMessage: autoStartDecision.reason, - }, new Date().toISOString()); + errorCode: "tool_denied", + }); } return {}; } @@ -676,16 +678,14 @@ async function dispatchLingAgentChannelMessage( visibleParticipantTeammateIds: workflowIntent.teammateIds, }))); if (!workflowIntent.intentDecisionId) { - input.store.createChannelIntentDecision({ - channelId: input.channel.id, - sourceMessageId: input.sourceMessage.id, + persistChannelIntentDecision(input.store, input.channel, input.sourceMessage, { action: "visible_multi_reply", selectedTeammateIds: workflowIntent.teammateIds, - replyMessageIds: aiReplies.map((reply) => reply.id), + resultItemIds: aiReplies.map((reply) => reply.id), reason: workflowIntent.reason, status: "accepted", createdBy: workflowIntent.createdBy, - }, new Date().toISOString()); + }); } return { aiReply: aiReplies[0], aiReplies }; } @@ -801,6 +801,25 @@ function coordinatorWorkflowStartFailedBody(proposal: ChannelExecutionProposal, return `我尝试开始这次 workflow,但执行前被阻塞:${message}`; } +function persistChannelIntentDecision( + store: RunStore, + channel: Channel, + sourceMessage: ChannelMessage, + decision: Omit[0], "channelId" | "sourceItemId" | "contextProjection">, +): void { + const projection = buildConversationContextProjection({ + store, + sourceItemId: sourceMessage.id, + view: "selector", + }); + store.createChannelIntentDecision({ + ...decision, + channelId: channel.id, + sourceItemId: sourceMessage.id, + contextProjection: projection.metadata, + }, new Date().toISOString()); +} + function canDispatchChannelReply(message: ChannelMessage): boolean { return message.authorType === "local-user" && message.kind === "message" && message.intent === "discuss"; } diff --git a/packages/daemon/src/features/channels/routes.ts b/packages/daemon/src/features/channels/routes.ts index 16cf7e8db..5b7973684 100644 --- a/packages/daemon/src/features/channels/routes.ts +++ b/packages/daemon/src/features/channels/routes.ts @@ -656,9 +656,9 @@ export async function handleChannelIntentDecisionsCollection( } if (request.method === "GET") { - const sourceMessageId = url.searchParams.get("sourceMessageId") ?? undefined; - if (sourceMessageId) { - const messageError = validateChannelMessageOwner(store, channelId, sourceMessageId); + const sourceItemId = url.searchParams.get("sourceItemId") ?? undefined; + if (sourceItemId) { + const messageError = validateChannelMessageOwner(store, channelId, sourceItemId); if (messageError) { writeJson(response, messageError.statusCode, { error: messageError.code, @@ -672,7 +672,7 @@ export async function handleChannelIntentDecisionsCollection( channelId, decisions: store.listChannelIntentDecisions({ channelId, - sourceMessageId, + sourceItemId, action: readOptionalChannelIntentDecisionActionQuery(url), status: readOptionalChannelIntentDecisionStatusQuery(url), limit: readOptionalIntegerQuery(url, "limit", 1), diff --git a/packages/daemon/test/channel-workspace.test.mjs b/packages/daemon/test/channel-workspace.test.mjs index 059e0d3c3..bd23fc05d 100644 --- a/packages/daemon/test/channel-workspace.test.mjs +++ b/packages/daemon/test/channel-workspace.test.mjs @@ -933,8 +933,8 @@ test("active channel reply does not intercept an unmarked direct workflow reques const activeRun = createActiveChannelReplyRunFixture(store, { cwd, channelId: channel.id, - runId: "run_active_reply_before_direct_workflow", - taskId: "task_active_reply_before_direct_workflow", + runId: "run_active_reply_before_execute", + taskId: "task_active_reply_before_execute", }); const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { @@ -946,8 +946,8 @@ test("active channel reply does not intercept an unmarked direct workflow reques assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); assert.deepEqual(created.autoStartedWorkflow.workflowSteps[0].metadata.requiredTools, ["write"]); assert.deepEqual(store.listRunInterventions({ sourceRunId: activeRun.id }), []); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.some((decision) => decision.action === "direct_workflow" && decision.status === "accepted"), true); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.some((decision) => decision.action === "execute" && decision.status === "accepted"), true); } finally { await daemon.close(); await rm(cwd, { recursive: true, force: true }); @@ -1860,8 +1860,8 @@ test("cached public link body lets Ling answer without materialization clarify", assert.equal(finalReply.body, "这个页面讲的是 cached public link body。"); assert.equal(runner.runs.length, 1); assert.match(runner.runs[0].task.prompt, /Cached public link body for Ling to summarize/); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(intentDecisions.decisions, []); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assertSingleReplyIntentDecision(intentDecisions.decisions, created.message.id); } finally { await daemon.close(); await rm(cwd, { recursive: true, force: true }); @@ -2089,8 +2089,8 @@ test("materialized pdf and docx attachments let Ling answer without materializat const docxPreview = await getJson(`${daemon.url}/attachments/${encodeURIComponent(docxAttachment.id)}/preview?cwd=${encodeURIComponent(cwd)}&scope=channel&channelId=${encodeURIComponent(channel.id)}`); assert.equal(docxPreview.kind, "office"); assert.match(docxPreview.text, /Walking on imported air/); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(intentDecisions.decisions, []); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assertSingleReplyIntentDecision(intentDecisions.decisions, created.message.id); } finally { await daemon.close(); await rm(cwd, { recursive: true, force: true }); @@ -2136,8 +2136,8 @@ test("materialized image attachment lets Ling answer without materialization cla assert.equal(runner.runs.length, 1); assert.match(runner.runs[0].task.prompt, /branch button hidden/); assert.doesNotMatch(runner.runs[0].task.prompt, /Image vision summarizer is not configured/); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(intentDecisions.decisions, []); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assertSingleReplyIntentDecision(intentDecisions.decisions, created.message.id); } finally { await daemon.close(); await rm(cwd, { recursive: true, force: true }); @@ -3817,7 +3817,7 @@ test("channel intent selector creates visible multi-replies for discussion-only const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); assert.equal(routeDecisions.decisions.length, 0); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + 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, 1); assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); assert.equal(decisions.decisions[0].action, "visible_multi_reply"); @@ -3828,7 +3828,7 @@ test("channel intent selector creates visible multi-replies for discussion-only created.aiReplies.map((reply) => reply.authorId).sort(), ); assert.deepEqual( - [...decisions.decisions[0].replyMessageIds].sort(), + [...decisions.decisions[0].resultItemIds].sort(), created.aiReplies.map((reply) => reply.id).sort(), ); } finally { @@ -3900,7 +3900,7 @@ test("channel intent selector creates workflow proposals for execution-shaped al const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); assert.equal(routeDecisions.decisions.length, 0); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + 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, 1); assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); assert.equal(decisions.decisions[0].action, "workflow_proposal"); @@ -3950,7 +3950,7 @@ test("channel intent selector does not assign specialists outside current channe assert.match(proposals.proposals[0].riskSummary, /Ling Agent 可以先处理/); assert.doesNotMatch(proposals.proposals[0].prompt, /Engineer:|QA:|Architect:/); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + 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, 1); assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); assert.equal(decisions.decisions[0].action, "workflow_proposal"); @@ -4030,7 +4030,7 @@ test("channel intent selector uses attached AI council specialists without copyi assert.equal(designerChannels.memberships.some((membership) => membership.channelId === channel.id), false); assert.equal(qaChannels.memberships.some((membership) => membership.channelId === channel.id), false); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + 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, 1); assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); assert.equal(decisions.decisions[0].action, "workflow_proposal"); @@ -4082,14 +4082,16 @@ test("channel intent selector asks one clarify question for underspecified work const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); assert.equal(routeDecisions.decisions.length, 0); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + 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, 1); assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); assert.equal(decisions.decisions[0].action, "clarify"); assert.equal(decisions.decisions[0].status, "accepted"); assert.equal(decisions.decisions[0].createdBy, "deterministic"); - assert.equal(decisions.decisions[0].selectedTeammateId, "ling_agent"); - assert.equal(decisions.decisions[0].replyMessageId, created.aiReply.id); + assert.deepEqual(decisions.decisions[0].selectedTeammateIds, ["ling_agent"]); + assert.deepEqual(decisions.decisions[0].resultItemIds, [created.aiReply.id]); + assert.deepEqual(decisions.decisions[0].clarification.missingFields, ["acceptance_criteria"]); + assert.equal(decisions.decisions[0].clarification.question, created.aiReply.body); assert.match(decisions.decisions[0].reason, /Clarify question created from channel intent/); } finally { await daemon.close(); @@ -4144,10 +4146,10 @@ test("channel intent selector auto-starts explicit direct workflow proposals", a ); assert.match(proposals.proposals[0].riskSummary, /runtime approval policy/); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + 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); assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); - assert.equal(decisions.decisions[0].action, "direct_workflow"); + assert.equal(decisions.decisions[0].action, "execute"); assert.equal(decisions.decisions[0].status, "accepted"); assert.equal(decisions.decisions[0].proposalId, proposals.proposals[0].id); assert.equal(decisions.decisions.some((decision) => /auto-started proposal/.test(decision.reason)), true); @@ -4262,9 +4264,9 @@ test("single-step file creation becomes direct workflow with bounded write tools assert.match(proposals.proposals[0].riskSummary, /write/); assert.match(proposals.proposals[0].prompt, /权限边界/); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + 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.equal(decisions.decisions[0].action, "direct_workflow"); + assert.equal(decisions.decisions[0].action, "execute"); assert.equal(decisions.decisions[0].status, "accepted"); assert.equal(decisions.decisions.some((decision) => /auto-started proposal/.test(decision.reason)), true); @@ -4311,8 +4313,8 @@ test("explicit Ling Agent file request becomes direct workflow instead of discus assert.match(proposals.proposals[0].prompt, /login\.html/); const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); assert.deepEqual(routeDecisions.decisions, []); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(intentDecisions.decisions.some((decision) => decision.action === "direct_workflow" && /Bounded executable request/.test(decision.reason)), true); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(intentDecisions.decisions.some((decision) => decision.action === "execute" && /Bounded executable request/.test(decision.reason)), true); assert.equal(runner.runs.length, 1); assert.deepEqual(runner.runs[0].tools, ["write"]); assert.match(runner.runs[0].task.prompt, /login\.html/); @@ -4469,8 +4471,8 @@ test("thread follow-up can create the single file named by the previous Ling Age assert.match(proposals.proposals[0].prompt, /Target: index\.html/); const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); assert.deepEqual(routeDecisions.decisions, []); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(intentDecisions.decisions.some((decision) => decision.action === "direct_workflow" && /Bounded executable request/.test(decision.reason)), true); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(intentDecisions.decisions.some((decision) => decision.action === "execute" && /Bounded executable request/.test(decision.reason)), true); assert.equal(runner.runs.length, 1); assert.deepEqual(runner.runs[0].tools, ["write"]); assert.match(runner.runs[0].task.prompt, /Target: index\.html/); @@ -4556,7 +4558,11 @@ test("thread follow-up opens the recent HTML target with bounded bash tools", as test("model-assisted thread follow-up opens recent target from typed operation kind", async () => { const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-open-")); const runner = new ScriptedRunner([ - JSON.stringify({ action: "direct_workflow", operationKind: "open_local_target", reason: "User wants to preview the recent local artifact." }), + JSON.stringify({ + action: "execute", + reason: "User wants to preview the recent local artifact.", + execution: { kind: "open_local_target", targetKind: "file" }, + }), "Opened local target.", ]); const stores = new Map(); @@ -4617,11 +4623,11 @@ test("model-assisted thread follow-up opens recent target from typed operation k assert.deepEqual(proposals.proposals[0].requiredTools, ["bash"]); assert.match(proposals.proposals[0].prompt, /Open index\.html in the local browser/); assert.doesNotMatch(proposals.proposals[0].prompt, /Create index\.html|declared file tool scope/); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); assert.equal(intentDecisions.decisions.some((decision) => - decision.action === "direct_workflow" && - decision.createdBy === "model" && - /direct_workflow\/open_local_target/.test(decision.reason) + decision.action === "execute" && + decision.createdBy === "model_policy" && + /execute\/open_local_target/.test(decision.reason) ), true); assert.equal(runner.runs.length, 2); assert.match(runner.runs[0].systemPrompt, /channel intent selector/i); @@ -4637,7 +4643,11 @@ test("model-assisted thread follow-up opens recent target from typed operation k test("model-assisted thread follow-up writes the recent local target without phrase-rule matching", async () => { const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-file-")); const runner = new ScriptedRunner([ - JSON.stringify({ action: "direct_workflow", operationKind: "file_create", reason: "User is asking Ling to write the previously discussed file locally." }), + JSON.stringify({ + action: "execute", + reason: "User is asking Ling to write the previously discussed file locally.", + execution: { kind: "file_create", targetKind: "file" }, + }), "Created local file.", ]); const stores = new Map(); @@ -4707,11 +4717,11 @@ test("model-assisted thread follow-up writes the recent local target without phr assert.match(proposals.proposals[0].prompt, /Target: index\.html/); const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); assert.deepEqual(routeDecisions.decisions, []); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); assert.equal(intentDecisions.decisions.some((decision) => - decision.action === "direct_workflow" && - decision.createdBy === "model" && - /Model-assisted direct_workflow\/file_create validated/.test(decision.reason) + decision.action === "execute" && + decision.createdBy === "model_policy" && + /Model-assisted execute\/file_create validated/.test(decision.reason) ), true); assert.equal(runner.runs.length, 2); assert.match(runner.runs[0].systemPrompt, /channel intent selector/i); @@ -4728,7 +4738,11 @@ test("model-assisted thread follow-up writes the recent local target without phr test("model-assisted thread follow-up clarifies instead of creating empty recent target", async () => { const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-empty-file-")); const runner = new ScriptedRunner([ - JSON.stringify({ action: "direct_workflow", operationKind: "file_create", reason: "User is asking Ling to write the previously discussed file locally." }), + JSON.stringify({ + action: "execute", + reason: "User is asking Ling to write the previously discussed file locally.", + execution: { kind: "file_create", targetKind: "file" }, + }), ]); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -4798,7 +4812,11 @@ test("model-assisted thread follow-up clarifies instead of creating empty recent test("model-assisted local write request clarifies when no target validates", async () => { const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-followup-missing-target-")); const runner = new ScriptedRunner([ - JSON.stringify({ action: "direct_workflow", operationKind: "file_create", reason: "User is asking Ling to write something locally." }), + JSON.stringify({ + action: "execute", + reason: "User is asking Ling to write something locally.", + execution: { kind: "file_create", targetKind: "file" }, + }), ]); const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); try { @@ -4828,10 +4846,12 @@ test("model-assisted local write request clarifies when no target validates", as assert.deepEqual(proposals.proposals, []); const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); assert.deepEqual(routeDecisions.decisions, []); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); assert.equal(intentDecisions.decisions.length, 1); assert.equal(intentDecisions.decisions[0].action, "clarify"); - assert.equal(intentDecisions.decisions[0].createdBy, "model"); + assert.equal(intentDecisions.decisions[0].createdBy, "model_policy"); + assert.deepEqual(intentDecisions.decisions[0].clarification.missingFields, ["target"]); + assert.equal(intentDecisions.decisions[0].clarification.question, created.aiReply.body); assert.match(intentDecisions.decisions[0].reason, /target validation failed/); } finally { await daemon.close(); @@ -4900,7 +4920,7 @@ test("workspace follow-up clarifies when the previous Ling Agent reply names mul assert.match(created.aiReply.body, /README\.md/); const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); assert.deepEqual(proposals.proposals, []); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + 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, 1); assert.equal(decisions.decisions[0].action, "clarify"); assert.match(decisions.decisions[0].reason, /multiple file targets/i); @@ -4974,7 +4994,7 @@ test("thread follow-up clarifies when the previous Ling Agent reply names multip assert.match(created.aiReply.body, /README\.md/); const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); assert.deepEqual(proposals.proposals, []); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + 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, 1); assert.equal(decisions.decisions[0].action, "clarify"); assert.match(decisions.decisions[0].reason, /multiple file targets/i); @@ -5020,7 +5040,7 @@ test("text attachment preview can feed a direct README write workflow", async () assert.match(proposals.proposals[0].prompt, /Attachment: readme-copy\.txt/); assert.match(proposals.proposals[0].prompt, /README body from attachment/); assert.match(runner.runs[0].task.prompt, /README body from attachment/); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); assert.equal(decisions.decisions.some((decision) => /auto-started proposal/.test(decision.reason)), true); } finally { await daemon.close(); @@ -5133,9 +5153,9 @@ test("attachment-dependent README write blocks when attachment content is not ma assert.equal(proposals.proposals[0].status, "blocked"); assert.match(proposals.proposals[0].errorMessage, /可读的附件内容|readable attachment content/i); assert.doesNotMatch(proposals.proposals[0].errorMessage, /附件预览|attachment preview|too large|previewStatus/i); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + 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, 1); - assert.equal(decisions.decisions[0].action, "direct_workflow"); + assert.equal(decisions.decisions[0].action, "execute"); assert.match(decisions.decisions[0].reason, /no available attachment preview\/extraction/i); } finally { await daemon.close(); @@ -5172,7 +5192,7 @@ test("unreadable screenshot task creates Ling materialization clarify reply", as assert.doesNotMatch(created.aiReply.body, /OCR|视觉提取|metadata_only|extractor|SSRF/i); const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); assert.equal(proposals.proposals.length, 0); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + 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, 1); assert.equal(decisions.decisions[0].action, "clarify"); assert.equal(decisions.decisions[0].status, "accepted"); @@ -5209,7 +5229,7 @@ test("blocked link body request creates Ling materialization clarify reply", asy assert.equal(runner.runs.length, 0); assert.match(created.aiReply.body, /local\/private links automatically|本机或私有地址/i); assert.doesNotMatch(created.aiReply.body, /body extraction is blocked|SSRF|metadata_only|extractor/i); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + 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, 1); assert.equal(decisions.decisions[0].action, "clarify"); assert.match(decisions.decisions[0].reason, /Link body materialization is blocked/); @@ -5399,8 +5419,8 @@ test("single-step file edit inherits target path from recent workflow context", assert.match(editProposals.proposals[0].prompt, new RegExp(`Target: ${escapeRegExp(cwd)}/README\\.md`)); const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(edited.message.id)}`); assert.deepEqual(routeDecisions.decisions, []); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(edited.message.id)}`); - assert.equal(decisions.decisions[0].action, "direct_workflow"); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(edited.message.id)}`); + assert.equal(decisions.decisions[0].action, "execute"); } finally { await daemon.close(); await rm(cwd, { recursive: true, force: true }); @@ -5463,8 +5483,8 @@ test("file creation how-to question remains ordinary Ling chat intent", async () assert.equal(created.aiReply.authorId, "ling_agent"); const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); assert.deepEqual(proposals.proposals, []); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(decisions.decisions, []); + 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)}`, { body: "为什么不能创建 README.md?", @@ -5507,8 +5527,8 @@ test("single-step test request becomes direct workflow with bounded bash tools", assert.equal(proposals.proposals[0].assigneeTeammateId, "teammate_builtin_qa"); assert.deepEqual(proposals.proposals[0].requiredTools, ["bash"]); assert.match(proposals.proposals[0].riskSummary, /bash/); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions[0].action, "direct_workflow"); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions[0].action, "execute"); } finally { await daemon.close(); await rm(cwd, { recursive: true, force: true }); @@ -5547,7 +5567,7 @@ test("high-risk push merge request auto-starts when required tools are not denie const workflows = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`); assert.equal(workflows.workflows.length, 1); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); assert.equal(decisions.decisions.some((decision) => /High-risk side effect is runtime-gated: git_push_or_merge/.test(decision.reason)), true); } finally { await daemon.close(); @@ -5609,8 +5629,8 @@ test("high-risk thread follow-up creates a proposal without auto-starting", asyn const workflows = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`); assert.equal(workflows.workflows.length, 0); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.some((decision) => decision.action === "direct_workflow" && decision.status === "accepted"), true); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.some((decision) => decision.action === "execute" && decision.status === "accepted"), true); } finally { await daemon.close(); await rm(cwd, { recursive: true, force: true }); @@ -5649,7 +5669,7 @@ test("high-risk push merge request blocks before run when required tool is denie assert.equal(proposals.proposals[0].status, "draft"); assert.equal(proposals.proposals[0].linkedWorkflowId, undefined); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); assert.equal(decisions.decisions.some((decision) => decision.status === "failed" && /denies required tools: bash/.test(decision.errorMessage ?? decision.reason)), true); } finally { await daemon.close(); @@ -5703,11 +5723,11 @@ test("model-assisted channel intent no_reply falls through to ordinary Ling repl assert.equal(routeDecisions.decisions[0].action, "reply"); assert.equal(routeDecisions.decisions[0].status, "accepted"); assert.equal(routeDecisions.decisions[0].replyMessageId, created.aiReply.id); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + 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, 1); assert.equal(decisions.decisions[0].action, "no_reply"); assert.equal(decisions.decisions[0].status, "suppressed"); - assert.equal(decisions.decisions[0].createdBy, "model"); + assert.equal(decisions.decisions[0].createdBy, "model_policy"); } finally { await daemon.close(); await rm(cwd, { recursive: true, force: true }); @@ -5749,8 +5769,167 @@ test("model-assisted channel intent selector does not fall back to phrase-rule w await waitForCondition(() => runner.runs.length === 2, "ordinary channel reply after selector parse failure"); const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); assert.equal(proposals.proposals.length, 0); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 0); + 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); + assert.equal(decisions.decisions[0].createdBy, "deterministic"); + assert.match(decisions.decisions[0].reason, /fail-open to an ordinary reply/i); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("issue #421 article continuation stays reply while an explicit README save executes", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-issue-421-article-")); + const runner = new ScriptedRunner([ + JSON.stringify({ action: "reply", reason: "The user asked for conversational article content." }), + "提纲:一、可恢复记忆;二、选择性投影;三、多 Agent 一致性。", + JSON.stringify({ action: "reply", reason: "The user confirmed that the article should continue in chat." }), + "正文:可恢复记忆的关键不是无限拼接历史,而是保留可追溯事实并按当前任务投影。", + JSON.stringify({ + action: "execute", + reason: "The user explicitly requested a local README write.", + execution: { kind: "file_create", targetKind: "file" }, + }), + "README.md saved.", + ]); + const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); + 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: "Issue 421 article continuity", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + + const article = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?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)}`, { + body: "需要,开始吧", + teammateId: "ling_agent", + requestAiReply: true, + }); + const continuedMessages = await waitForChannelMessageStatus( + daemon.url, + cwd, + channel.id, + continuation.aiReply.id, + "ready", + ); + const continuedReply = continuedMessages.messages.find((message) => message.id === continuation.aiReply.id); + assert.match(continuedReply.body, /可恢复记忆/); + + const articleDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(article.message.id)}`); + const continuationDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(continuation.message.id)}`); + assertSingleReplyIntentDecision(articleDecisions.decisions, article.message.id); + assertSingleReplyIntentDecision(continuationDecisions.decisions, continuation.message.id); + assert.equal(articleDecisions.decisions[0].createdBy, "model_policy"); + assert.equal(continuationDecisions.decisions[0].createdBy, "model_policy"); + assert.equal(continuationDecisions.decisions[0].contextSegmentId, articleDecisions.decisions[0].contextSegmentId); + assert.equal(continuationDecisions.decisions[0].clarification, undefined); + assert.equal(continuationDecisions.decisions[0].execution, undefined); + 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)}`, { + body: "把刚才的文章保存到 README.md。", + teammateId: "ling_agent", + requestAiReply: true, + }); + assertCoordinatorWorkflowStartReply(save); + assert.equal(save.autoStartedWorkflow.workflowSteps.length, 1); + const saveProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(save.message.id)}`); + assert.equal(saveProposals.proposals.length, 1); + assert.equal(saveProposals.proposals[0].status, "accepted"); + assert.match(saveProposals.proposals[0].prompt, /Target: README\.md/); + const saveDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(save.message.id)}`); + assert.equal(saveDecisions.decisions.length, 2); + assert(saveDecisions.decisions.every((decision) => decision.action === "execute")); + assert(saveDecisions.decisions.every((decision) => decision.execution.kind === "file_create")); + assert(saveDecisions.decisions.every((decision) => decision.execution.targetKind === "file")); + assert(saveDecisions.decisions.every((decision) => decision.proposalId === saveProposals.proposals[0].id)); + assert(saveDecisions.decisions.every((decision) => { + assertIntentDecisionInspectPayloadIsSafe(decision); + return true; + })); + const classified = saveDecisions.decisions.find((decision) => decision.createdBy === "model_policy"); + const started = saveDecisions.decisions.find((decision) => decision.workflowId === save.autoStartedWorkflow.workflow.id); + assert.ok(classified); + assert.ok(started); + assert.deepEqual(started.resultItemIds, [save.aiReply.id]); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("issue #421 unrelated snake-game request escapes an old target clarification", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-issue-421-segment-reset-")); + const runner = new ScriptedRunner([ + JSON.stringify({ + action: "clarify", + reason: "A local write needs a concrete destination.", + clarification: { missingFields: ["target"], question: "要保存到哪个目标文件?" }, + }), + JSON.stringify({ action: "reply", reason: "The snake-game design is independently answerable in chat." }), + "贪吃蛇玩法:方向键移动,吃到食物增长,撞墙或撞到自己结束。", + ]); + runner.segmentationResponses = [JSON.stringify({ + action: "propose_new", + confidence: 0.96, + relatedSegmentIds: [], + reason: "The snake-game design is unrelated to the unresolved file destination.", + })]; + const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); + 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: "Issue 421 clarification isolation", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + + const oldRequest = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/messages?cwd=${encodeURIComponent(cwd)}`, { + body: "把这段内容写到本地文件里。", + teammateId: "ling_agent", + requestAiReply: true, + }); + assert.equal(oldRequest.aiReply.status, "ready"); + assert.match(oldRequest.aiReply.body, /目标文件/); + const oldDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(oldRequest.message.id)}`); + assert.equal(oldDecisions.decisions.length, 1); + 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)}`, { + body: "再做一个贪吃蛇小游戏的方案,先讲玩法。", + teammateId: "ling_agent", + requestAiReply: true, + }); + const snakeMessages = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, snake.aiReply.id, "ready"); + const snakeReply = snakeMessages.messages.find((message) => message.id === snake.aiReply.id); + assert.match(snakeReply.body, /贪吃蛇玩法/); + assert.doesNotMatch(snakeReply.body, /目标文件|target file/i); + const snakeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(snake.message.id)}`); + assertSingleReplyIntentDecision(snakeDecisions.decisions, snake.message.id); + assert.equal(snakeDecisions.decisions[0].createdBy, "model_policy"); + assert.notEqual(snakeDecisions.decisions[0].contextSegmentId, oldDecisions.decisions[0].contextSegmentId); + assert.equal(runner.segmentationRuns.length, 1); + const snakeProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(snake.message.id)}`); + assert.deepEqual(snakeProposals.proposals, []); } finally { await daemon.close(); await rm(cwd, { recursive: true, force: true }); @@ -6504,8 +6683,8 @@ test("attachment-only channel turn reaches Ling Agent with metadata and preview" const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); assert.equal(routeDecisions.decisions.length, 1); assert.equal(routeDecisions.decisions[0].action, "reply"); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(intentDecisions.decisions, []); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assertSingleReplyIntentDecision(intentDecisions.decisions, created.message.id); } finally { await daemon.close(); await rm(cwd, { recursive: true, force: true }); @@ -12164,7 +12343,10 @@ function wrapTestRunnerContextSegmentation(runner) { return runner.runTask(options); } runner.segmentationRuns.push(options); - const text = JSON.stringify({ action: "uncertain", candidateSegmentIds: [] }); + const configuredResponse = runner.segmentationResponses?.shift(); + const text = typeof configuredResponse === "function" + ? configuredResponse(options) + : configuredResponse ?? JSON.stringify({ action: "uncertain", candidateSegmentIds: [] }); options.onEvent?.({ type: "assistant_text_delta", text }); return { taskId: options.task.id, @@ -12485,18 +12667,21 @@ function assertIntentDecisionInspectPayloadIsSafe(decision) { "id", "workspaceId", "channelId", - "sourceMessageId", + "sourceTurnId", + "sourceItemId", + "contextSegmentId", + "contextProjectionVersion", "action", - "selectedTeammateId", "selectedTeammateIds", "proposalId", - "replyMessageId", - "replyMessageIds", + "resultItemIds", "workflowId", "reason", + "clarification", + "execution", "status", "createdBy", - "errorMessage", + "errorCode", "createdAt", "updatedAt", ]); @@ -12529,6 +12714,15 @@ function assertIntentDecisionInspectPayloadIsSafe(decision) { } } +function assertSingleReplyIntentDecision(decisions, sourceItemId) { + assert.equal(decisions.length, 1); + assertIntentDecisionInspectPayloadIsSafe(decisions[0]); + assert.equal(decisions[0].schemaVersion, 2); + assert.equal(decisions[0].sourceItemId, sourceItemId); + assert.equal(decisions[0].action, "reply"); + assert.equal(decisions[0].status, "accepted"); +} + async function waitForChannelMessageStatus(baseUrl, cwd, channelId, messageId, status, threadId) { const deadline = Date.now() + 2_000; let latest;