Skip to content

Commit 1d9d74d

Browse files
authored
Merge pull request #49 from MaxLinCode/codex/native-turn-routing
feat: implement native turn interpretation and policy routing
2 parents 2642f89 + ff30b92 commit 1d9d74d

11 files changed

Lines changed: 790 additions & 416 deletions

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

Lines changed: 103 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,23 @@ import { getDefaultCalendarAdapter, resetCalendarAdapterForTests } from "@atlas/
2323

2424
import { handleTelegramWebhook } from "@/lib/server/telegram-webhook";
2525

26-
const { editTelegramMessageMock, sendTelegramMessageMock, sendTelegramChatActionMock, routeTurnWithResponsesMock } =
27-
vi.hoisted(() => ({
28-
editTelegramMessageMock: vi.fn(),
26+
const {
27+
editTelegramMessageMock,
28+
sendTelegramMessageMock,
29+
sendTelegramChatActionMock,
30+
routeTurnWithResponsesMock,
31+
summarizeConversationMemoryWithResponsesMock,
32+
respondToConversationTurnWithResponsesMock,
33+
recoverConfirmedMutationWithResponsesMock
34+
} = vi.hoisted(() => ({
35+
editTelegramMessageMock: vi.fn(),
2936
sendTelegramMessageMock: vi.fn(),
3037
sendTelegramChatActionMock: vi.fn(),
31-
routeTurnWithResponsesMock: vi.fn()
32-
}));
38+
routeTurnWithResponsesMock: vi.fn(),
39+
summarizeConversationMemoryWithResponsesMock: vi.fn(),
40+
respondToConversationTurnWithResponsesMock: vi.fn(),
41+
recoverConfirmedMutationWithResponsesMock: vi.fn()
42+
}));
3343

3444
vi.mock("@atlas/integrations", async () => {
3545
const actual = await vi.importActual<typeof import("@atlas/integrations")>("@atlas/integrations");
@@ -68,9 +78,12 @@ vi.mock("@atlas/integrations", async () => {
6878
]
6979
}),
7080
editTelegramMessage: editTelegramMessageMock,
81+
respondToConversationTurnWithResponses: respondToConversationTurnWithResponsesMock,
82+
recoverConfirmedMutationWithResponses: recoverConfirmedMutationWithResponsesMock,
7183
routeTurnWithResponses: routeTurnWithResponsesMock,
7284
sendTelegramChatAction: sendTelegramChatActionMock,
73-
sendTelegramMessage: sendTelegramMessageMock
85+
sendTelegramMessage: sendTelegramMessageMock,
86+
summarizeConversationMemoryWithResponses: summarizeConversationMemoryWithResponsesMock
7487
};
7588
});
7689

