mavend: give the voice handler a CoreAPI that can serve the day plan

wireVoice runs before the tick loop exists, so it could only be handed
the bare store adapter — and that adapter answers DayPlan with "not
available via direct store API", because a day plan is assembled by the
tick loop and is not a table to read. So queryDayPlan, which the query
chain reaches for "какие у меня планы на сегодня", failed for every
caller on the deployed daemon.

main already back-patches the other direction (daemonAPI.chatFn =
handler.handleText). This is the same seam in reverse, at both wiring
sites. No recursion risk: nothing in the voice path calls api.Chat.

With the plan reachable, it recited its reminders as literal JSON. The
payload unwrapper existed but was private to the phraser, so the day
plan had its own non-unwrapping copy. One owner now, store.ReminderText,
with the phraser delegating to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
kami
2026-08-01 23:17:10 +04:00
parent 17964d1162
commit b35151418a
6 changed files with 98 additions and 24 deletions
+28
View File
@@ -351,3 +351,31 @@ func TestTickDayPlanReadsTheStore(t *testing.T) {
t.Errorf("a reminder for next year is not today's plan: %q", plan.Spoken)
}
}
// TestHandlerUpgradesToTheDaemonAPI — wireVoice runs before the tick loop
// exists, so the handler starts with the bare store adapter, and that adapter
// refuses DayPlan ("not available via direct store API"). main back-patches
// the real one in. Without the patch every "какие у меня планы на сегодня"
// answered "не получилось собрать план" on the deployed daemon, 01-08-2026.
func TestHandlerUpgradesToTheDaemonAPI(t *testing.T) {
h := &reactiveHandler{api: ipc.NewStoreAPI(nil), now: planDay}
if _, err := h.api.DayPlan(context.Background()); err == nil {
t.Fatal("the bare store adapter served a day plan; this test is measuring nothing")
}
want := samplePlan()
h.upgradeAPI(&daemonAPI{
CoreAPI: ipc.UnimplementedCoreAPI{},
getDayPlan: func(context.Context) ipc.DayPlan { return want },
})
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня планы на сегодня?"},
})
if !ok {
t.Fatal("queryDayPlan passed on a plan question")
}
if reply != want.Spoken {
t.Fatalf("reply = %q, want the assembled plan", reply)
}
}
+4
View File
@@ -341,6 +341,9 @@ func run(args []string) error {
if voiceW != nil && voiceW.handler != nil {
api := coreAPI.(*daemonAPI)
api.chatFn = voiceW.handler.handleText
// And the reverse: the handler was wired with the bare store
// adapter, which cannot serve the day plan. See upgradeAPI.
voiceW.handler.upgradeAPI(api)
}
if voiceW != nil && voiceW.mcp != nil {
coreAPI.(*daemonAPI).getMCPServers = voiceW.mcp.status
@@ -603,6 +606,7 @@ func run(args []string) error {
}
if voiceW != nil && voiceW.handler != nil {
newAPI.chatFn = voiceW.handler.handleText
voiceW.handler.upgradeAPI(newAPI)
}
srv.SetAPI(newAPI)
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
+1 -1
View File
@@ -882,7 +882,7 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
}
reminders = append(reminders, morning.PlanEntry{
At: fire,
Text: strings.TrimSpace(r.Payload),
Text: r.Text(),
Kind: morning.PlanReminder,
})
}
+30 -6
View File
@@ -76,12 +76,15 @@ type reactiveHandler struct {
tts tts.Synthesizer
router *router.Router
embedder router.Embedder // reused for note write/query (same model as the classifier)
api ipc.CoreAPI
tools *tool.Executor
matcher *tool.Matcher
phraser phraser.Phraser
replier voice.Replier
now func() time.Time
// api — the CoreAPI the handler reads and writes through. Wired with the
// bare store adapter and UPGRADED by main once the daemonAPI exists; see
// upgradeAPI.
api ipc.CoreAPI
tools *tool.Executor
matcher *tool.Matcher
phraser phraser.Phraser
replier voice.Replier
now func() time.Time
// crawler reads a web page he names out loud (queryWeb). nil ⇒ on-demand
// page reading is off, which is the default: no `crawl` block, no fetch.
@@ -183,6 +186,27 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
return h.reply(ctx, replyText, nil)
}
// upgradeAPI points the handler at the daemon's own CoreAPI once main has
// built it.
//
// Wiring order forces this. wireVoice runs before the tick loop exists, so it
// can only be handed the bare store adapter — and that adapter answers DayPlan
// (and TickTrace, and MorningStatus) with "not available via direct store
// API", because a day plan is assembled by the tick loop and is not a table to
// read. So queryDayPlan, which the query chain reaches for "какие у меня планы
// на сегодня", failed on the deployed daemon for every caller. main already
// back-patches the other direction (daemonAPI.chatFn = handler.handleText);
// this is the same seam in reverse.
//
// Safe against the obvious loop: nothing in the voice path calls api.Chat, so
// pointing the handler at an API whose Chat IS the handler cannot recurse.
func (h *reactiveHandler) upgradeAPI(api ipc.CoreAPI) {
if h == nil || api == nil {
return
}
h.api = api
}
// handleText — the core reactive path without stt/tts. Used by the IPC Chat
// endpoint (and eventually by telegram). Splits out the audio bookends from
// HandlePushToTalk so text channels share the same routing logic.