fix(linsight): thread task-mode skill selection to the submit

Follow-up to the None≡[] skill gate: with skills now strictly opt-in, the
daily task-mode path (ChatView → useAiChat → unified /chat/completions →
_to_linsight_submit) exposed a latent gap — it never carried the picked
skills, so the stored SV had skills=None and NOTHING was materialized. The
picker looked broken ("workspace has no such skill file"). Previously this
was masked because None loaded every enabled skill.

Thread the selection end-to-end:
- APIChatCompletion gains `skills` (Track H); _to_linsight_submit maps it
  onto the linsight submit schema (None/[] = no skills, opt-in list = those).
- useAiChat sends the picked skill names (taskModeSkillsState('new')) on
  task-mode turns only; the daily chain ignores the field.
- tests: _to_linsight_submit forwards a selection / stays None when absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
LineWalker
2026-07-15 11:48:00 +08:00
co-authored by Claude Opus 4.8
parent 58325411d1
commit 97d43716a2
4 changed files with 45 additions and 2 deletions
@@ -100,6 +100,13 @@ class APIChatCompletion(BaseModel):
# daily chain; the turn still lives in the same conversation (chat_id).
task_mode: bool | None = False
# --- F035 Track H: per-turn selected skill names (task mode only) ---
# The skills the user picked in the daily task-mode input. Threaded onto the
# linsight submit schema by ``_to_linsight_submit``; ``None``/``[]`` both mean
# "no skills this turn" (skills are opt-in, see materialize_session_skills).
# Ignored on the plain daily chain.
skills: list[str] | None = None
# --- Preserved (new code-path also honours use_knowledge_base / files) ---
use_knowledge_base: UseKnowledgeBaseParam | None = None
files: list[dict] | None = None
@@ -2031,4 +2031,7 @@ def _to_linsight_submit(data: APIChatCompletion):
model=data.model or None,
files=submit_files,
tools=submit_tools,
# F035 Track H: thread the daily task-mode skill picker onto the submit so
# only the user's explicit selection is materialized (None/[] = no skills).
skills=data.skills,
)
@@ -106,3 +106,28 @@ def test_to_linsight_submit_no_knowledge_defaults_false():
assert submit.org_knowledge_enabled is False
assert submit.personal_knowledge_enabled is False
def test_to_linsight_submit_forwards_selected_skills():
"""The daily task-mode skill picker must reach the submit schema.
Regression: _to_linsight_submit dropped skills entirely, so the stored SV had
skills=None. That was masked while None meant "load all enabled skills"; once
None means "no skills", an explicit pick that never arrived silently loaded
nothing — the picker looked broken.
"""
data = APIChatCompletion(
clientTimestamp="t", conversationId="c", model="m", text="hi", task_mode=True, skills=["morning-report"]
)
submit = chat_service._to_linsight_submit(data)
assert submit.skills == ["morning-report"]
def test_to_linsight_submit_no_skills_stays_none():
data = APIChatCompletion(clientTimestamp="t", conversationId="c", model="m", text="hi", task_mode=True)
submit = chat_service._to_linsight_submit(data)
assert submit.skills is None
+10 -2
View File
@@ -17,7 +17,7 @@ import useAiChatSSE, { type SSESubmission } from "~/hooks/useAiChatSSE";
import { useGetBsConfig } from "~/hooks/queries/data-provider";
import { useLinsightManager } from "~/hooks/useLinsightManager";
import { startLinsight, getLinsightSessionVersionList } from "~/api/linsight";
import { SopStatus } from "~/store/linsight";
import { SopStatus, taskModeSkillsState } from "~/store/linsight";
const NO_PARENT = "00000000-0000-0000-0000-000000000000";
@@ -46,6 +46,10 @@ export default function useAiChat(initialConversationId: string = "new", isLings
// emits the new ChatResponse SSE format). Empty array keeps us on the
// legacy flow so existing tests / old clients aren't disrupted.
const [selectedAgentTools] = useRecoilState(store.selectedAgentTools);
// F035 Track H: skills picked in the daily task-mode input live in the shared
// 'new' atom (AiChatInput keys the picker there). Threaded onto the task-mode
// turn's submit payload so the user's explicit selection is what gets loaded.
const [dailyTaskSkills] = useRecoilState(taskModeSkillsState('new'));
// Admin-level org-KB toggle. Knowledge spaces remain available even when
// the org knowledge base feature is disabled, so we only strip org ids.
const { data: bsConfig } = useGetBsConfig();
@@ -244,6 +248,10 @@ export default function useAiChat(initialConversationId: string = "new", isLings
// F035 Track J (TJ-6): route this turn to the linsight task kernel
// via the SAME unified entry. Backend replies with a handoff event.
task_mode: taskMode,
// F035 Track H: send the picked skill names on task-mode turns so
// the backend materializes exactly those (empty = none). Omitted
// outside task mode (the daily chain ignores it).
skills: taskMode ? dailyTaskSkills.map((s) => s.name) : [],
};
// Correlation key for the user (question) message. Starts as the
@@ -609,7 +617,7 @@ export default function useAiChat(initialConversationId: string = "new", isLings
setIsStreaming(true);
setSseSubmission(submission);
},
[conversationId, isStreaming, chatModel, selectedOrgKbs, searchType, selectedAgentTools, isLingsi, createLinsight, updateLinsight, localize, queryClient]
[conversationId, isStreaming, chatModel, selectedOrgKbs, searchType, selectedAgentTools, dailyTaskSkills, isLingsi, createLinsight, updateLinsight, localize, queryClient]
);
// --- Stop generating ---