From 33e1ab50e4ba50a318cc2c08541707679a98fbc8 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 29 Jul 2026 14:37:38 +0100 Subject: [PATCH] feat(mobile): add Kimi K3 to the model picker (port #3869) Ports the desktop Kimi K3 model support (#3869, #3883) to the React Native app. Adds `moonshotai/kimi-k3` to the cloud-task model picker, gated on the shared `KIMI_MODEL_FLAG`, with no reasoning-effort support (matching desktop, where Kimi is absent from the Claude effort table). When the flag is off Kimi is hidden and a persisted per-task selection of it falls back to a visible model, mirroring desktop's stripKimiModelOption. Generated-By: PostHog Code Task-Id: 21ef821f-1517-4bd0-a79d-074848e1c2b0 --- apps/mobile/src/app/task/[id].tsx | 13 +++- apps/mobile/src/app/task/index.tsx | 17 ++++- .../tasks/composer/TaskChatComposer.tsx | 18 ++++- .../features/tasks/composer/options.test.ts | 75 +++++++++++++++++++ .../src/features/tasks/composer/options.ts | 40 ++++++++++ 5 files changed, 156 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 086ba8efa2..93a30c62e2 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -13,7 +13,9 @@ import { DEFAULT_GATEWAY_MODEL, DEFAULT_REASONING_EFFORT, type ExecutionMode, + isModalModelId, isSupportedReasoningEffort, + KIMI_MODEL_FLAG, type SupportedReasoningEffort, serializeCloudPrompt, type Task, @@ -21,6 +23,7 @@ import { import { useQueryClient } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { useLocalSearchParams, useRouter } from "expo-router"; +import { useFeatureFlag } from "posthog-react-native"; import { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, @@ -187,10 +190,18 @@ export default function TaskDetailScreen() { const composerMode: ExecutionMode = (composerConfigMatchesAdapter ? composerConfig?.mode : undefined) ?? getDefaultExecutionModeForAdapter(composerAdapter); - const composerModel = + const kimiEnabled = !!useFeatureFlag(KIMI_MODEL_FLAG); + const persistedComposerModel = (composerConfigMatchesAdapter ? composerConfig?.model : undefined) ?? task?.latest_run?.model ?? (composerAdapter === "codex" ? DEFAULT_CODEX_MODEL : DEFAULT_GATEWAY_MODEL); + // Fall a persisted Kimi selection back to the default when the flag is off so + // a hidden model never gets sent on the retry-after-terminal path. The + // composer independently re-resolves against the live config once mounted. + const composerModel = + !kimiEnabled && isModalModelId(persistedComposerModel) + ? DEFAULT_GATEWAY_MODEL + : persistedComposerModel; const requestedComposerReasoning = composerConfigMatchesAdapter ? composerConfig?.reasoning : undefined; diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index fe260587eb..04050437c7 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -11,6 +11,7 @@ import { type ExecutionMode, getReasoningEffortOptions, isSupportedReasoningEffort, + KIMI_MODEL_FLAG, type SupportedReasoningEffort, serializeCloudPrompt, } from "@posthog/shared"; @@ -24,7 +25,8 @@ import { PaperclipIcon, StopIcon, } from "phosphor-react-native"; -import { useCallback, useEffect, useState } from "react"; +import { useFeatureFlag } from "posthog-react-native"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, Pressable, @@ -53,6 +55,7 @@ import { import type { PendingAttachment } from "@/features/tasks/composer/attachments/types"; import { DotBackground } from "@/features/tasks/composer/DotBackground"; import { + filterKimiModelConfigOptions, getMobileExecutionModes, getModelConfigOption, } from "@/features/tasks/composer/options"; @@ -104,8 +107,16 @@ export default function NewTaskScreen() { const keyboard = useReanimatedKeyboardAnimation(); const restingBottom = bottom("compact"); const [adapter, setAdapter] = useState("claude"); - const { configOptions, hasLiveConfig, isConfigReady } = - useCloudTaskConfigOptions(adapter); + const { + configOptions: liveConfigOptions, + hasLiveConfig, + isConfigReady, + } = useCloudTaskConfigOptions(adapter); + const kimiEnabled = !!useFeatureFlag(KIMI_MODEL_FLAG); + const configOptions = useMemo( + () => filterKimiModelConfigOptions(liveConfigOptions, kimiEnabled), + [liveConfigOptions, kimiEnabled], + ); const modelConfigOption = getModelConfigOption(configOptions); const { error, diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index 611cd9f635..a74bc76335 100644 --- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -7,6 +7,7 @@ import { DEFAULT_GATEWAY_MODEL, DEFAULT_REASONING_EFFORT, type ExecutionMode, + KIMI_MODEL_FLAG, type SupportedReasoningEffort, } from "@posthog/shared"; import * as Haptics from "expo-haptics"; @@ -19,7 +20,8 @@ import { Stack, Stop, } from "phosphor-react-native"; -import { useCallback, useEffect, useState } from "react"; +import { useFeatureFlag } from "posthog-react-native"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, Keyboard, @@ -42,7 +44,11 @@ import { pickPhotoFromLibrary, } from "./attachments/pickers"; import type { PendingAttachment } from "./attachments/types"; -import { getModelConfigOption, resolveComposerPrimaryAction } from "./options"; +import { + filterKimiModelConfigOptions, + getModelConfigOption, + resolveComposerPrimaryAction, +} from "./options"; import { Pill } from "./Pill"; import { type ComposerContent, @@ -106,7 +112,13 @@ export function TaskChatComposer({ onCancelEdit, }: TaskChatComposerProps) { const themeColors = useThemeColors(); - const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions(adapter); + const { configOptions: liveConfigOptions, hasLiveConfig } = + useCloudTaskConfigOptions(adapter); + const kimiEnabled = !!useFeatureFlag(KIMI_MODEL_FLAG); + const configOptions = useMemo( + () => filterKimiModelConfigOptions(liveConfigOptions, kimiEnabled), + [liveConfigOptions, kimiEnabled], + ); const modelConfigOption = getModelConfigOption(configOptions); const [message, setMessage] = useState(() => initialMessage ?? ""); const [attachments, setAttachments] = useState([]); diff --git a/apps/mobile/src/features/tasks/composer/options.test.ts b/apps/mobile/src/features/tasks/composer/options.test.ts index 567276219c..47b77eb79f 100644 --- a/apps/mobile/src/features/tasks/composer/options.test.ts +++ b/apps/mobile/src/features/tasks/composer/options.test.ts @@ -5,11 +5,28 @@ import { } from "@posthog/shared"; import { describe, expect, it } from "vitest"; import { + filterKimiModelConfigOptions, + filterKimiModelOption, getComposerModelOptions, getMobileExecutionModes, resolveComposerPrimaryAction, } from "./options"; +const KIMI = "moonshotai/kimi-k3"; + +const kimiModelOption: CloudTaskConfigOption = { + id: "model", + name: "Model", + type: "select", + currentValue: DEFAULT_GATEWAY_MODEL, + options: [ + { value: DEFAULT_GATEWAY_MODEL, name: "Claude Opus 4.8" }, + { value: KIMI, name: "Kimi K3" }, + ], + category: "model", + description: "Choose a model", +}; + const modelOption: CloudTaskConfigOption = { id: "model", name: "Model", @@ -63,6 +80,64 @@ describe("mobile composer options", () => { ]); }); + describe("filterKimiModelOption", () => { + it("keeps Kimi K3 when the flag is on", () => { + const filtered = filterKimiModelOption(kimiModelOption, true); + expect(filtered.options.map((o) => o.value)).toContain(KIMI); + }); + + it("drops Kimi K3 when the flag is off", () => { + const filtered = filterKimiModelOption(kimiModelOption, false); + expect(filtered.options.map((o) => o.value)).not.toContain(KIMI); + expect(filtered.options.map((o) => o.value)).toEqual([ + DEFAULT_GATEWAY_MODEL, + ]); + }); + + it("rewrites a persisted Kimi selection to a visible model when the flag is off", () => { + const filtered = filterKimiModelOption( + { ...kimiModelOption, currentValue: KIMI }, + false, + ); + expect(filtered.currentValue).toBe(DEFAULT_GATEWAY_MODEL); + }); + + it("leaves an already-visible selection untouched when the flag is off", () => { + const filtered = filterKimiModelOption(kimiModelOption, false); + expect(filtered.currentValue).toBe(DEFAULT_GATEWAY_MODEL); + }); + }); + + describe("filterKimiModelConfigOptions", () => { + const modeOption: CloudTaskConfigOption = { + id: "mode", + name: "Mode", + type: "select", + currentValue: "plan", + options: [{ value: "plan", name: "Plan" }], + category: "mode", + description: "Execution mode", + }; + + it("returns the config set untouched when the flag is on", () => { + const input = [modeOption, kimiModelOption]; + expect(filterKimiModelConfigOptions(input, true)).toBe(input); + }); + + it("strips Kimi from only the model option when the flag is off", () => { + const filtered = filterKimiModelConfigOptions( + [modeOption, kimiModelOption], + false, + ); + const model = filtered.find((option) => option.category === "model"); + const mode = filtered.find((option) => option.category === "mode"); + expect(model?.options.map((o) => o.value)).toEqual([ + DEFAULT_GATEWAY_MODEL, + ]); + expect(mode).toBe(modeOption); + }); + }); + it.each([ [{ hasContent: true }, "send"], [{ canStop: true }, "stop"], diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts index 85777b78d7..eac5bac776 100644 --- a/apps/mobile/src/features/tasks/composer/options.ts +++ b/apps/mobile/src/features/tasks/composer/options.ts @@ -1,6 +1,7 @@ import type { ModeInfo } from "@posthog/core/sessions/executionModes"; import { type CloudTaskConfigOption, + isModalModelId, isRestrictedModelOption, } from "@posthog/shared"; @@ -27,6 +28,45 @@ export function getModelConfigOption( return option; } +/** + * Drops the Kimi K3 model from the live model config when the feature flag is + * off, and rewrites a persisted or server-default Kimi selection to the first + * remaining option so it never leaks into the picker. Mirrors the desktop + * `stripKimiModelOption` filter, but over the mobile `CloudTaskConfigOption`. + */ +export function filterKimiModelOption( + modelOption: CloudTaskConfigOption, + kimiEnabled: boolean, +): CloudTaskConfigOption { + if (kimiEnabled) return modelOption; + const options = modelOption.options.filter( + (option) => !isModalModelId(option.value), + ); + return { + ...modelOption, + options, + currentValue: isModalModelId(modelOption.currentValue) + ? (options[0]?.value ?? modelOption.currentValue) + : modelOption.currentValue, + }; +} + +/** + * Applies {@link filterKimiModelOption} to the model option within a live + * config set, leaving the other categories untouched. Callers filter once at + * the source so the model auto-resolve effect and the picker share Kimi-free + * options. + */ +export function filterKimiModelConfigOptions( + configOptions: readonly CloudTaskConfigOption[], + kimiEnabled: boolean, +): readonly CloudTaskConfigOption[] { + if (kimiEnabled) return configOptions; + return configOptions.map((option) => + option.category === "model" ? filterKimiModelOption(option, false) : option, + ); +} + export function getComposerModelOptions( modelOption: CloudTaskConfigOption, ): MobileModelOption[] {