Skip to content

Commit 093a03d

Browse files
MaxLinCodeclaude
andcommitted
fix: generic parent ref resolution in resolveWriteTarget
When focus_entity_id points at an entity with parentTargetRef (e.g. a clarification), follow the ref to the actual write target. Turn-type agnostic — works for any entity kind with a parent back-pointer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 95aa6b1 commit 093a03d

2 files changed

Lines changed: 149 additions & 0 deletions

File tree

apps/web/src/lib/server/turn-router.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,139 @@ describe("resolveWriteTarget", () => {
678678
expect(result).toEqual({});
679679
});
680680

681+
it("follows parentTargetRef from entity registry when focus points at a clarification", () => {
682+
const result = resolveWriteTarget(
683+
{
684+
focus_entity_id: "clar-1",
685+
currently_editable_entity_id: null,
686+
last_user_mentioned_entity_ids: [],
687+
last_presented_items: [],
688+
pending_clarifications: [],
689+
mode: "clarifying",
690+
},
691+
[
692+
{
693+
id: "clar-1",
694+
conversationId: "c-1",
695+
kind: "clarification",
696+
label: "What time?",
697+
status: "active",
698+
createdAt: "2026-04-09T10:00:00.000Z",
699+
updatedAt: "2026-04-09T10:00:00.000Z",
700+
data: {
701+
prompt: "What time?",
702+
reason: "scheduleFields.time",
703+
open: true,
704+
parentTargetRef: { entityId: "task-1" },
705+
},
706+
},
707+
],
708+
"clarification_answer",
709+
);
710+
711+
expect(result.targetEntityId).toBe("task-1");
712+
});
713+
714+
it("follows parentTargetRef regardless of turn type", () => {
715+
const result = resolveWriteTarget(
716+
{
717+
focus_entity_id: "clar-1",
718+
currently_editable_entity_id: null,
719+
last_user_mentioned_entity_ids: [],
720+
last_presented_items: [],
721+
pending_clarifications: [],
722+
mode: "clarifying",
723+
},
724+
[
725+
{
726+
id: "clar-1",
727+
conversationId: "c-1",
728+
kind: "clarification",
729+
label: "What time?",
730+
status: "active",
731+
createdAt: "2026-04-09T10:00:00.000Z",
732+
updatedAt: "2026-04-09T10:00:00.000Z",
733+
data: {
734+
prompt: "What time?",
735+
reason: "scheduleFields.time",
736+
open: true,
737+
parentTargetRef: { entityId: "task-1" },
738+
},
739+
},
740+
],
741+
"edit_request",
742+
);
743+
744+
expect(result.targetEntityId).toBe("task-1");
745+
});
746+
747+
it("uses candidate ID as-is when entity has no parentTargetRef", () => {
748+
const result = resolveWriteTarget(
749+
{
750+
focus_entity_id: "task-1",
751+
currently_editable_entity_id: null,
752+
last_user_mentioned_entity_ids: [],
753+
last_presented_items: [],
754+
pending_clarifications: [],
755+
mode: "planning",
756+
},
757+
[
758+
{
759+
id: "task-1",
760+
conversationId: "c-1",
761+
kind: "task",
762+
label: "Gym",
763+
status: "active",
764+
createdAt: "2026-04-09T10:00:00.000Z",
765+
updatedAt: "2026-04-09T10:00:00.000Z",
766+
data: {
767+
taskId: "t-1",
768+
title: "Gym",
769+
lifecycleState: "scheduled",
770+
scheduledStartAt: null,
771+
scheduledEndAt: null,
772+
},
773+
},
774+
],
775+
"edit_request",
776+
);
777+
778+
expect(result.targetEntityId).toBe("task-1");
779+
});
780+
781+
it("returns no targetEntityId when parentTargetRef is null (new plan)", () => {
782+
const result = resolveWriteTarget(
783+
{
784+
focus_entity_id: "clar-1",
785+
currently_editable_entity_id: null,
786+
last_user_mentioned_entity_ids: [],
787+
last_presented_items: [],
788+
pending_clarifications: [],
789+
mode: "clarifying",
790+
},
791+
[
792+
{
793+
id: "clar-1",
794+
conversationId: "c-1",
795+
kind: "clarification",
796+
label: "What time?",
797+
status: "active",
798+
createdAt: "2026-04-09T10:00:00.000Z",
799+
updatedAt: "2026-04-09T10:00:00.000Z",
800+
data: {
801+
prompt: "What time?",
802+
reason: "scheduleFields.time",
803+
open: true,
804+
parentTargetRef: null,
805+
},
806+
},
807+
],
808+
"clarification_answer",
809+
);
810+
811+
expect(result.targetEntityId).toBeUndefined();
812+
});
813+
681814
it("attaches resolvedProposalId for non-confirmation turns when single proposal exists", () => {
682815
const result = resolveWriteTarget(
683816
null,

apps/web/src/lib/server/turn-router.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,22 @@ export function resolveWriteTarget(
4444
discourseState?.focus_entity_id ?? null,
4545
]);
4646

47+
// Generic parent ref resolution: if the candidate entity has a
48+
// parentTargetRef, follow it to the actual write target.
49+
if (resolvedEntityIds[0]) {
50+
const entity = entityRegistry.find((e) => e.id === resolvedEntityIds[0]);
51+
if (entity && "parentTargetRef" in entity.data) {
52+
const parentId = (
53+
entity.data as { parentTargetRef: { entityId: string } | null }
54+
).parentTargetRef?.entityId;
55+
if (parentId) {
56+
resolvedEntityIds[0] = parentId;
57+
} else {
58+
resolvedEntityIds.shift();
59+
}
60+
}
61+
}
62+
4763
const activeProposals = entityRegistry.filter(
4864
(e): e is Extract<ConversationEntity, { kind: "proposal_option" }> =>
4965
e.kind === "proposal_option" &&

0 commit comments

Comments
 (0)