@@ -262,10 +275,25 @@ beforeEach(async () => {
262275
sendTelegramMessageMock.mockReset();
263276
sendTelegramChatActionMock.mockReset();
264277
routeTurnWithResponsesMock.mockReset();
278+
summarizeConversationMemoryWithResponsesMock.mockReset();
279+
respondToConversationTurnWithResponsesMock.mockReset();
280+
recoverConfirmedMutationWithResponsesMock.mockReset();
265281
routeTurnWithResponsesMock.mockResolvedValue({
266282
route: "mutation",
267283
reason: "Direct scheduling request."
268284
});
285+
summarizeConversationMemoryWithResponsesMock.mockResolvedValue({
286+
summary: "Recent conversation summary."
287+
});
288+
respondToConversationTurnWithResponsesMock.mockResolvedValue({
289+
reply: "Mocked conversation reply."
290+
});
291+
recoverConfirmedMutationWithResponsesMock.mockResolvedValue({
292+
outcome: "needs_clarification",
293+
recoveredText: null,
294+
reason: "Mocked recovery fallback.",
295+
userReplyMessage: "Could you clarify what you want me to change?"
296+
});
269297
sendTelegramMessageMock.mockResolvedValue({
270298
ok: true,
271299
result: {
@@ -491,6 +519,9 @@ describe("telegram webhook route", () => {
491519

492520
it("lets mixed-intent messages fall through to the normal router even with outstanding followups", async () => {
493521
const { store, followUpStore } = await seedOutstandingFollowUpBundle(["Review launch checklist", "Send investor update"]);
522+
const conversationResponder = vi.fn(async () => ({
523+
reply: "What time tomorrow should I schedule the dentist?"
524+
}));
494525

495526
const response = await handleTelegramWebhook(
496527
buildRequest({
@@ -507,14 +538,25 @@ describe("telegram webhook route", () => {
507538
store,
508539
followUpStore,
509540
calendar: getDefaultCalendarAdapter(),
510-
primeProcessingStore: seedInboxItemForProcessingTests
541+
primeProcessingStore: seedInboxItemForProcessingTests,
542+
conversationResponder
511543
}
512544
);
513545

514546
expect(response.status).toBe(200);
515-
expect(routeTurnWithResponsesMock).toHaveBeenCalledTimes(1);
516-
expect((response.body as { processing: { outcome: string } }).processing.outcome).toBe("planned");
547+
expect(response.body).toMatchObject({
548+
processing: {
549+
outcome: "conversation_replied",
550+
reply: "What time tomorrow should I schedule the dentist?"
551+
},
552+
routing: {
553+
policy: {
554+
action: "ask_clarification"
555+
}
556+
}
557+
});
517558
expect(listTasksForTests().filter((task) => task.lifecycleState === "done")).toHaveLength(0);
559+
expect(conversationResponder).toHaveBeenCalledTimes(1);
518560
});
519561

520562
it("also gates /start for unlinked users", async () => {
@@ -806,6 +848,58 @@ describe("telegram webhook route", () => {
806848
expect(editTelegramMessageMock).toHaveBeenCalledTimes(1);
807849
});
808850

851+
it("does not keep clear scheduling requests in discuss-first mode", async () => {
852+
process.env.TELEGRAM_WEBHOOK_SECRET = "test-webhook-secret";
853+
854+
const response = await handleTelegramWebhook(
855+
buildRequest({
856+
update_id: 145,
857+
message: {
858+
message_id: 110,
859+
date: 1_700_000_020,
860+
text: "Schedule gym tomorrow at 6pm for 1 hour",
861+
chat: {
862+
id: 999,
863+
type: "private"
864+
},
865+
from: {
866+
id: 123,
867+
is_bot: false,
868+
first_name: "Max",
869+
last_name: "Lin"
870+
}
871+
}
872+
}),
873+
{
874+
store: getDefaultInboxProcessingStore(),
875+
calendar: getDefaultCalendarAdapter(),
876+
primeProcessingStore: seedInboxItemForProcessingTests
877+
}
878+
);
879+
880+
expect(response.status).toBe(200);
881+
expect(response.body).toMatchObject({
882+
accepted: true,
883+
turnRoute: "mutation",
884+
routing: {
885+
interpretation: {
886+
turnType: "planning_request",
887+
ambiguity: "none"
888+
},
889+
policy: {
890+
action: "execute_mutation"
891+
}
892+
},
893+
processing: {
894+
outcome: "planned"
895+
}
896+
});
897+
expect(sendTelegramMessageMock).toHaveBeenCalledWith({
898+
chatId: "999",
899+
text: "Checking your schedule"
900+
});
901+
});
902+
809903
it("treats confirmed mutation turns as write-capable when recovery finds one concrete proposal", async () => {
810904
process.env.TELEGRAM_WEBHOOK_SECRET = "test-webhook-secret";
811905
await recordOutgoingTelegramMessageIfNew({

apps/web/src/lib/server/decide-turn-policy.test.ts

Lines changed: 65 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ describe("decideTurnPolicy", () => {
2424
});
2525
});
2626

27-
it("asks for clarification when write intent still has missing slots", () => {
27+
it("asks for clarification when a scheduling request is still missing required slots", () => {
2828
expect(
2929
decideTurnPolicy({
3030
interpretation: {
3131
turnType: "planning_request",
32-
confidence: 0.7,
32+
confidence: 0.58,
3333
resolvedEntityIds: [],
34-
ambiguity: "low",
34+
ambiguity: "high",
3535
missingSlots: ["time"]
3636
},
3737
routingContext: {
@@ -46,28 +46,28 @@ describe("decideTurnPolicy", () => {
4646
});
4747
});
4848

49-
it("presents a proposal for medium-confidence write intent", () => {
49+
it("does not force proposal mode for low-confidence writes alone", () => {
5050
expect(
5151
decideTurnPolicy({
5252
interpretation: {
5353
turnType: "planning_request",
5454
confidence: 0.68,
5555
resolvedEntityIds: [],
56-
ambiguity: "low"
56+
ambiguity: "none"
5757
},
5858
routingContext: {
59-
rawText: "Could we move it to tomorrow morning?",
60-
normalizedText: "Could we move it to tomorrow morning?",
59+
rawText: "Schedule gym tomorrow at 6pm",
60+
normalizedText: "Schedule gym tomorrow at 6pm",
6161
recentTurns: []
6262
}
6363
})
6464
).toMatchObject({
65-
action: "present_proposal",
66-
requiresConfirmation: true
65+
action: "execute_mutation",
66+
requiresConfirmation: false
6767
});
6868
});
6969

70-
it("executes direct confident write intent", () => {
70+
it("executes complete scheduling requests with no ambiguity", () => {
7171
expect(
7272
decideTurnPolicy({
7373
interpretation: {
@@ -77,8 +77,8 @@ describe("decideTurnPolicy", () => {
7777
ambiguity: "none"
7878
},
7979
routingContext: {
80-
rawText: "Schedule gym tomorrow evening",
81-
normalizedText: "Schedule gym tomorrow evening",
80+
rawText: "Schedule gym tomorrow at 6pm for 1 hour",
81+
normalizedText: "Schedule gym tomorrow at 6pm for 1 hour",
8282
recentTurns: []
8383
}
8484
})
@@ -101,7 +101,23 @@ describe("decideTurnPolicy", () => {
101101
routingContext: {
102102
rawText: "Yes",
103103
normalizedText: "Yes",
104-
recentTurns: []
104+
recentTurns: [],
105+
entityRegistry: [
106+
{
107+
id: "proposal-1",
108+
conversationId: "conversation-1",
109+
kind: "proposal_option",
110+
label: "Schedule it at 3pm",
111+
status: "active",
112+
createdAt: "2026-03-20T16:00:00.000Z",
113+
updatedAt: "2026-03-20T16:00:00.000Z",
114+
data: {
115+
route: "conversation_then_mutation",
116+
replyText: "Would you like me to schedule it at 3pm?",
117+
confirmationRequired: true
118+
}
119+
}
120+
]
105121
}
106122
})
107123
).toMatchObject({
@@ -110,45 +126,63 @@ describe("decideTurnPolicy", () => {
110126
});
111127
});
112128

113-
it("falls back to clarification when confirmation has no recoverable proposal", () => {
129+
it("asks for clarification on ambiguous write-like turns", () => {
114130
expect(
115131
decideTurnPolicy({
116132
interpretation: {
117-
turnType: "confirmation",
118-
confidence: 0.4,
119-
resolvedEntityIds: [],
120-
ambiguity: "high"
133+
turnType: "edit_request",
134+
confidence: 0.6,
135+
resolvedEntityIds: ["task-1"],
136+
ambiguity: "high",
137+
ambiguityReason: "Underspecified edit."
121138
},
122139
routingContext: {
123-
rawText: "Yes",
124-
normalizedText: "Yes",
140+
rawText: "Move it",
141+
normalizedText: "Move it",
125142
recentTurns: []
126143
}
127144
})
128145
).toMatchObject({
129-
action: "ask_clarification",
130-
clarificationSlots: ["proposal"]
146+
action: "ask_clarification"
131147
});
132148
});
133149

134-
it("asks for clarification on high-ambiguity write-like turns", () => {
150+
it("uses present_proposal only for explicit confirmation-required policy", () => {
135151
expect(
136152
decideTurnPolicy({
137153
interpretation: {
138-
turnType: "edit_request",
139-
confidence: 0.6,
154+
turnType: "planning_request",
155+
confidence: 0.93,
140156
resolvedEntityIds: ["task-1"],
141-
ambiguity: "high",
142-
ambiguityReason: "Underspecified edit."
157+
resolvedProposalId: "proposal-1",
158+
ambiguity: "none"
143159
},
144160
routingContext: {
145-
rawText: "Move it",
146-
normalizedText: "Move it",
147-
recentTurns: []
161+
rawText: "Schedule it tomorrow at 6pm",
162+
normalizedText: "Schedule it tomorrow at 6pm",
163+
recentTurns: [],
164+
entityRegistry: [
165+
{
166+
id: "proposal-1",
167+
conversationId: "conversation-1",
168+
kind: "proposal_option",
169+
label: "Schedule it tomorrow at 6pm",
170+
status: "active",
171+
createdAt: "2026-03-20T16:00:00.000Z",
172+
updatedAt: "2026-03-20T16:00:00.000Z",
173+
data: {
174+
route: "conversation_then_mutation",
175+
replyText: "Would you like me to schedule it tomorrow at 6pm?",
176+
confirmationRequired: true,
177+
targetEntityId: "task-1"
178+
}
179+
}
180+
]
148181
}
149182
})
150183
).toMatchObject({
151-
action: "ask_clarification"
184+
action: "present_proposal",
185+
requiresConfirmation: true
152186
});
153187
});
154188
});

0 commit comments

Comments
 (0)