Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
484a6c7
refactor(shared): extract task contracts and model policy
richardsolomou Jul 24, 2026
ec5fb80
fix(models): preserve GLM effort policy
richardsolomou Jul 24, 2026
fa2dda4
test(ui): wait for async content
richardsolomou Jul 24, 2026
0960bc3
test(ui): isolate plan approval presentation
richardsolomou Jul 24, 2026
3f608f2
fix(shared): Preserve canonical task artifacts
richardsolomou Jul 28, 2026
9c4235c
test(agent): match canonical model picker order
richardsolomou Jul 28, 2026
b4e9084
Merge main into shared cloud task foundations
richardsolomou Jul 29, 2026
b69d17d
refactor(api-client): extract cloud task transport
richardsolomou Jul 24, 2026
01aec6d
refactor(core): extract cloud task policies
richardsolomou Jul 24, 2026
53e5383
refactor(core): rename cloud task service as engine
richardsolomou Jul 24, 2026
2245a2e
refactor(core): extract portable cloud task engine
richardsolomou Jul 24, 2026
36f9b39
refactor(core): extract repository integration semantics
richardsolomou Jul 24, 2026
3003336
refactor(core): extract pending prompt recovery
richardsolomou Jul 24, 2026
b5204c8
refactor(core): extract plan approval presentation
richardsolomou Jul 24, 2026
7ccd259
refactor(core): extract permission option presentation
richardsolomou Jul 24, 2026
046422e
refactor(core): extract composer controls
richardsolomou Jul 24, 2026
37f1c0b
refactor(core): extract composer model policy
richardsolomou Jul 24, 2026
0a1c884
fix(core): prefer streamed plan content
richardsolomou Jul 24, 2026
df8b20a
Merge branch 'main' into posthog-code/share-mobile-inbox-transport
richardsolomou Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/core/src/sessions/permissionResponse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,62 @@ import type { PermissionRequest } from "@posthog/shared";
import { describe, expect, it } from "vitest";
import {
formatPermissionAnswerPrompt,
getPermissionOptionMeta,
isOtherPermissionOption,
isPermissionApproval,
isPermissionRejection,
permissionOptionUsesCustomInput,
planPermissionResponse,
resolveInitialPlanApprovalOption,
selectPlanPermissionOptions,
} from "./permissionResponse";

describe("permission option presentation", () => {
const approveOnce = {
optionId: "default",
name: "Approve",
kind: "allow_once" as const,
};
const approveAuto = {
optionId: "auto",
name: "Approve automatically",
kind: "allow_always" as const,
};
const reject = {
optionId: "reject_with_feedback",
name: "Reject",
kind: "reject_once" as const,
_meta: { customInput: true, description: "Explain why" },
};

it("classifies approval, rejection, and custom-input options", () => {
expect(isPermissionApproval(approveOnce)).toBe(true);
expect(isPermissionRejection(reject)).toBe(true);
expect(permissionOptionUsesCustomInput(reject)).toBe(true);
expect(getPermissionOptionMeta(reject)).toEqual({
customInput: true,
description: "Explain why",
});
});

it("selects plan options and prefers a feedback rejection", () => {
expect(selectPlanPermissionOptions([approveOnce, reject])).toEqual({
approvals: [approveOnce],
rejection: reject,
});
});

it.each([
["default", "default"],
[null, "auto"],
["missing", "auto"],
])("resolves preferred approval %s", (preferred, expected) => {
expect(
resolveInitialPlanApprovalOption([approveOnce, approveAuto], preferred),
).toBe(expected);
});
});

