Skip to content

Commit aa850a7

Browse files
authored
Merge pull request #44 from MaxLinCode/codex/turn-routing-policy-split
Land conversation state and routing layers
2 parents 98d18d4 + 561fee4 commit aa850a7

28 files changed

Lines changed: 2753 additions & 176 deletions

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

Lines changed: 68 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
);
@@ -1469,6 +1498,7 @@ describe("telegram webhook route", () => {
14691498
});
14701499
expect(conversationResponder).toHaveBeenCalledWith({
14711500
route: "conversation",
1501+
rawText: "How should I prioritize this week?",
14721502
normalizedText: "How should I prioritize this week?",
14731503
recentTurns: [
14741504
{
@@ -1557,10 +1587,11 @@ describe("telegram webhook route", () => {
15571587
summary: "The user is discussing a possible move to tomorrow morning."
15581588
}),
15591589
conversationResponder,
1560-
turnRouter: async () => ({
1561-
route: "conversation_then_mutation",
1562-
reason: "Mixed turn should discuss first.",
1563-
writesAllowed: false
1590+
turnRouter: async () => buildRoutedTurn({
1591+
turnType: "planning_request",
1592+
action: "present_proposal",
1593+
confidence: 0.68,
1594+
ambiguity: "low"
15641595
})
15651596
}
15661597
);
@@ -1586,6 +1617,7 @@ describe("telegram webhook route", () => {
15861617
expect(listOutgoingBotEventsForTests()).toHaveLength(1);
15871618
expect(conversationResponder).toHaveBeenCalledWith({
15881619
route: "conversation_then_mutation",
1620+
rawText: "Could we move it to tomorrow morning?",
15891621
normalizedText: "Could we move it to tomorrow morning?",
15901622
recentTurns: [
15911623
{
@@ -1647,17 +1679,17 @@ describe("telegram webhook route", () => {
16471679
throw new Error("summary unavailable");
16481680
},
16491681
conversationResponder,
1650-
turnRouter: async () => ({
1651-
route: "conversation",
1652-
reason: "Discussion turn.",
1653-
writesAllowed: false
1682+
turnRouter: async () => buildRoutedTurn({
1683+
turnType: "informational",
1684+
action: "reply_only"
16541685
})
16551686
}
16561687
);
16571688

16581689
expect(response.status).toBe(200);
16591690
expect(conversationResponder).toHaveBeenCalledWith({
16601691
route: "conversation",
1692+
rawText: "Should we talk this through first?",
16611693
normalizedText: "Should we talk this through first?",
16621694
recentTurns: [
16631695
{
@@ -1713,10 +1745,9 @@ describe("telegram webhook route", () => {
17131745
conversationResponder: async () => ({
17141746
reply: "From our recent exchange, it sounds like you mean the dentist reminder, but I am not treating that as confirmed state."
17151747
}),
1716-
turnRouter: async () => ({
1717-
route: "conversation",
1718-
reason: "Write-adjacent question still needs cautious conversational handling.",
1719-
writesAllowed: false
1748+
turnRouter: async () => buildRoutedTurn({
1749+
turnType: "informational",
1750+
action: "reply_only"
17201751
})
17211752
}
17221753
);

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ describe("conversation response service", () => {
1010
const result = await buildConversationResponse(
1111
{
1212
route: "conversation",
13+
rawText: "How should I prioritize this week?",
1314
normalizedText: "How should I prioritize this week?",
1415
recentTurns: [
1516
{
@@ -28,6 +29,7 @@ describe("conversation response service", () => {
2829
expect(result.reply).toContain("deadline-driven work");
2930
expect(respond).toHaveBeenCalledWith({
3031
route: "conversation",
32+
rawText: "How should I prioritize this week?",
3133
normalizedText: "How should I prioritize this week?",
3234
recentTurns: [
3335
{
@@ -44,6 +46,7 @@ describe("conversation response service", () => {
4446
const result = await buildConversationResponse(
4547
{
4648
route: "conversation_then_mutation",
49+
rawText: "Could we move it to tomorrow morning?",
4750
normalizedText: "Could we move it to tomorrow morning?",
4851
recentTurns: [
4952
{
@@ -68,10 +71,11 @@ describe("conversation response service", () => {
6871
await expect(
6972
buildConversationResponse({
7073
route: "conversation",
74+
rawText: " ",
7175
normalizedText: " ",
7276
recentTurns: [],
7377
memorySummary: null
7478
})
75-
).rejects.toThrow("must include non-empty normalizedText");
79+
).rejects.toThrow("must include non-empty rawText and normalizedText");
7680
});
7781
});

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)