Skip to content

Commit ccce83d

Browse files
committed
Land conversation state and routing layers
1 parent 98d18d4 commit ccce83d

25 files changed

Lines changed: 2697 additions & 168 deletions

apps/web/src/app/api/telegram/webhook/route.test.ts

Lines changed: 65 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2-
import type { InboxPlanningOutput } from "@atlas/core";
2+
import type { InboxPlanningOutput, RoutedTurn, TurnPolicyAction, TurnInterpretationType } from "@atlas/core";
33
import {
44
getDefaultGoogleCalendarConnectionStore,
55
getDefaultFollowUpRuntimeStore,
@@ -108,6 +108,37 @@ function buildRequest(body: unknown, secret = "test-webhook-secret") {
108108
});
109109
}
110110

111+
function buildRoutedTurn(input: {
112+
turnType: TurnInterpretationType;
113+
action: TurnPolicyAction;
114+
confidence?: number;
115+
ambiguity?: "none" | "low" | "high";
116+
resolvedProposalId?: string;
117+
}) : RoutedTurn {
118+
return {
119+
interpretation: {
120+
turnType: input.turnType,
121+
confidence: input.confidence ?? 0.9,
122+
resolvedEntityIds: [],
123+
...(input.resolvedProposalId ? { resolvedProposalId: input.resolvedProposalId } : {}),
124+
ambiguity: input.ambiguity ?? "none"
125+
},
126+
policy: {
127+
action: input.action,
128+
reason: "test",
129+
requiresWrite: input.action === "execute_mutation" || input.action === "recover_and_execute",
130+
requiresConfirmation: input.action === "present_proposal",
131+
useMutationPipeline: input.action === "execute_mutation" || input.action === "recover_and_execute",
132+
...(input.resolvedProposalId ? { targetProposalId: input.resolvedProposalId } : {}),
133+
...(input.action === "recover_and_execute"
134+
? { mutationInputSource: "recovered_proposal" as const }
135+
: input.action === "execute_mutation"
136+
? { mutationInputSource: "direct_user_turn" as const }
137+
: {})
138+
}
139+
};
140+
}
141+
111142
async function seedOutstandingFollowUpBundle(titles: string[]) {
112143
const store = getDefaultInboxProcessingStore();
113144
const followUpStore = getDefaultFollowUpRuntimeStore();
@@ -749,10 +780,9 @@ describe("telegram webhook route", () => {
749780
store: getDefaultInboxProcessingStore(),
750781
calendar: getDefaultCalendarAdapter(),
751782
primeProcessingStore: seedInboxItemForProcessingTests,
752-
turnRouter: async () => ({
753-
route: "mutation",
754-
reason: "Direct scheduling request.",
755-
writesAllowed: true
783+
turnRouter: async () => buildRoutedTurn({
784+
turnType: "planning_request",
785+
action: "execute_mutation"
756786
})
757787
}
758788
);
@@ -863,10 +893,10 @@ describe("telegram webhook route", () => {
863893
summary: "The assistant proposed a concrete 3pm schedule and the user is now confirming it."
864894
}),
865895
confirmedMutationRecoverer,
866-
turnRouter: async () => ({
867-
route: "confirmed_mutation",
868-
reason: "The user is confirming a recent concrete proposal.",
869-
writesAllowed: true
896+
turnRouter: async () => buildRoutedTurn({
897+
turnType: "confirmation",
898+
action: "recover_and_execute",
899+
resolvedProposalId: "proposal-1"
870900
})
871901
}
872902
);
@@ -1019,10 +1049,10 @@ describe("telegram webhook route", () => {
10191049
reason: "The user refined the recent concrete proposal.",
10201050
userReplyMessage: "Done - I've moved it to 4pm."
10211051
}),
1022-
turnRouter: async () => ({
1023-
route: "confirmed_mutation",
1024-
reason: "The user is refining a recent concrete proposal.",
1025-
writesAllowed: true
1052+
turnRouter: async () => buildRoutedTurn({
1053+
turnType: "confirmation",
1054+
action: "recover_and_execute",
1055+
resolvedProposalId: "proposal-1"
10261056
})
10271057
}
10281058
);
@@ -1155,10 +1185,10 @@ describe("telegram webhook route", () => {
11551185
reason: "The latest turn clearly reports completion of the recent journaling task.",
11561186
userReplyMessage: "Got it."
11571187
}),
1158-
turnRouter: async () => ({
1159-
route: "confirmed_mutation",
1160-
reason: "The latest turn clearly completes one recent task.",
1161-
writesAllowed: true
1188+
turnRouter: async () => buildRoutedTurn({
1189+
turnType: "confirmation",
1190+
action: "recover_and_execute",
1191+
resolvedProposalId: "proposal-1"
11621192
})
11631193
}
11641194
);
@@ -1223,10 +1253,10 @@ describe("telegram webhook route", () => {
12231253
reason: "I have two recent proposals in view. Which one do you want me to apply?",
12241254
userReplyMessage: "I have two recent proposals in view. Which one do you want me to apply?"
12251255
}),
1226-
turnRouter: async () => ({
1227-
route: "confirmed_mutation",
1228-
reason: "This looks like a confirmation but the target is ambiguous.",
1229-
writesAllowed: true
1256+
turnRouter: async () => buildRoutedTurn({
1257+
turnType: "confirmation",
1258+
action: "recover_and_execute",
1259+
resolvedProposalId: "proposal-1"
12301260
})
12311261
}
12321262
);
@@ -1405,10 +1435,9 @@ describe("telegram webhook route", () => {
14051435
primeProcessingStore: seedInboxItemForProcessingTests,
14061436
conversationMemorySummarizer,
14071437
conversationResponder,
1408-
turnRouter: async () => ({
1409-
route: "conversation",
1410-
reason: "Planning dialogue request.",
1411-
writesAllowed: false
1438+
turnRouter: async () => buildRoutedTurn({
1439+
turnType: "informational",
1440+
action: "reply_only"
14121441
})
14131442
}
14141443
);
@@ -1557,10 +1586,11 @@ describe("telegram webhook route", () => {
15571586
summary: "The user is discussing a possible move to tomorrow morning."
15581587
}),
15591588
conversationResponder,
1560-
turnRouter: async () => ({
1561-
route: "conversation_then_mutation",
1562-
reason: "Mixed turn should discuss first.",
1563-
writesAllowed: false
1589+
turnRouter: async () => buildRoutedTurn({
1590+
turnType: "planning_request",
1591+
action: "present_proposal",
1592+
confidence: 0.68,
1593+
ambiguity: "low"
15641594
})
15651595
}
15661596
);
@@ -1647,10 +1677,9 @@ describe("telegram webhook route", () => {
16471677
throw new Error("summary unavailable");
16481678
},
16491679
conversationResponder,
1650-
turnRouter: async () => ({
1651-
route: "conversation",
1652-
reason: "Discussion turn.",
1653-
writesAllowed: false
1680+
turnRouter: async () => buildRoutedTurn({
1681+
turnType: "informational",
1682+
action: "reply_only"
16541683
})
16551684
}
16561685
);
@@ -1713,10 +1742,9 @@ describe("telegram webhook route", () => {
17131742
conversationResponder: async () => ({
17141743
reply: "From our recent exchange, it sounds like you mean the dentist reminder, but I am not treating that as confirmed state."
17151744
}),
1716-
turnRouter: async () => ({
1717-
route: "conversation",
1718-
reason: "Write-adjacent question still needs cautious conversational handling.",
1719-
writesAllowed: false
1745+
turnRouter: async () => buildRoutedTurn({
1746+
turnType: "informational",
1747+
action: "reply_only"
17201748
})
17211749
}
17221750
);

apps/web/src/lib/server/conversation-response.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ export async function buildConversationResponse(
2222
}
2323

2424
function parseConversationResponseInput(input: BuildConversationResponseInput): BuildConversationResponseInput {
25-
if (!input.normalizedText.trim()) {
26-
throw new Error("Conversation response input must include non-empty normalizedText.");
25+
if (!input.rawText.trim() || !input.normalizedText.trim()) {
26+
throw new Error("Conversation response input must include non-empty rawText and normalizedText.");
2727
}
2828

2929
return input;

apps/web/src/lib/server/conversation-state.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@ import {
1010
type PresentedItem,
1111
type ScheduleBlock,
1212
type Task,
13-
type TurnRoute
13+
type TurnInterpretation,
14+
type TurnPolicyAction
1415
} from "@atlas/core";
1516
import { type ProcessedInboxResult } from "@atlas/db";
1617

1718
type DeriveConversationReplyStateInput = {
1819
snapshot: ConversationStateSnapshot;
19-
route: Extract<TurnRoute, "conversation" | "conversation_then_mutation">;
20+
policyAction: Extract<TurnPolicyAction, "reply_only" | "ask_clarification" | "present_proposal">;
21+
interpretation: TurnInterpretation;
2022
reply: string;
23+
userTurnText: string;
2124
summaryText: string | null;
2225
occurredAt?: string;
2326
};
@@ -36,16 +39,22 @@ export function deriveConversationReplyState(input: DeriveConversationReplyState
3639
const newClarifications: PendingClarification[] = [];
3740
let nextFocusEntityId: string | null | undefined;
3841

39-
if (input.route === "conversation_then_mutation") {
42+
if (input.policyAction === "present_proposal") {
4043
const proposalEntity = buildConversationEntity(input.snapshot.conversation.id, {
4144
kind: "proposal_option",
4245
label: summarizeLabel(input.reply),
4346
status: "active",
4447
createdAt: occurredAt,
4548
updatedAt: occurredAt,
4649
data: {
47-
route: input.route,
48-
replyText: input.reply
50+
route: "conversation_then_mutation",
51+
replyText: input.reply,
52+
policyAction: input.policyAction,
53+
targetEntityId: input.interpretation.resolvedEntityIds[0] ?? null,
54+
mutationInputSource: null,
55+
confirmationRequired: true,
56+
originatingTurnText: input.userTurnText,
57+
missingSlots: input.interpretation.missingSlots
4958
}
5059
});
5160

@@ -60,7 +69,7 @@ export function deriveConversationReplyState(input: DeriveConversationReplyState
6069
nextFocusEntityId = proposalEntity.id;
6170
}
6271

63-
if (looksLikeClarification(input.reply)) {
72+
if (input.policyAction === "ask_clarification") {
6473
const clarificationEntity = buildConversationEntity(input.snapshot.conversation.id, {
6574
kind: "clarification",
6675
label: summarizeLabel(input.reply),
@@ -90,13 +99,13 @@ export function deriveConversationReplyState(input: DeriveConversationReplyState
9099
...(presentedItems.length > 0 ? { presentedItems } : {}),
91100
...(newClarifications.length > 0 ? { newClarifications } : {}),
92101
...(nextFocusEntityId !== undefined ? { focusEntityId: nextFocusEntityId } : {}),
93-
pendingConfirmation: input.route === "conversation_then_mutation",
102+
pendingConfirmation: input.policyAction === "present_proposal",
94103
validEntityIds: entityRegistry.map((entity) => entity.id)
95104
}).state;
96105

97106
return {
98107
summaryText: input.summaryText,
99-
mode: input.route as ConversationRecordMode,
108+
mode: getConversationModeForPolicy(input.policyAction),
100109
entityRegistry: trimEntityRegistry(entityRegistry),
101110
discourseState: nextDiscourseState
102111
};
@@ -254,14 +263,16 @@ function isTaskResolved(task: Task) {
254263
return task.lifecycleState === "done" || task.lifecycleState === "archived";
255264
}
256265

257-
function looksLikeClarification(reply: string) {
258-
return reply.trim().endsWith("?");
259-
}
260-
261266
function summarizeLabel(text: string) {
262267
return text.trim().slice(0, 120);
263268
}
264269

270+
function getConversationModeForPolicy(
271+
policyAction: DeriveConversationReplyStateInput["policyAction"]
272+
): ConversationRecordMode {
273+
return policyAction === "reply_only" ? "conversation" : "conversation_then_mutation";
274+
}
275+
265276
function buildConversationEntity(
266277
conversationId: string,
267278
input: {

0 commit comments

Comments
 (0)