function makePermission(
options: Array<{
optionId: string;
Expand Down
67 changes: 67 additions & 0 deletions packages/core/src/sessions/permissionResponse.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,72 @@
import type { PermissionRequest } from "@posthog/shared";

export type PermissionOption = PermissionRequest["options"][number];

export function getPermissionOptionMeta(option: PermissionOption): {
customInput: boolean;
description?: string;
} {
const meta = option._meta as
| { customInput?: boolean; description?: string }
| null
| undefined;
return {
customInput: meta?.customInput === true,
...(meta?.description ? { description: meta.description } : {}),
};
}

export function isPermissionApproval(option: PermissionOption): boolean {
return option.kind === "allow_once" || option.kind === "allow_always";
}

export function isPermissionRejection(option: PermissionOption): boolean {
return (
option.kind === "reject_once" ||
option.kind === "reject_always" ||
option.optionId.includes("reject")
);
}

export function permissionOptionUsesCustomInput(
option: PermissionOption,
): boolean {
return (
isOtherPermissionOption(option.optionId) ||
getPermissionOptionMeta(option).customInput
);
}

export function selectPlanPermissionOptions(options: PermissionOption[]): {
approvals: PermissionOption[];
rejection: PermissionOption | null;
} {
const approvals = options.filter(isPermissionApproval);
const rejections = options.filter(isPermissionRejection);
return {
approvals,
rejection:
rejections.find(permissionOptionUsesCustomInput) ?? rejections[0] ?? null,
};
}

export function resolveInitialPlanApprovalOption(
approvals: PermissionOption[],
preferredOptionId?: string | null,
): string | undefined {
const has = (optionId: string): boolean =>
approvals.some((option) => option.optionId === optionId);
return (
(preferredOptionId && has(preferredOptionId)
? preferredOptionId
: undefined) ??
(has("auto") ? "auto" : undefined) ??
approvals.find((option) => option.optionId === "default")?.optionId ??
approvals.find((option) => option.kind === "allow_once")?.optionId ??
approvals[0]?.optionId
);
}

const OTHER_OPTION_ID = "_other";
const OTHER_OPTION_ID_ALT = "other";

Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/sessions/planApprovalPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { extractPlanText } from "./planApprovalPresentation";

describe("extractPlanText", () => {
it.each([
[{ rawInput: { plan: "Raw plan" } }, "Raw plan"],
[{ content: [{ text: "Direct content" }] }, "Direct content"],
[
{
content: [
{ type: "content", content: { type: "text", text: "Nested" } },
],
},
"Nested",
],
[{ rawInput: {}, content: [] }, null],
])("extracts plan presentation from %o", (toolCall, expected) => {
expect(extractPlanText(toolCall)).toBe(expected);
});

it("prefers streamed content over stale raw input", () => {
expect(
extractPlanText({
rawInput: { plan: "Canonical" },
content: [{ text: "Rendered" }],
}),
).toBe("Rendered");
});
});
23 changes: 23 additions & 0 deletions packages/core/src/sessions/planApprovalPresentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
function extractTextContent(item: unknown): string | null {
if (!item || typeof item !== "object") return null;
const record = item as Record<string, unknown>;
if (typeof record.text === "string") return record.text;

if (!record.content || typeof record.content !== "object") return null;
const content = record.content as Record<string, unknown>;
return typeof content.text === "string" ? content.text : null;
}

export function extractPlanText(toolCall: {
rawInput?: { plan?: unknown } | null;
content?: readonly unknown[] | null;
}): string | null {
for (const item of toolCall.content ?? []) {
const text = extractTextContent(item);
if (text?.trim()) return text;
}

const rawPlan = toolCall.rawInput?.plan;
if (typeof rawPlan === "string" && rawPlan.trim()) return rawPlan;
return null;
}
27 changes: 27 additions & 0 deletions packages/core/src/task-detail/composerControls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { resolveComposerPrimaryAction } from "./composerControls";

describe("resolveComposerPrimaryAction", () => {
it.each([
[{ hasContent: true }, "send"],
[{ canStop: true }, "stop"],
[{ canStop: true, hasContent: true }, "send"],
[{ canStop: true, hasContent: true, allowSendWhileRunning: false }, "stop"],
[{ isRecording: true }, "mic-stop"],
[{}, "mic"],
[{ disabled: true, hasContent: true }, "disabled"],
[{ isTranscribing: true }, "disabled"],
])("derives %s", (overrides, expected) => {
expect(
resolveComposerPrimaryAction({
hasContent: false,
disabled: false,
isRecording: false,
isTranscribing: false,
canStop: false,
allowSendWhileRunning: true,
...overrides,
}),
).toBe(expected);
});
});
99 changes: 99 additions & 0 deletions packages/core/src/task-detail/composerControls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
type Adapter,
type CloudTaskConfigOption,
DEFAULT_REASONING_EFFORT,
isRestrictedModelOption,
isSupportedReasoningEffort,
type SupportedReasoningEffort,
} from "@posthog/shared";

export interface ComposerModelOption {
value: string;
label: string;
description?: string;
disabled: boolean;
}

export function getModelConfigOption(
configOptions: readonly CloudTaskConfigOption[],
): CloudTaskConfigOption {
const option = configOptions.find((item) => item.category === "model");
if (!option) throw new Error("Cloud task model configuration is unavailable");
return option;
}

export function getComposerModelOptions(
modelOption: CloudTaskConfigOption,
): ComposerModelOption[] {
return modelOption.options.map((option) => ({
value: option.value,
label: option.name,
description: option.description,
disabled: isRestrictedModelOption(option._meta),
}));
}

export function getConfigOptionLabel(
options: ReadonlyArray<{ value: string; name: string }>,
value: string | undefined,
): string | undefined {
return options.find((option) => option.value === value)?.name ?? value;
}

export function resolveAvailableModel(
modelOption: CloudTaskConfigOption,
value: string,
): string {
const selected = modelOption.options.find((option) => option.value === value);
return selected && !isRestrictedModelOption(selected._meta)
? value
: modelOption.currentValue;
}

export function resolveComposerModelChange({
adapter,
modelOption,
requestedModel,
reasoning,
}: {
adapter: Adapter;
modelOption: CloudTaskConfigOption;
requestedModel: string;
reasoning: SupportedReasoningEffort;
}): { model: string; reasoning: SupportedReasoningEffort } {
const model = resolveAvailableModel(modelOption, requestedModel);
return {
model,
reasoning: isSupportedReasoningEffort(adapter, model, reasoning)
? reasoning
: DEFAULT_REASONING_EFFORT,
};
}

export type ComposerPrimaryAction =
| "send"
| "stop"
| "mic"
| "mic-stop"
| "disabled";

export function resolveComposerPrimaryAction({
hasContent,
disabled,
isRecording,
isTranscribing,
canStop,
allowSendWhileRunning,
}: {
hasContent: boolean;
disabled: boolean;
isRecording: boolean;
isTranscribing: boolean;
canStop: boolean;
allowSendWhileRunning: boolean;
}): ComposerPrimaryAction {
if (disabled || isTranscribing) return "disabled";
if (canStop && (!allowSendWhileRunning || !hasContent)) return "stop";
if (hasContent && !isRecording) return "send";
return isRecording ? "mic-stop" : "mic";
}
42 changes: 42 additions & 0 deletions packages/core/src/task-detail/composerModelPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
type Adapter,
type CloudTaskConfigOption,
DEFAULT_GATEWAY_MODEL,
restrictedModelMeta,
type SupportedReasoningEffort,
} from "@posthog/shared";
import { expect, it } from "vitest";
import { resolveCloudComposerModelChange } from "./composerModelPolicy";

