Skip to content

Commit e7ac59b

Browse files
Merge branch 'release/v1.8.0' into ponytail/replace-i18n-with-i18next
2 parents 0a8bf8e + 4230b02 commit e7ac59b

61 files changed

Lines changed: 1334 additions & 1473 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/compositor/src/compositor.rs

Lines changed: 350 additions & 26 deletions
Large diffs are not rendered by default.

electron-builder.json5

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,9 @@
126126
// vendors two artifacts into this directory: the shared av*.dll set,
127127
// which the D3D11 compositor addon dlopens at require() time and
128128
// therefore MUST ship, and a standalone static ffmpeg.exe that nothing
129-
// in the app spawns it exists for scripts/bench-export.mjs. Shipping
130-
// it added a large binary to every Windows installer, plus an LGPL
131-
// redistribution obligation, for a file no shipped code opens.
129+
// in the app spawns. Shipping it added a large binary to every Windows
130+
// installer, plus an LGPL redistribution obligation, for a file no
131+
// shipped code opens.
132132
"filter": ["win32-*/*", "!win32-*/ffmpeg.exe"]
133133
}
134134
],

electron/ai-edition/chat-service.toolloop.test.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,14 +244,17 @@ describe("runChat tool loop", () => {
244244
invokeMock.mockImplementationOnce(async (args) => {
245245
events.push({ kind: "captured", payload: args.userMessage });
246246
args.sink.text("Hi ");
247+
args.sink.thinking("pondering. ");
247248
args.sink.text("there.");
249+
args.sink.thinking("concluding.");
248250
args.sink.toolStart("addTrim", { startSec: 1, endSec: 2 });
249251
args.sink.toolEnd("addTrim", true, "added trim 0:01.0 – 0:02.0");
250252
return { text: "Done.", document: args.document, mutated: true };
251253
});
252254

253255
const sink = {
254256
text: (delta: string) => fixture.events.push({ kind: "text", payload: delta }),
257+
thinking: (delta: string) => fixture.events.push({ kind: "thinking", payload: delta }),
255258
toolStart: (name: string, args: unknown) =>
256259
fixture.events.push({ kind: "toolStart", payload: { name, args } }),
257260
toolEnd: (name: string, ok: boolean, summary?: string) =>
@@ -262,12 +265,22 @@ describe("runChat tool loop", () => {
262265
const s = createSession("proj_sink");
263266
const result = await runChat("proj_sink", s.id, "cut", stubConfig(), fixtureDocument(), sink);
264267
expect(result.success).toBe(true);
265-
expect(fixture.events.map((e) => e.kind)).toEqual(["text", "text", "toolStart", "toolEnd"]);
266-
expect((fixture.events[0].payload as string) + (fixture.events[1].payload as string)).toBe(
268+
expect(fixture.events.map((e) => e.kind)).toEqual([
269+
"text",
270+
"thinking",
271+
"text",
272+
"thinking",
273+
"toolStart",
274+
"toolEnd",
275+
]);
276+
expect((fixture.events[0].payload as string) + (fixture.events[2].payload as string)).toBe(
267277
"Hi there.",
268278
);
269-
expect(fixture.events[2].payload).toMatchObject({ name: "addTrim" });
270-
expect(fixture.events[3].payload).toMatchObject({
279+
expect((fixture.events[1].payload as string) + (fixture.events[3].payload as string)).toBe(
280+
"pondering. concluding.",
281+
);
282+
expect(fixture.events[4].payload).toMatchObject({ name: "addTrim" });
283+
expect(fixture.events[5].payload).toMatchObject({
271284
name: "addTrim",
272285
ok: true,
273286
summary: expect.stringMatching(/added trim/),
@@ -284,6 +297,7 @@ describe("runChat tool loop", () => {
284297
});
285298
const sinkErr = {
286299
text: (delta: string) => fixture.events.push({ kind: "text", payload: delta }),
300+
thinking: (delta: string) => fixture.events.push({ kind: "thinking", payload: delta }),
287301
toolStart: (name: string, args: unknown) =>
288302
fixture.events.push({ kind: "toolStart", payload: { name, args } }),
289303
toolEnd: (name: string, ok: boolean, summary?: string) =>

electron/ai-edition/chat-service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ export function deleteSession(projectId: string, sessionId: string): boolean {
204204
export interface ChatEventSink {
205205
/** Streamed text delta from the model. */
206206
text?: (delta: string) => void;
207+
/** Streamed delta from the model's reasoning block (Anthropic/MiniMax
208+
* thinking). Provider-agnostic — never called for providers that don't
209+
* expose thinking. The chat panel streams these into a live "Thinking…"
210+
* block so the reasoning phase doesn't feel like dead air. */
211+
thinking?: (delta: string) => void;
207212
/** A tool call is about to execute. */
208213
toolStart?: (name: string, args: unknown) => void;
209214
/** A tool call has finished. `ok=false` carries the model's error message. */
@@ -218,6 +223,7 @@ const noop = () => undefined;
218223
/** ponytail: zero-config sink that swallows every event. */
219224
const NOOP_SINK: Required<ChatEventSink> = {
220225
text: noop,
226+
thinking: noop,
221227
toolStart: noop,
222228
toolEnd: noop,
223229
error: noop,
@@ -323,6 +329,7 @@ export async function runChat(
323329

324330
const agentSink = {
325331
text: (delta: string) => emit.text(delta),
332+
thinking: (delta: string) => emit.thinking(delta),
326333
toolStart: (name: string, args: unknown) => {
327334
emit.toolStart(name, args);
328335
void editsAllowed;

electron/ai-edition/deep-agent/chat-model.test.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
// providers in 1.8.0 — see provider-registry.ts.
66

77
import { describe, expect, it } from "vitest";
8-
import { createOpenScreenChatModel, messageContentToText } from "./chat-model";
8+
import {
9+
ANTHROPIC_API_MAX_OUTPUT_TOKENS,
10+
createOpenScreenChatModel,
11+
messageContentToText,
12+
messageContentToThinking,
13+
} from "./chat-model";
914

1015
/** ChatOpenAI keeps the `configuration` bag it was constructed with on
1116
* `clientConfig`; that is where the base URL and default headers land. */
@@ -35,6 +40,57 @@ describe("createOpenScreenChatModel — provider aliases", () => {
3540
});
3641
});
3742

43+
describe("createOpenScreenChatModel — Anthropic-wire output budget", () => {
44+
// Regression for #181: ChatAnthropic's default maxTokens table only knows
45+
// Claude slugs (16k); anything else — MiniMax-M3 included — falls back to
46+
// 4096. With adaptive thinking on, a cold-start turn can spend that whole
47+
// budget on reasoning and truncate before any text block, surfacing as
48+
// "Empty response from model" on the first call only.
49+
function maxTokens(model: unknown): number | undefined {
50+
return (model as { maxTokens?: number }).maxTokens;
51+
}
52+
53+
for (const provider of ["minimax", "minimax-token-plan"]) {
54+
it(`sets an explicit maxTokens on the ${provider} ChatAnthropic`, async () => {
55+
const model = await createOpenScreenChatModel({
56+
provider,
57+
model: "MiniMax-M3",
58+
apiKey: "test-key",
59+
});
60+
expect(model.constructor.name).toBe("ChatAnthropic");
61+
expect(maxTokens(model)).toBe(ANTHROPIC_API_MAX_OUTPUT_TOKENS);
62+
});
63+
}
64+
65+
it("floors maxTokens for non-Claude models on the anthropic provider", async () => {
66+
const model = await createOpenScreenChatModel({
67+
provider: "anthropic",
68+
model: "some-self-hosted-model",
69+
apiKey: "sk-ant-test",
70+
baseUrl: "https://anthropic.example.internal",
71+
});
72+
expect(maxTokens(model)).toBe(ANTHROPIC_API_MAX_OUTPUT_TOKENS);
73+
});
74+
75+
it("keeps LangChain's per-model default for known Claude slugs", async () => {
76+
// claude-3-haiku's hard output limit is 4096 — overriding it with 16k
77+
// would make the API reject every request for this model.
78+
const legacy = await createOpenScreenChatModel({
79+
provider: "anthropic",
80+
model: "claude-3-haiku-20240307",
81+
apiKey: "sk-ant-test",
82+
});
83+
expect(maxTokens(legacy)).toBe(4096);
84+
85+
const current = await createOpenScreenChatModel({
86+
provider: "anthropic",
87+
model: "claude-haiku-4-5",
88+
apiKey: "sk-ant-test",
89+
});
90+
expect(maxTokens(current)).toBe(ANTHROPIC_API_MAX_OUTPUT_TOKENS);
91+
});
92+
});
93+
3894
describe("messageContentToText", () => {
3995
it("passes a plain string through", () => {
4096
expect(messageContentToText("hello")).toBe("hello");
@@ -49,3 +105,43 @@ describe("messageContentToText", () => {
49105
expect(messageContentToText(42)).toBe("");
50106
});
51107
});
108+
109+
describe("messageContentToThinking", () => {
110+
// Anthropic/MiniMax thinking blocks land in AIMessageChunk content arrays
111+
// as `{type: "thinking", thinking: "..."}` parts (see @langchain/anthropic
112+
// message_outputs.js — `thinking_delta` SSE events). The extractor has to
113+
// pull them out so the chat panel can stream them separately; text parts
114+
// stay on the messageContentToText path.
115+
it("concatenates thinking parts in array order", () => {
116+
expect(
117+
messageContentToThinking([
118+
{ type: "thinking", thinking: "step one. " },
119+
{ type: "text", text: "should be ignored" },
120+
{ type: "thinking", thinking: "step two." },
121+
]),
122+
).toBe("step one. step two.");
123+
});
124+
125+
it("ignores redacted_thinking blocks (encrypted reasoning the provider hides)", () => {
126+
// ChatAnthropic surfaces encrypted reasoning as parts of type
127+
// "redacted_thinking" — we don't have a string to display, so skip.
128+
expect(
129+
messageContentToThinking([
130+
{ type: "thinking", thinking: "visible. " },
131+
{ type: "redacted_thinking" },
132+
{ type: "thinking", thinking: "more visible." },
133+
]),
134+
).toBe("visible. more visible.");
135+
});
136+
137+
it("returns an empty string for a plain string or non-array input", () => {
138+
expect(messageContentToThinking("not a list")).toBe("");
139+
expect(messageContentToThinking(null)).toBe("");
140+
expect(messageContentToThinking(42)).toBe("");
141+
});
142+
143+
it("returns an empty string when there are no thinking parts", () => {
144+
expect(messageContentToThinking([{ type: "text", text: "answer" }])).toBe("");
145+
expect(messageContentToThinking([])).toBe("");
146+
});
147+
});

electron/ai-edition/deep-agent/chat-model.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,33 @@ export interface OpenScreenChatModelConfig {
2828
// that don't actually authenticate (same as axcut's OPENAI_COMPATIBLE_NO_AUTH).
2929
export const OPENAI_COMPATIBLE_NO_AUTH_API_KEY = "openscreen-openai-compatible-no-auth";
3030

31+
// ponytail: explicit output budget for the Anthropic-wire providers
32+
// (`anthropic`, `minimax`, `minimax-token-plan`). ChatAnthropic picks its
33+
// default `maxTokens` from a table of known Claude models (16k for 4.x/5.x)
34+
// and falls back to 4096 for anything else — including every MiniMax slug and
35+
// any self-hosted model name. With thinking on, a cold-start turn can spend
36+
// the entire 4096-token budget on reasoning and truncate with
37+
// `stop_reason: "max_tokens"` before emitting a single text block — the
38+
// "first call returns an empty response" bug (#181). 16384 matches what the
39+
// known Claude models get.
40+
//
41+
// This only applies to the Anthropic Messages API path, where `max_tokens`
42+
// is mandatory: the OpenAI-shaped transports (ChatOpenAI, ChatMistralAI)
43+
// send no cap by default, so there is nothing to fix — and imposing one
44+
// would truncate outputs that are uncapped today.
45+
export const ANTHROPIC_API_MAX_OUTPUT_TOKENS = 16_384;
46+
47+
// ponytail: LangChain's default-maxTokens table knows every released
48+
// claude-* slug with its real per-model limit (4096 for claude-3-haiku,
49+
// 16384 for 4.x/5.x) — trust it. Overriding with a flat 16k would exceed a
50+
// legacy model's hard limit and turn the request into a 400. Anything NOT
51+
// claude-shaped on the anthropic branch is a self-hosted Anthropic-compatible
52+
// endpoint behind `baseUrl`, which LangChain can't know — floor those at
53+
// ANTHROPIC_API_MAX_OUTPUT_TOKENS like the MiniMax path.
54+
function isKnownClaudeSlug(model: string): boolean {
55+
return model.trim().toLowerCase().startsWith("claude-");
56+
}
57+
3158
export function resolveOpenAIChatApiKey(provider: string, apiKey?: string): string | undefined {
3259
if (apiKey) return apiKey;
3360
return provider === "openai-compatible" ? OPENAI_COMPATIBLE_NO_AUTH_API_KEY : undefined;
@@ -54,6 +81,27 @@ export function messageContentToText(content: unknown): string {
5481
return "";
5582
}
5683

84+
// ponytail: counterpart to messageContentToText for the Anthropic/MiniMax
85+
// thinking blocks. ChatAnthropic with `thinking: {type: "adaptive"}` (or
86+
// `enabled`) emits streamed `thinking_delta` SSE events that LangChain turns
87+
// into content parts `{type: "thinking", thinking: "..."}`. We strip that
88+
// thinking text out of the final AIMessage content (where it counts against
89+
// max_tokens on the visible text path, but isn't user-visible text) and pipe
90+
// it separately to the renderer so the chat panel can show a live "Thinking…"
91+
// block instead of dead air. `redacted_thinking` parts (encrypted reasoning
92+
// the provider chose not to show us) are skipped — there's nothing to display.
93+
export function messageContentToThinking(content: unknown): string {
94+
if (!Array.isArray(content)) return "";
95+
let total = "";
96+
for (const part of content) {
97+
if (!part || typeof part !== "object") continue;
98+
const p = part as { type?: unknown; thinking?: unknown };
99+
if (p.type !== "thinking") continue;
100+
if (typeof p.thinking === "string") total += p.thinking;
101+
}
102+
return total;
103+
}
104+
57105
export async function createOpenScreenChatModel(
58106
input: OpenScreenChatModelConfig,
59107
): Promise<BaseChatModel> {
@@ -85,6 +133,7 @@ export async function createOpenScreenChatModel(
85133
// ponytail: ChatAnthropic accepts `anthropicApiUrl` for self-hosted
86134
// Anthropic-compatible endpoints — MiniMax uses this on the wire path.
87135
...(config.baseUrl ? { anthropicApiUrl: config.baseUrl } : {}),
136+
...(isKnownClaudeSlug(config.model) ? {} : { maxTokens: ANTHROPIC_API_MAX_OUTPUT_TOKENS }),
88137
...(reasoningOptions.thinking ? { thinking: reasoningOptions.thinking as never } : {}),
89138
...(reasoningOptions.outputConfig
90139
? { outputConfig: reasoningOptions.outputConfig as never }
@@ -138,6 +187,7 @@ async function createLocalProviderChatModel(
138187
apiKey: config.apiKey,
139188
model: config.model,
140189
anthropicApiUrl: config.baseUrl ?? "https://api.minimax.io/anthropic",
190+
maxTokens: ANTHROPIC_API_MAX_OUTPUT_TOKENS,
141191
...(reasoningOptions.thinking ? { thinking: reasoningOptions.thinking as never } : {}),
142192
});
143193
default:

electron/ai-edition/deep-agent/service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,17 @@ import {
3131
import {
3232
createOpenScreenChatModel,
3333
messageContentToText,
34+
messageContentToThinking,
3435
type OpenScreenChatModelConfig,
3536
} from "./chat-model";
3637

3738
export interface OpenScreenAgentSink {
3839
text: (delta: string) => void;
40+
/** Streaming delta from the model's reasoning block (Anthropic/MiniMax
41+
* thinking). Provider-agnostic — for providers without thinking this is
42+
* never called. The chat panel uses it to surface the reasoning phase
43+
* that would otherwise be invisible "dead air" while the model thinks. */
44+
thinking: (delta: string) => void;
3945
toolStart: (name: string, args: unknown) => void;
4046
toolEnd: (name: string, ok: boolean, summary?: string) => void;
4147
error: (message: string) => void;
@@ -233,6 +239,10 @@ export async function invokeOpenScreenAgent(args: InvokeArgs): Promise<InvokeRes
233239
const chunk = data?.chunk as Record<string, unknown> | undefined;
234240
if (chunk) chatModelChunks.push(chunk);
235241
const content = chunk?.content;
242+
const thinkingDelta = messageContentToThinking(content);
243+
if (thinkingDelta) {
244+
sink.thinking(thinkingDelta);
245+
}
236246
const delta = messageContentToText(content);
237247
if (delta) {
238248
sink.text(delta);

electron/electron-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ interface Window {
317317
onMenuSaveProjectAs: (callback: () => void) => () => void;
318318
quitApp: () => void;
319319
setTitleBarOverlay: (color: string, symbolColor: string) => void;
320-
getPlatform: () => Promise<string>;
320+
getPlatform: () => string;
321321
revealInFolder: (
322322
filePath: string,
323323
) => Promise<{ success: boolean; error?: string; message?: string }>;

electron/ipc/handlers.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ import {
4747
rewindToMessage,
4848
runChat,
4949
runChatDefault,
50-
runTimelineOperation,
5150
selectSession,
5251
} from "../ai-edition/chat-service";
5352
import { DocumentService } from "../ai-edition/document-service";
@@ -3283,10 +3282,6 @@ export function registerIpcHandlers(
32833282
return { success: true };
32843283
}
32853284

3286-
ipcMain.handle("get-platform", () => {
3287-
return process.platform;
3288-
});
3289-
32903285
// Keep the native Windows/Linux window-control overlay in the app's theme
32913286
// colours. The renderer sends the resolved CSS values so the palette stays in
32923287
// one place. No-op on macOS (traffic lights aren't tintable) and on any window
@@ -3422,8 +3417,6 @@ export function registerIpcHandlers(
34223417
rewindToMessage(projectId, sessionId, messageId),
34233418
compactNow: (projectId, sessionId) =>
34243419
compactSessionNow(projectId, sessionId, aiEditionLlmConfig),
3425-
runTimelineOperation: (projectId, sessionId, op, conversationMessage) =>
3426-
runTimelineOperation(projectId, sessionId, op, conversationMessage, aiEditionDocuments),
34273420
getContextUsage: getSessionContextUsage,
34283421
runAiEditionChatDefault: (projectId, message, sink) =>
34293422
runChatDefault(projectId, message, aiEditionLlmConfig, sink),

0 commit comments

Comments
 (0)