const modelOption: CloudTaskConfigOption = {
id: "model",
name: "Model",
type: "select",
currentValue: DEFAULT_GATEWAY_MODEL,
options: [
{ value: DEFAULT_GATEWAY_MODEL, name: "Claude" },
{ value: "restricted", name: "Restricted", _meta: restrictedModelMeta() },
{ value: "gpt-5.3-codex", name: "Codex" },
],
category: "model",
description: "Choose a model",
};

it.each([
["claude", DEFAULT_GATEWAY_MODEL, "high", DEFAULT_GATEWAY_MODEL, "high"],
["claude", "restricted", "high", DEFAULT_GATEWAY_MODEL, "high"],
["claude", "missing", "high", DEFAULT_GATEWAY_MODEL, "high"],
["codex", "gpt-5.3-codex", "xhigh", "gpt-5.3-codex", "high"],
] as const)(
"resolves %s model %s with %s reasoning",
(adapter, requestedModel, reasoning, expectedModel, expectedReasoning) => {
expect(
resolveCloudComposerModelChange({
adapter: adapter as Adapter,
modelOption,
requestedModel,
reasoning: reasoning as SupportedReasoningEffort,
}),
).toEqual({ model: expectedModel, reasoning: expectedReasoning });
},
);
35 changes: 35 additions & 0 deletions packages/core/src/task-detail/composerModelPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
type Adapter,
type CloudTaskConfigOption,
DEFAULT_REASONING_EFFORT,
isRestrictedModelOption,
isSupportedReasoningEffort,
type SupportedReasoningEffort,
} from "@posthog/shared";

export function resolveCloudComposerModelChange({
adapter,
modelOption,
requestedModel,
reasoning,
}: {
adapter: Adapter;
modelOption: CloudTaskConfigOption;
requestedModel: string;
reasoning: SupportedReasoningEffort;
}): { model: string; reasoning: SupportedReasoningEffort } {
const selected = modelOption.options.find(
(option) => option.value === requestedModel,
);
const model =
selected && !isRestrictedModelOption(selected._meta)
? requestedModel
: modelOption.currentValue;

return {
model,
reasoning: isSupportedReasoningEffort(adapter, model, reasoning)
? reasoning
: DEFAULT_REASONING_EFFORT,
};
}
Loading
Loading