You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/15 11:00:01 UTC
(maka) 02/02: fix(workhub): align composer controls and apply Code Mode per turn (#5345)
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 0d9ea7576a49c2f3173aa2cbe99ded53f82deaab
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 18:59:45 2026 +0800
fix(workhub): align composer controls and apply Code Mode per turn (#5345)
* fix(workhub): align composer controls and live Code Mode policy
Generated-by: OpenAI Codex
* fix(desktop): share session inspector across feature boundaries
Generated-by: OpenAI Codex
* fix(workhub): close review findings and refresh CI inventories
Remove the obsolete Code Mode missing-tool bridge, require an explicit thinking setting, and align the shared inspector inventory and exports.
Generated-by: OpenAI Codex
---
apps/desktop/e2e-budget.json | 2 +-
apps/desktop/e2e/workhub-layout.spec.ts | 24 +++-
apps/desktop/renderer-architecture.json | 1 +
.../src/main/__tests__/use-session-trace.test.ts | 40 ++----
.../main/__tests__/workhub-send-visibility.test.ts | 29 +++-
.../contracts/session-inspector/copy.ts | 157 +++++++++++++++++++++
.../session-inspector/latest-request-usage.ts | 68 +++++++++
.../session-inspector}/live-context-usage.ts | 0
.../contracts/session-inspector/service.ts | 49 +++++++
.../session-inspector-overview-model.ts | 0
.../session-inspector-panel-model.ts | 0
.../session-inspector}/session-inspector-panel.tsx | 10 +-
.../session-inspector}/session-trace-refresh.ts | 0
.../session-inspector}/use-live-context-usage.ts | 5 +-
.../session-inspector}/use-session-trace.ts | 25 ++--
apps/desktop/src/renderer/chat-composer-region.tsx | 51 +------
.../desktop/src/renderer/features/workbar/ports.ts | 33 +----
.../src/renderer/features/workbar/testing.ts | 16 +--
.../tools/inspector/live-context-usage-probe.tsx | 7 +-
.../features/workbar/ui/workbar-surface.tsx | 6 +-
.../workhub/controller/use-workhub-controller.ts | 18 ++-
.../features/workhub/locales/workhub-live-copy.ts | 3 +
.../desktop/src/renderer/features/workhub/ports.ts | 1 +
.../renderer/features/workhub/ui/workhub-root.tsx | 35 ++++-
.../src/renderer/locales/conversation-copy.ts | 142 +------------------
.../renderer/locales/settings-preferences-copy.ts | 6 +-
.../platform/desktop/create-workbar-services.ts | 20 +--
.../platform/desktop/create-workhub-services.ts | 3 +
apps/desktop/src/renderer/styles/workhub.css | 15 ++
apps/desktop/stories/session-workbar.stories.tsx | 6 +-
apps/desktop/stories/workhub.stories.tsx | 36 ++++-
docs/astryx-surface-file-inventory.md | 2 +-
docs/astryx-surface-file-inventory.paths | 2 +-
.../__tests__/execution-model-composition.test.ts | 43 ++++++
.../hosted-execution-tool-profile.test.ts | 119 +++++++++++++++-
.../__tests__/session-catalog-coordinator.test.ts | 38 +++++
.../workhub-coordination-protocol.test.ts | 12 +-
packages/runtime-host/src/protocol/index.ts | 2 +-
.../src/protocol/workhub-coordination.ts | 14 +-
.../src/server/execution-composition.ts | 9 ++
.../src/server/session-catalog-coordinator.ts | 5 +-
.../src/__tests__/code-mode-backend.test.ts | 4 +-
packages/runtime/src/__tests__/code-mode.test.ts | 4 +-
.../runtime/src/__tests__/session-manager.test.ts | 57 ++++++++
packages/runtime/src/runtime-kernel.ts | 3 +
packages/runtime/src/session-manager.ts | 4 +-
packages/ui/src/chat-model-switcher.tsx | 2 +-
.../@maka+runtime++@ai-sdk+code-mode+1.0.56.patch | 117 ++++++++++++++-
48 files changed, 926 insertions(+), 319 deletions(-)
diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json
index a1249147b0..b3b4c5e562 100644
--- a/apps/desktop/e2e-budget.json
+++ b/apps/desktop/e2e-budget.json
@@ -75,7 +75,7 @@
},
"workhub-layout.spec.ts": {
"tests": 3,
- "electron": "WorkHub model configuration and attachment sending cross the Host boundary; the Project New task action must dismiss the native dock owned by a separate WebContentsView; the compact native window grows only with the inline composer wheel and keeps its bottom anchor, and pointer dragging scrolls the no-drag wheel instead of moving the frameless window; native floating preserves macOS Dock visibility; a held-open Host Turn verifies immediate prompt visibility, durable tr [...]
+ "electron": "WorkHub model configuration and attachment sending cross the Host boundary; the Project New task action must dismiss the native dock owned by a separate WebContentsView; the compact native window grows only with the inline composer wheel and keeps its bottom anchor; opening context details expands the native window, while the thinking menu stays within the compact bounds, and pointer dragging scrolls the no-drag wheel instead of moving the frameless window; native floa [...]
},
"workhub-reconstruction.spec.ts": {
"tests": 1,
diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts
index 85bba7036d..a0541662d5 100644
--- a/apps/desktop/e2e/workhub-layout.spec.ts
+++ b/apps/desktop/e2e/workhub-layout.spec.ts
@@ -28,7 +28,7 @@ test('WorkHub uses its coordination model and shared attachment composer', async
const { connections } = await window.maka.connections.getSnapshot();
const connection = connections.find((entry) => entry.slug === 'e2e')!;
const ids = ['claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001', 'claude-opus-4-5-20251101'];
- await window.maka.connections.update({ connectionId: connection.connectionId, slug: connection.slug }, { enabledModelIds: ids, models: ids.map((id) => ({ id })) });
+ await window.maka.connections.update({ connectionId: connection.connectionId, slug: connection.slug }, { enabledModelIds: ids, models: ids.map((id) => ({ id })), defaultModel: ids[2] });
});
await page.locator(COMPOSER_INPUT).fill('WorkHub navigation regression');
await awaitSendReady(page);
@@ -139,6 +139,7 @@ test('WorkHub uses its coordination model and shared attachment composer', async
const session = await window.maka.workHub.getSession(id);
return window.maka.workHub.configureModel(id, {
expectedRevision: session.revision,
+ thinkingLevel: session.thinkingLevel ?? null,
modelTarget: { kind: 'explicit', connectionId: session.llmConnectionId!, connectionSlug: session.llmConnectionSlug, model: session.model },
});
}, sessionId);
@@ -193,6 +194,27 @@ test('WorkHub uses its coordination model and shared attachment composer', async
await expect(workhub.getByRole('button', { name: /^(隐藏|Hide)$/ })).toHaveCount(0);
await expect.poll(() => workhub.evaluate(() => innerHeight === Math.ceil(document.querySelector('.workHubComposerSurface')!.getBoundingClientRect().height))).toBe(true);
await expect.poll(floatingBottom).toBe(anchoredBottom);
+ // Opening context details must expand the native compact window as well as the renderer.
+ await workhub.getByRole('button', { name: /打开用量追踪|Open usage trace/ }).click();
+ await expect(workhub.getByRole('button', { name: /返回对话|Back to conversation/ })).toBeVisible();
+ await expect.poll(() => workhub.evaluate(() => innerHeight)).toBe(expandedHeight);
+ await workhub.getByRole('button', { name: /返回对话|Back to conversation/ }).click();
+ await workhub.getByRole('button', { name: /收起对话|Collapse conversation/ }).click();
+ await expect.poll(() => workhub.evaluate(() => innerHeight === Math.ceil(document.querySelector('.workHubComposerSurface')!.getBoundingClientRect().height))).toBe(true);
+ const thinking = workhub.getByRole('button', { name: /思考级别|Thinking level/ });
+ await expect(thinking).toBeEnabled();
+ await thinking.click();
+ const thinkingMenu = workhub.getByRole('menu');
+ await expect(thinkingMenu).toBeVisible();
+ await workhub.screenshot({ animations: 'disabled', path: testInfo.outputPath('floating-thinking-levels.png') });
+ await expect.poll(() => thinkingMenu.evaluate((element) => {
+ const rect = element.getBoundingClientRect();
+ return rect.top >= 0 && rect.bottom <= innerHeight;
+ })).toBe(true);
+ await workhub.getByRole('menuitemradio', { name: /^(高|High)$/ }).click();
+ await expect(thinking).toContainText(/高|High/);
+ await expect.poll(() => workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).thinkingLevel, sessionId)).toBe('high');
+ await workhub.screenshot({ animations: 'disabled', path: testInfo.outputPath('floating-composer-controls.png') });
const compactHeight = await workhub.evaluate(() => innerHeight);
const screenLayout = async () => {
const origin = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().find((window) => window.getTitle() === 'WorkHub')!.getContentBounds());
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 496cde3d7a..b5538c2ba6 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -1085,6 +1085,7 @@
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
+ "./application/contracts/session-inspector/latest-request-usage.js": 1,
"./composer-mentions.js": 1,
"./new-task-reload-intent.js": 1,
"@maka/ui": 1,
diff --git a/apps/desktop/src/main/__tests__/use-session-trace.test.ts b/apps/desktop/src/main/__tests__/use-session-trace.test.ts
index 75c1437194..ff7b1728f3 100644
--- a/apps/desktop/src/main/__tests__/use-session-trace.test.ts
+++ b/apps/desktop/src/main/__tests__/use-session-trace.test.ts
@@ -31,9 +31,8 @@ import {
createFakeWorkbarServices,
TRACE_REFRESH_DEBOUNCE_MS,
useSessionTrace,
- WorkbarServicesProvider,
- type WorkbarSessionTracePage,
- type WorkbarSessionUsageSummary,
+ type SessionTracePage,
+ type SessionUsageSummary,
type WorkbarServices,
} from '../../renderer/features/workbar/testing.js';
@@ -62,7 +61,7 @@ function trace(sessionId: string): SessionTrace {
function usageSummary(
totalRequests = 0,
totalCostUsd = 0,
-): WorkbarSessionUsageSummary {
+): SessionUsageSummary {
return {
range: { from: 0, to: 1 },
totalRequests,
@@ -112,15 +111,15 @@ interface TraceHarness {
function createTraceHarness(
options: {
- tracePages?: WorkbarSessionTracePage[];
+ tracePages?: SessionTracePage[];
trace?: (
sessionId: string,
cursor?: string,
- ) => Promise<Result<WorkbarSessionTracePage>>;
+ ) => Promise<Result<SessionTracePage>>;
summary?: (
sessionId: string,
readIndex: number,
- ) => Promise<Result<WorkbarSessionUsageSummary>>;
+ ) => Promise<Result<SessionUsageSummary>>;
} = {},
): TraceHarness {
const handlers = new Set<(event: SessionEvent) => void>();
@@ -147,7 +146,7 @@ function createTraceHarness(
trace: async (
sessionId: string,
cursor?: string,
- ): Promise<Result<WorkbarSessionTracePage>> => {
+ ): Promise<Result<SessionTracePage>> => {
harness.reads.push(sessionId);
harness.traceRequests.push({ sessionId, ...(cursor ? { cursor } : {}) });
if (options.trace) return options.trace(sessionId, cursor);
@@ -213,20 +212,7 @@ function Probe(props: {
onSnapshot?: (trace: SessionTrace | undefined) => void;
onHookSnapshot?: (snapshot: ReturnType<typeof useSessionTrace>) => void;
}) {
- return createElement(
- WorkbarServicesProvider,
- { services: props.services },
- createElement(TraceProbe, props),
- );
-}
-
-function TraceProbe(props: {
- sessionId?: string;
- active: boolean;
- onSnapshot?: (trace: SessionTrace | undefined) => void;
- onHookSnapshot?: (snapshot: ReturnType<typeof useSessionTrace>) => void;
-}) {
- const snapshot = useSessionTrace(props.sessionId, props.active, COPY);
+ const snapshot = useSessionTrace(props.sessionId, props.active, COPY, props.services.inspector);
props.onSnapshot?.(snapshot.trace);
props.onHookSnapshot?.(snapshot);
return null;
@@ -236,7 +222,7 @@ function tracePage(
sessionId: string,
runId: string,
nextCursor: string | null,
-): WorkbarSessionTracePage {
+): SessionTracePage {
const startedAt = Number(runId.replace(/\D/g, ''));
return {
trace: {
@@ -543,8 +529,8 @@ describe('useSessionTrace', () => {
it('keeps the requested page depth when a head refresh supersedes load-earlier', async () => {
const { root } = installReactRenderer();
- let resolveEarlierPage: ((result: Result<WorkbarSessionTracePage>) => void) | undefined;
- const earlierPage = new Promise<Result<WorkbarSessionTracePage>>((resolve) => {
+ let resolveEarlierPage: ((result: Result<SessionTracePage>) => void) | undefined;
+ const earlierPage = new Promise<Result<SessionTracePage>>((resolve) => {
resolveEarlierPage = resolve;
});
let startReads = 0;
@@ -599,8 +585,8 @@ describe('useSessionTrace', () => {
it('settles loading when load-earlier supersedes an in-flight retry', async () => {
const { root } = installReactRenderer();
- let resolveRetry: ((result: Result<WorkbarSessionTracePage>) => void) | undefined;
- const retryPage = new Promise<Result<WorkbarSessionTracePage>>((resolve) => {
+ let resolveRetry: ((result: Result<SessionTracePage>) => void) | undefined;
+ const retryPage = new Promise<Result<SessionTracePage>>((resolve) => {
resolveRetry = resolve;
});
let headReads = 0;
diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
index db85aec741..885afa2c35 100644
--- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
@@ -154,7 +154,7 @@ async function mountController(failFirstRead = false, overrides: Partial<WorkHub
};
}
-test('WorkHub model selection settles with its new revision and cannot be undone by a delayed catalog read', async () => {
+test('WorkHub model and thinking selection share versioned saves and reject stale reads', async () => {
type Session = Awaited<ReturnType<WorkHubServices['getSession']>>;
const initial = {
id: JSON.stringify(['host-1', 'workhub-coordination']),
@@ -162,6 +162,7 @@ test('WorkHub model selection settles with its new revision and cannot be undone
runningTurnIds: [],
} as unknown as Session;
let snapshot = initial;
+ let failSave = false;
let notify!: () => void;
let nextRead: Promise<Session> | undefined;
const requests: Array<Parameters<WorkHubServices['configureModel']>[1]> = [];
@@ -174,7 +175,8 @@ test('WorkHub model selection settles with its new revision and cannot be undone
subscribeSessions: (handler) => { notify = handler; return () => {}; },
configureModel: async (_id, input) => {
requests.push(input);
- snapshot = { ...snapshot, model: input.modelTarget.model, revision: snapshot.revision + 1 };
+ if (failSave) throw new Error('configuration failed');
+ snapshot = { ...snapshot, model: input.modelTarget.model, thinkingLevel: input.thinkingLevel ?? undefined, revision: snapshot.revision + 1 };
return { kind: 'committed', session: snapshot } as unknown as Awaited<ReturnType<WorkHubServices['configureModel']>>;
},
});
@@ -190,6 +192,9 @@ test('WorkHub model selection settles with its new revision and cannot be undone
void change.then(() => { settled = true; });
});
assert.equal(settled, false, 'the wheel must remain pending until the saved session is available');
+ assert.equal(h.controller.configuringModel, true);
+ await act(async () => { await h.controller.changeThinkingLevel('high'); });
+ assert.equal(requests.length, 1, 'model and thinking saves cannot overlap');
await act(async () => { confirmation.resolve(snapshot); await change; });
assert.equal(h.controller.session?.model, 'B');
assert.equal(h.controller.session?.revision, 2);
@@ -200,6 +205,26 @@ test('WorkHub model selection settles with its new revision and cannot be undone
});
assert.equal(requests[1]?.expectedRevision, 2, 'the next pick uses the committed revision');
assert.equal(h.controller.session?.model, 'C');
+ await act(async () => { await h.controller.changeThinkingLevel('high'); });
+ assert.equal(h.controller.session?.thinkingLevel, 'high');
+ assert.equal(requests.at(-1)?.expectedRevision, 3);
+ assert.equal(requests.at(-1)?.modelTarget.model, 'C', 'thinking changes preserve model identity');
+ failSave = true;
+ await act(async () => { await h.controller.changeThinkingLevel('low'); });
+ assert.equal(h.controller.session?.thinkingLevel, 'high', 'failed writes retain the saved level');
+ assert.equal(h.controller.error, 'configuration failed');
+ assert.equal(h.controller.configuringModel, false);
+ failSave = false;
+ await act(async () => { await h.controller.changeThinkingLevel(undefined); });
+ assert.equal(requests.at(-1)?.thinkingLevel, null, 'default explicitly clears the stored override');
+ assert.equal(h.controller.session?.thinkingLevel, undefined);
+ await act(async () => { await h.controller.changeThinkingLevel('high'); });
+ await act(async () => { await h.controller.changeModel({ llmConnectionId: 'connection', llmConnectionSlug: 'provider', model: 'D' }); });
+ assert.equal(h.controller.session?.thinkingLevel, undefined, 'changing models clears the old model level');
+ const count = requests.length;
+ await act(async () => { h.admit('busy-turn'); });
+ await act(async () => { await h.controller.changeThinkingLevel('high'); });
+ assert.equal(requests.length, count, 'running turns cannot change their thinking level');
});
test('WorkHub stops presenting execution on observation loss while retaining the Stop target', async () => {
diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/copy.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/copy.ts
new file mode 100644
index 0000000000..e60bffa1e7
--- /dev/null
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/copy.ts
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export interface InspectorCopy {
+ ariaLabel: string;
+ /** Copy action and success copy for an unpriced model call's exact Pricing key. */
+ copyPricingKey: string;
+ pricingKeyCopied: string;
+ unpricedPricingKey: string;
+ /** Toast title when the clipboard write is denied or unavailable. */
+ copyFailed: string;
+ copyFailedDetail: string;
+ loadFailed: string;
+ retry: string;
+ empty: string;
+ /** The panel-empty (tier 2) sentence under `empty`. */
+ emptyHelp: string;
+ costUnavailable: string;
+ costEstimateHelp: string;
+ loadEarlier: string;
+ hideEarlier: string;
+ loadingEarlier: string;
+ loadingTrace: string;
+ loadingSummary: string;
+ summaryUnavailable: string;
+ /** Label for the complete Session cost estimate. */
+ totals: {
+ cost: string;
+ };
+ /**
+ * The session-wide metered-token split, read like a bill: what the
+ * provider's cache served, what was paid as uncached input, what was paid
+ * as output. Names the bands of the token track, in the track's order.
+ */
+ tokenUsage: {
+ title: string;
+ segment: { cacheRead: string; cacheMiss: string; output: string };
+ };
+ /**
+ * Where the session's recorded time went. Names the bands of the duration
+ * track; each row also states how many times its kind ran.
+ */
+ durationUsage: {
+ title: string;
+ /** Label under the ring's total figure. */
+ center: string;
+ segment: {
+ model: (count: number) => string;
+ tool: (count: number) => string;
+ };
+ };
+ /**
+ * The coverage notice, composed with its own breakdown: the separators
+ * belong to the language, not to the layout, so a Chinese sentence gets
+ * `:` and `、` where an English one gets `:` and `,`.
+ */
+ coveragePartial: (parts: readonly string[]) => string;
+ coverageAbsent: (parts: readonly string[]) => string;
+ /** Each states its own count, so English can say "1 turn" and not "1 turns". */
+ unreadable: (count: number) => string;
+ oversizedRuns: (count: number) => string;
+ turnsMissing: (count: number) => string;
+ turnsShort: (count: number) => string;
+ /**
+ * Names a step whose kind IS its identity — a compaction, an error, a
+ * permission prompt with no tool attached. Rows that carry a real
+ * identifier (a model id, a tool name) print that instead.
+ */
+ stepKind: { permission: string; compaction: string; error: string };
+ /** Why a model was called, when the reason was not the turn itself. */
+ callKind: (kind: string) => string;
+ /** How a permission request was answered. */
+ permissionDecision: (decision: string) => string;
+ /** What a tool that failed was recovered as. */
+ recoveredAs: (disposition: string) => string;
+ /** Attempts beyond the first, in words rather than as `×N`. */
+ retries: (count: number) => string;
+ /**
+ * What ended the turn badly, in words. The trace's codes are engineering
+ * vocabulary (`tool_failed`, `turn_aborted`); this is the sentence a
+ * reader gets, with a plain fallback for a code nobody has named yet.
+ */
+ turnFailure: (code: string) => string;
+ /** Stable display name of one turn, qualified by its recorded start time. */
+ turnLabel: (startedAt: string) => string;
+ /** Summary above the raw timeline. */
+ overview: {
+ context: string;
+ /** Names the bands of the context bar, in the bar's own order. */
+ segment: {
+ cacheRead: string;
+ fresh: string;
+ used: string;
+ free: string;
+ };
+ /** The three figures a reader opens this tab for, as headline stats. */
+ cacheHit: string;
+ /** Heading over the causal record. */
+ timelineTab: string;
+ /**
+ * What filled the context, under the bar that says how full it is.
+ *
+ * Kept verbally separate from the bar on purpose: these are estimates
+ * over serialized bytes and do not sum to the provider-reported prompt
+ * (#2323), so the heading says estimate and every figure carries a `≈`.
+ */
+ composition: {
+ title: string;
+ /** States the unit and its authority, once, under the heading. */
+ basis: string;
+ part: {
+ system_instructions: string;
+ tool_definitions: string;
+ messages: string;
+ other: string;
+ };
+ /** Heading over the per-tool rows. */
+ tools: string;
+ /** The tools below the visible rows, folded into one. */
+ remainingTools: (count: number) => string;
+ /** Tool schemas the payload never named. */
+ unlabelled: string;
+ /** The metered call carried no capture — a gap, not an empty prompt. */
+ unrecorded: string;
+ };
+ };
+}
+
+/**
+ * The name a step falls back to when it has no identifier of its own. A model
+ * call and a tool call always carry one, so they never reach here.
+ *
+ * It lives beside the words rather than in the panel so fallback labels stay
+ * part of the locale's vocabulary instead of being reconstructed by the view.
+ */
+export function inspectorStepKindLabel(copy: InspectorCopy, kind: string): string {
+ if (kind === 'permission') return copy.stepKind.permission;
+ if (kind === 'compaction') return copy.stepKind.compaction;
+ if (kind === 'error') return copy.stepKind.error;
+ return kind;
+}
diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts
new file mode 100644
index 0000000000..62635a5cee
--- /dev/null
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * The session's latest provider-counted request, or nothing.
+ *
+ * A token count belongs to one request on one route: it is a number in that
+ * model's tokenizer, and it is only the session's latest if nothing newer
+ * exists. The runtime enforces both when it reads an anchor back, refusing one
+ * whose run header names another model or connection. A control that shows the
+ * number has to enforce the same two facts or it will display a precise-looking
+ * figure about a request the user is not making — model A's tokens against
+ * model B's window, or a historical range's usage presented as current.
+ *
+ * So this refuses rather than approximates, and the three refusals are the
+ * three normal states that break the pairing:
+ *
+ * - the loaded transcript range is not the session tail, so a newer request may
+ * exist that this range cannot see;
+ * - the newest usage row carries no anchor, which is what manual `/compact`
+ * writes, so the scan continues past it exactly as the runtime's does;
+ * - the anchor names a different route than the active one, or names none at
+ * all because it was written before anchors carried their route.
+ */
+export interface LatestRequestUsageAnchor {
+ inputTokens: number;
+ outputTokens?: number;
+ modelId?: string;
+ connectionId?: string;
+}
+
+export function selectLatestRequestUsage(
+ messages: readonly { type: string; lastRequestAnchor?: LatestRequestUsageAnchor }[],
+ /** `hasNewer` means the loaded range is not the session tail. */
+ range: { hasNewer?: boolean } | undefined,
+ model: string | undefined,
+ route: { llmConnectionId?: string } | undefined,
+): number | undefined {
+ const connectionId = route?.llmConnectionId;
+ if (range?.hasNewer || model === undefined || connectionId === undefined) return undefined;
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const message = messages[index];
+ if (message?.type !== 'token_usage') continue;
+ const anchor = message.lastRequestAnchor;
+ if (!anchor) continue;
+ if (anchor.modelId !== model || anchor.connectionId !== connectionId) return undefined;
+ if (!Number.isFinite(anchor.inputTokens) || anchor.inputTokens <= 0) return undefined;
+ const output = Number.isFinite(anchor.outputTokens ?? 0) ? Math.max(0, anchor.outputTokens ?? 0) : 0;
+ return anchor.inputTokens + output;
+ }
+ return undefined;
+}
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts
similarity index 100%
rename from apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage.ts
rename to apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts
diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/service.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/service.ts
new file mode 100644
index 0000000000..ae99910b5e
--- /dev/null
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/service.ts
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { SessionEvent } from '@maka/core/events';
+import type { SessionTrace } from '@maka/core/session-trace';
+import type { Result } from '@maka/core/result';
+import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol';
+import type { MergedUsageSummary } from '@maka/core/usage-ledger-merge';
+
+export interface SessionTracePage {
+ readonly trace: SessionTrace;
+ readonly nextCursor: string | null;
+}
+
+export type SessionUsageSummary = MergedUsageSummary;
+
+export interface SessionInspectorService {
+ trace(
+ sessionId: string,
+ cursor?: string,
+ ): Promise<Result<SessionTracePage>>;
+ summary(sessionId: string): Promise<Result<SessionUsageSummary>>;
+ context(sessionId: string): Promise<Result<ContextDiagnosticsResult>>;
+ subscribeSessionEvents(
+ sessionId: string,
+ handler: (event: SessionEvent) => void,
+ ): () => void;
+ subscribeUsageChanges(
+ sessionId: string,
+ handler: () => void,
+ ): () => void;
+}
+
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-overview-model.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-overview-model.ts
similarity index 100%
rename from apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-overview-model.ts
rename to apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-overview-model.ts
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel-model.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel-model.ts
similarity index 100%
rename from apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel-model.ts
rename to apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel-model.ts
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx b/apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel.tsx
similarity index 99%
rename from apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx
rename to apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel.tsx
index 9b6c940269..dd6f2824b1 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import type { SessionInspectorService } from './service.js';
import { type ReactNode, useMemo, useState } from 'react';
import { Banner } from '@astryxdesign/core/Banner';
import { Button } from '@astryxdesign/core/Button';
@@ -30,10 +31,9 @@ import { traceTurnIdentityKey } from '@maka/core/session-trace';
import { useToast, useUiLocale } from '@maka/ui';
import { ICON_SIZE, Activity, AlertTriangle, Copy } from '@maka/ui/icons';
import {
- getDesktopConversationCopy,
type InspectorCopy,
inspectorStepKindLabel,
-} from '../../../../locales/conversation-copy.js';
+} from './copy.js';
import {
deriveInspectorOverviewModel,
estimatedSessionCost,
@@ -63,14 +63,14 @@ import { useSessionTrace } from './use-session-trace.js';
* like every other empty surface (EmptyState) rather than like a stray
* paragraph.
*/
-export function SessionInspectorPanel(props: { sessionId: string; active: boolean }) {
+export function SessionInspectorPanel(props: { sessionId: string; active: boolean; inspector: SessionInspectorService; copy: InspectorCopy }) {
const locale = useUiLocale();
- const copy = getDesktopConversationCopy(locale).inspector;
+ const { copy } = props;
const toast = useToast();
const snapshot = useSessionTrace(props.sessionId, props.active, {
loadFailed: copy.loadFailed,
locale,
- });
+ }, props.inspector);
const model = useMemo(() => deriveInspectorPanelModel(snapshot.trace), [snapshot.trace]);
const overview = useMemo(
() => deriveInspectorOverviewModel(snapshot.context, snapshot.summary),
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-trace-refresh.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/session-trace-refresh.ts
similarity index 100%
rename from apps/desktop/src/renderer/features/workbar/tools/inspector/session-trace-refresh.ts
rename to apps/desktop/src/renderer/application/contracts/session-inspector/session-trace-refresh.ts
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/use-live-context-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts
similarity index 95%
rename from apps/desktop/src/renderer/features/workbar/tools/inspector/use-live-context-usage.ts
rename to apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts
index a8e9ef4d0f..1933b5e423 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/inspector/use-live-context-usage.ts
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts
@@ -18,7 +18,7 @@
*/
import { useEffect, useState } from 'react';
-import { useWorkbarServices } from '../../services-context.js';
+import type { SessionInspectorService } from './service.js';
import {
createLiveContextUsageTracker,
type LiveContextUsage,
@@ -38,11 +38,12 @@ import { TRACE_REFRESH_DEBOUNCE_MS } from './session-trace-refresh.js';
* hook says nothing, and the caller falls back to the per-turn anchor.
*/
export function useLiveContextUsage(input: {
+ readonly inspector: SessionInspectorService;
readonly sessionId: string | undefined;
readonly model: string | undefined;
readonly providerType: string | undefined;
}): LiveContextUsage | undefined {
- const { inspector } = useWorkbarServices();
+ const { inspector } = input;
const [usage, setUsage] = useState<LiveContextUsage | undefined>(undefined);
const { sessionId, model, providerType } = input;
useEffect(() => {
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/use-session-trace.ts
similarity index 95%
rename from apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts
rename to apps/desktop/src/renderer/application/contracts/session-inspector/use-session-trace.ts
index 560ff4f47f..e7896bde04 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/use-session-trace.ts
@@ -26,20 +26,19 @@ import {
} from '@maka/core/session-trace';
import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol';
import type {
- WorkbarInspectorService,
- WorkbarSessionTracePage,
- WorkbarSessionUsageSummary,
-} from '../../ports.js';
+ SessionInspectorService,
+ SessionTracePage,
+ SessionUsageSummary,
+} from './service.js';
import {
createRefreshCoalescer,
createTraceRefreshCoalescer,
TRACE_REFRESH_DEBOUNCE_MS,
} from './session-trace-refresh.js';
-import { useWorkbarServices } from '../../services-context.js';
interface SessionTraceState {
sessionId?: string;
- tracePages?: readonly WorkbarSessionTracePage[];
+ tracePages?: readonly SessionTracePage[];
/**
* What the context is made of right now, from the Host operation that owns
* that question (#2323). Read on the same signal as the trace but kept
@@ -47,7 +46,7 @@ interface SessionTraceState {
* answer it must not blank the causal record beside it.
*/
context?: ContextDiagnosticsResult;
- summary?: WorkbarSessionUsageSummary;
+ summary?: SessionUsageSummary;
loading: boolean;
summaryLoading?: boolean;
summaryError?: boolean;
@@ -82,19 +81,19 @@ export function useSessionTrace(
// whose comment once outran its code — is renderable in a test without the
// UI package behind it.
copy: { loadFailed: string; locale: UiLocale },
+ inspector: SessionInspectorService,
): SessionTraceSnapshot & {
canHideEarlier: boolean;
retry: () => void;
loadEarlier: () => void;
hideEarlier: () => void;
} {
- const { inspector } = useWorkbarServices();
const traceRevisionRef = useRef(0);
const summaryRevisionRef = useRef(0);
const contextRevisionRef = useRef(0);
const desiredPageCountRef = useRef<{ sessionId: string; count: number } | undefined>(undefined);
const traceWindowRef = useRef<
- { sessionId: string; pages: readonly WorkbarSessionTracePage[] } | undefined
+ { sessionId: string; pages: readonly SessionTracePage[] } | undefined
>(undefined);
const [state, setState] = useState<SessionTraceState>(EMPTY_STATE);
@@ -375,13 +374,13 @@ export function useSessionTrace(
}
async function readTracePageWindow(
- inspector: WorkbarInspectorService,
+ inspector: SessionInspectorService,
sessionId: string,
pageCount: number,
loadFailed: string,
isCurrent: () => boolean,
-): Promise<WorkbarSessionTracePage[]> {
- const pages: WorkbarSessionTracePage[] = [];
+): Promise<SessionTracePage[]> {
+ const pages: SessionTracePage[] = [];
const seen = new Set<string>();
let requestCursor: string | null = null;
while (pages.length < pageCount) {
@@ -401,6 +400,6 @@ async function readTracePageWindow(
return pages;
}
-function mergeSessionTracePages(pages: readonly WorkbarSessionTracePage[]): SessionTrace {
+function mergeSessionTracePages(pages: readonly SessionTracePage[]): SessionTrace {
return mergeSessionTraces(pages.map((page) => page.trace));
}
diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx
index 18dfaca848..6098164b44 100644
--- a/apps/desktop/src/renderer/chat-composer-region.tsx
+++ b/apps/desktop/src/renderer/chat-composer-region.tsx
@@ -30,6 +30,7 @@ import {
UserQuestionPrompt,
} from '@maka/ui';
import type { ComposerHandle } from '@maka/ui';
+export { selectLatestRequestUsage } from './application/contracts/session-inspector/latest-request-usage.js';
import { useComposerMentionsContext } from './composer-mentions.js';
import {
readNewTaskReloadDraft,
@@ -153,56 +154,6 @@ interface ChatComposerRegionProps
directoryPickerEnabled: boolean;
}
-/**
- * The session's latest provider-counted request, or nothing.
- *
- * A token count belongs to one request on one route: it is a number in that
- * model's tokenizer, and it is only the session's latest if nothing newer
- * exists. The runtime enforces both when it reads an anchor back, refusing one
- * whose run header names another model or connection. A control that shows the
- * number has to enforce the same two facts or it will display a precise-looking
- * figure about a request the user is not making — model A's tokens against
- * model B's window, or a historical range's usage presented as current.
- *
- * So this refuses rather than approximates, and the three refusals are the
- * three normal states that break the pairing:
- *
- * - the loaded transcript range is not the session tail, so a newer request may
- * exist that this range cannot see;
- * - the newest usage row carries no anchor, which is what manual `/compact`
- * writes, so the scan continues past it exactly as the runtime's does;
- * - the anchor names a different route than the active one, or names none at
- * all because it was written before anchors carried their route.
- */
-export interface LatestRequestUsageAnchor {
- inputTokens: number;
- outputTokens?: number;
- modelId?: string;
- connectionId?: string;
-}
-
-export function selectLatestRequestUsage(
- messages: readonly { type: string; lastRequestAnchor?: LatestRequestUsageAnchor }[],
- /** `hasNewer` means the loaded range is not the session tail. */
- range: { hasNewer?: boolean } | undefined,
- model: string | undefined,
- route: { llmConnectionId?: string } | undefined,
-): number | undefined {
- const connectionId = route?.llmConnectionId;
- if (range?.hasNewer || model === undefined || connectionId === undefined) return undefined;
- for (let index = messages.length - 1; index >= 0; index -= 1) {
- const message = messages[index];
- if (message?.type !== 'token_usage') continue;
- const anchor = message.lastRequestAnchor;
- if (!anchor) continue;
- if (anchor.modelId !== model || anchor.connectionId !== connectionId) return undefined;
- if (!Number.isFinite(anchor.inputTokens) || anchor.inputTokens <= 0) return undefined;
- const output = Number.isFinite(anchor.outputTokens ?? 0) ? Math.max(0, anchor.outputTokens ?? 0) : 0;
- return anchor.inputTokens + output;
- }
- return undefined;
-}
-
export function ChatComposerRegion({
composerRef,
active,
diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts
index 6a31ce2d94..409a8e4a8a 100644
--- a/apps/desktop/src/renderer/features/workbar/ports.ts
+++ b/apps/desktop/src/renderer/features/workbar/ports.ts
@@ -17,6 +17,9 @@
* under the License.
*/
+import type { SessionInspectorService } from '../../application/contracts/session-inspector/service.js';
+export type { SessionInspectorService, SessionTracePage, SessionUsageSummary } from '../../application/contracts/session-inspector/service.js';
+
import type {
MessageQueuePlacement,
QuoteRef,
@@ -42,16 +45,12 @@ import type {
StoredMessage,
TurnRecord,
} from '@maka/core/session';
-import type { SessionTrace } from '@maka/core/session-trace';
import type { UserQuestionResponse } from '@maka/core/user-question';
import type { InteractionFormResponse } from '@maka/core/interaction';
-import type { Result } from '@maka/core/result';
import type {
ContextCompactResult,
- ContextDiagnosticsResult,
TurnMessageExecutionQueryResult,
} from '@maka/runtime-host/protocol';
-import type { MergedUsageSummary } from '@maka/core/usage-ledger-merge';
import type {
ShellRunPtyDataEvent,
ShellRunPtySnapshot,
@@ -150,30 +149,6 @@ export interface WorkbarArtifactsService {
saveAs(sessionId: string, artifactId: string): Promise<ArtifactSaveResult>;
}
-export interface WorkbarSessionTracePage {
- readonly trace: SessionTrace;
- readonly nextCursor: string | null;
-}
-
-export type WorkbarSessionUsageSummary = MergedUsageSummary;
-
-export interface WorkbarInspectorService {
- trace(
- sessionId: string,
- cursor?: string,
- ): Promise<Result<WorkbarSessionTracePage>>;
- summary(sessionId: string): Promise<Result<WorkbarSessionUsageSummary>>;
- context(sessionId: string): Promise<Result<ContextDiagnosticsResult>>;
- subscribeSessionEvents(
- sessionId: string,
- handler: (event: SessionEvent) => void,
- ): WorkbarUnsubscribe;
- subscribeUsageChanges(
- sessionId: string,
- handler: () => void,
- ): WorkbarUnsubscribe;
-}
-
export interface WorkbarAttachmentsService {
readBytes(sessionId: string, artifactId: string): Promise<ArtifactBinaryReadResult>;
pickFiles(): Promise<
@@ -312,7 +287,7 @@ export interface WorkbarServices {
readonly terminal: WorkbarTerminalService;
readonly browser: WorkbarBrowserService;
readonly artifacts: WorkbarArtifactsService;
- readonly inspector: WorkbarInspectorService;
+ readonly inspector: SessionInspectorService;
readonly attachments: WorkbarAttachmentsService;
readonly workBoard?: WorkbarWorkBoardService;
readonly sideChat: SideChatSessionPort;
diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts
index f64361735c..026f3986d1 100644
--- a/apps/desktop/src/renderer/features/workbar/testing.ts
+++ b/apps/desktop/src/renderer/features/workbar/testing.ts
@@ -22,8 +22,8 @@ import type { WorkbarServices } from './ports.js';
export { WorkbarServicesProvider } from './services-context.js';
export type {
WorkbarServices,
- WorkbarSessionTracePage,
- WorkbarSessionUsageSummary,
+ SessionTracePage,
+ SessionUsageSummary,
WorkbarIngestInput,
} from './ports.js';
@@ -32,17 +32,17 @@ export * from './model/workbar-layout.js';
export * from './model/workbar-tool-definitions.js';
export * from './tools/artifacts/artifact-list-keyboard.js';
export * from './tools/artifacts/artifact-visibility.js';
-export * from './tools/inspector/session-inspector-panel-model.js';
+export * from '../../application/contracts/session-inspector/session-inspector-panel-model.js';
export {
compactNumberFormatter,
InspectorCompositionSection,
RING_ACTIVE_MIN_SWEEP,
RING_MIN_SWEEP,
usageRingArcs,
-} from './tools/inspector/session-inspector-panel.js';
-export * from './tools/inspector/session-inspector-overview-model.js';
-export * from './tools/inspector/session-trace-refresh.js';
-export * from './tools/inspector/live-context-usage.js';
+} from '../../application/contracts/session-inspector/session-inspector-panel.js';
+export * from '../../application/contracts/session-inspector/session-inspector-overview-model.js';
+export * from '../../application/contracts/session-inspector/session-trace-refresh.js';
+export * from '../../application/contracts/session-inspector/live-context-usage.js';
export * from './tools/side-chat/quote-companion-panel-state.js';
export * from './tools/side-chat/quote-companion-core.js';
export * from './tools/side-chat/quote-companion-context-compaction.js';
@@ -53,7 +53,7 @@ export {
export * from './tools/terminal/session-terminal-hydration.js';
export * from './tools/terminal/session-terminal-query.js';
export * from './tools/terminal/session-terminal-frame.js';
-export * from './tools/inspector/use-session-trace.js';
+export * from '../../application/contracts/session-inspector/use-session-trace.js';
export * from './controller/use-workbar-controller.js';
export { SideChatCloseConfirmation } from './ui/side-chat-close-confirmation.js';
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx b/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx
index 14f3a032b7..e43864ef2d 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx
+++ b/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx
@@ -17,9 +17,10 @@
* under the License.
*/
+import { useWorkbarServices } from '../../services-context.js';
import type { ReactElement, ReactNode } from 'react';
-import type { LiveContextUsage } from './live-context-usage.js';
-import { useLiveContextUsage } from './use-live-context-usage.js';
+import type { LiveContextUsage } from '../../../../application/contracts/session-inspector/live-context-usage.js';
+import { useLiveContextUsage } from '../../../../application/contracts/session-inspector/use-live-context-usage.js';
/**
* Render-prop boundary for the composer context gauge (#4717).
@@ -37,7 +38,9 @@ export function LiveContextUsageProbe(props: {
readonly providerType: string | undefined;
readonly children: (usage: LiveContextUsage | undefined) => ReactNode;
}): ReactElement {
+ const { inspector } = useWorkbarServices();
const usage = useLiveContextUsage({
+ inspector,
sessionId: props.sessionId,
model: props.model,
providerType: props.providerType,
diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
index 6ca910c759..62cfdc4a91 100644
--- a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
+++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import { useWorkbarServices } from '../services-context.js';
import { lazy, Suspense, useState, type ReactNode } from 'react';
import { Composer, useUiLocale, type ChatModelChoice } from '@maka/ui';
import {
@@ -78,7 +79,7 @@ const BrowserPanel = lazy(() =>
import('../tools/browser/browser-panel').then((module) => ({ default: module.BrowserPanel })),
);
const SessionInspectorPanel = lazy(() =>
- import('../tools/inspector/session-inspector-panel').then((module) => ({
+ import('../../../application/contracts/session-inspector/session-inspector-panel.js').then((module) => ({
default: module.SessionInspectorPanel,
})),
);
@@ -419,6 +420,7 @@ export function WorkbarSurface(props: {
workBoardStartTaskEnabled?: boolean;
confirmBypass: () => Promise<boolean>;
}) {
+ const { inspector } = useWorkbarServices();
const locale = useUiLocale();
const copy = getDesktopConversationCopy(locale).workbar;
const [artifactCount, setArtifactCount] = useState({ sessionId: props.sessionId, count: 0 });
@@ -559,6 +561,8 @@ export function WorkbarSurface(props: {
content = (
<Suspense fallback={<WorkbarPanelLoading label={copy.inspector} />}>
<SessionInspectorPanel
+ inspector={inspector}
+ copy={getDesktopConversationCopy(locale).inspector}
key={props.sessionId}
sessionId={props.sessionId!}
active={!props.hidden && active}
diff --git a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts
index 58b6be323c..195700e263 100644
--- a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts
+++ b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts
@@ -34,6 +34,7 @@ import {
} from '@maka/ui';
import type { WorkHubAnswerInput, WorkHubAnswerResult } from '../../../../shared/workhub-conversation.js';
import type { AttachmentRef, FollowUpMode, MessageQueuePlacement } from '@maka/core/events';
+import type { ThinkingLevel } from '@maka/core/model-thinking';
import type { ChatModelChoice } from '@maka/core/chat-model-choice';
import { startWorkHubCoordinationLifecycle } from './coordination-lifecycle.js';
import { useWorkHubServices } from '../services.js';
@@ -62,6 +63,8 @@ export function useWorkHubController(onSubmit?: () => void) {
const [sessions, setSessions] = useState<Awaited<ReturnType<WorkHubServices['listSessions']>>>(
[],
);
+ const [configuringModel, setConfiguringModel] = useState(false);
+ const configuringModelRef = useRef(false);
const [choices, setChoices] = useState<ChatModelChoice[]>([]);
const [transcript, setTranscript] = useState(emptyTranscript);
// Reconciliation reads the published view, never a source page held by input.
@@ -524,11 +527,14 @@ export function useWorkHubController(onSubmit?: () => void) {
llmConnectionId: string;
llmConnectionSlug: string;
model: string;
- }) {
- if (!sessionId || !session || busy) return;
+ }, thinkingLevel: ThinkingLevel | null = null) {
+ if (!sessionId || !session || busy || configuringModelRef.current) return;
+ configuringModelRef.current = true;
+ setConfiguringModel(true);
try {
const result = await services.configureModel(sessionId, {
expectedRevision: session.revision,
+ thinkingLevel,
modelTarget: {
kind: 'explicit',
connectionId: input.llmConnectionId,
@@ -550,6 +556,9 @@ export function useWorkHubController(onSubmit?: () => void) {
if (currentSessionId.current !== sessionId) return;
refreshSessions.current();
report(reason);
+ } finally {
+ configuringModelRef.current = false;
+ setConfiguringModel(false);
}
}
async function mutateQueue(action: (target: string) => Promise<void>) {
@@ -596,6 +605,11 @@ export function useWorkHubController(onSubmit?: () => void) {
send,
stop,
changeModel,
+ configuringModel,
+ changeThinkingLevel: async (level: ThinkingLevel | undefined) => {
+ if (!session?.llmConnectionId || !session.llmConnectionSlug || !session.model) return;
+ await changeModel({ llmConnectionId: session.llmConnectionId, llmConnectionSlug: session.llmConnectionSlug, model: session.model }, level ?? null);
+ },
retry: () => {
const attempt = pendingSend.current;
if (readError && sessionId) {
diff --git a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts
index 54f2e272c2..3659db9256 100644
--- a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts
+++ b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts
@@ -29,6 +29,7 @@ export const workHubLiveCopy = {
controlFailed: 'The action could not be completed. Review the conversation and try again.',
progressTitle: 'WorkHub progress', progressWorking: 'Working', progressPaused: 'Paused', progressError: 'Needs attention', progressDone: 'Finished', progressHint: 'You can keep working in Maka.', progressOpen: 'View conversation', progressClose: 'Hide progress for this task',
title: 'WorkHub',
+ backToConversation: 'Back to conversation',
progressInput: 'Add a message…',
float: 'Float WorkHub',
dock: 'Return to Maka',
@@ -55,6 +56,7 @@ export const workHubLiveCopy = {
controlFailed: '操作未完成,请查看对话后重试。',
progressTitle: 'WorkHub 进度', progressWorking: '正在处理', progressPaused: '已暂停', progressError: '需要查看', progressDone: '本轮已结束', progressHint: '你可以继续在 Maka 中操作。', progressOpen: '查看对话', progressClose: '本轮不再显示进度',
title: '工作台',
+ backToConversation: '返回对话',
progressInput: '补充一句…',
float: '浮出工作台',
dock: '收回 Maka',
@@ -81,6 +83,7 @@ export const workHubLiveCopy = {
controlFailed: '操作未完成,請查看對話後重試。',
progressTitle: 'WorkHub 進度', progressWorking: '正在處理', progressPaused: '已暫停', progressError: '需要查看', progressDone: '本輪已結束', progressHint: '你可以繼續在 Maka 中操作。', progressOpen: '查看對話', progressClose: '本輪不再顯示進度',
title: '工作台',
+ backToConversation: '返回對話',
progressInput: '補充一句…',
float: '浮出工作台',
dock: '收回 Maka',
diff --git a/apps/desktop/src/renderer/features/workhub/ports.ts b/apps/desktop/src/renderer/features/workhub/ports.ts
index 190a7210da..453791b3d2 100644
--- a/apps/desktop/src/renderer/features/workhub/ports.ts
+++ b/apps/desktop/src/renderer/features/workhub/ports.ts
@@ -49,6 +49,7 @@ export interface WorkHubTranscript {
close(): Promise<void>;
}
export interface WorkHubServices {
+ readonly inspector: import('../../application/contracts/session-inspector/service.js').SessionInspectorService;
readonly surface: 'main' | 'workhub';
readonly initialLocale: UiLocale;
subscribeAppearance(handler: (locale: UiLocale) => void): () => void;
diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
index 1088b8212b..59786210cf 100644
--- a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
@@ -21,6 +21,10 @@ import { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } fro
import { ChatSurfaceLayout, UserQuestionPrompt, MakaWordmark, useUiLocale, type ComposerHandle } from '@maka/ui';
import { Button, IconButton } from '@astryxdesign/core';
import { ChevronDown, PictureInPicture2, Undo2, X } from '@maka/ui/icons';
+import { useLiveContextUsage } from '../../../application/contracts/session-inspector/use-live-context-usage.js';
+import { SessionInspectorPanel } from '../../../application/contracts/session-inspector/session-inspector-panel.js';
+import { selectLatestRequestUsage } from '../../../application/contracts/session-inspector/latest-request-usage.js';
+import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js';
import { WorkHubProgressCard } from './workhub-progress-card.js';
import { WorkHubComposer } from './workhub-composer.js';
import { WorkHubConversation } from './workhub-conversation.js';
@@ -74,6 +78,13 @@ function WorkHubContents() {
const { selectWork } = useContext(WorkHubHighlightContext);
const controller = useWorkHubController(() => selectWork(undefined));
const { services, session, transcript, busy } = controller;
+ const modelChoice = controller.choices.find((choice) =>
+ choice.connectionId === session?.llmConnectionId && choice.connectionSlug === session?.llmConnectionSlug && choice.model === session?.model,
+ );
+ const thinkingLevels = modelChoice?.thinkingLevels ?? [];
+ const liveContextUsage = useLiveContextUsage({ inspector: services.inspector, sessionId: controller.sessionId, model: session?.model, providerType: modelChoice?.providerType });
+ const [inspectingContext, setInspectingContext] = useState(false);
+ const thinkingLevel = session?.thinkingLevel && thinkingLevels.includes(session.thinkingLevel) ? session.thinkingLevel : undefined;
const locale = useUiLocale();
const t = workHubLiveCopy[locale];
const shortcutLabel = navigator.platform.toLowerCase().includes('mac') ? '⌘⇧K' : 'Ctrl+Shift+K';
@@ -324,11 +335,28 @@ function WorkHubContents() {
onStop={controller.stop}
activeSession={session}
activeModel={session?.model}
+ activeModelLabel={modelChoice?.label}
+ activeProviderType={modelChoice?.providerType}
activeModelConnectionId={session?.llmConnectionId}
activeModelConnectionSlug={session?.llmConnectionSlug}
modelChoices={controller.choices}
maxInputRows={progress && !editingProgress ? 1 : showConversation ? undefined : 6}
onModelChange={controller.changeModel}
+ modelSwitchAvailability={controller.configuringModel ? { available: false, pending: true, reason: 'pending' } : undefined}
+ contextUsage={session ? {
+ usageTokens: liveContextUsage?.usageTokens ?? selectLatestRequestUsage(transcript.messages, transcript, session.model, session),
+ declaredContextWindow: modelChoice?.declaredContextWindow,
+ meteredContextWindow: liveContextUsage?.contextWindow,
+ metadataContextWindow: modelChoice?.contextWindow,
+ onOpen: () => {
+ setInspectingContext(true);
+ setConversationExpanded(true);
+ if (progress) call(services.presentation.expandProgress(presentation.progressRequest));
+ },
+ } : undefined}
+ activeThinkingLevels={thinkingLevels}
+ activeThinkingLevel={thinkingLevel}
+ onThinkingLevelChange={controller.changeThinkingLevel}
modelSwitchHasHistory={transcript.messages.length > 0}
footerAccessory={
<div className="workHubComposerActions">
@@ -348,7 +376,10 @@ function WorkHubContents() {
<div className="workhub-body">
<WorkHubNavigationRail locale={locale} sessions={tasks} delegatedSessionIds={delegatedSessionIds} copy={getWorkHubRailCopy(locale)} />
<div className="workhub-conversation-shell">
- <WorkHubConversation
+ {inspectingContext && session ? <div className="workHubContextInspector">
+ <Button variant="ghost" size="sm" label={t.backToConversation} onClick={() => setInspectingContext(false)} />
+ <SessionInspectorPanel sessionId={session.id} active={showConversation} inspector={services.inspector} copy={getDesktopConversationCopy(locale).inspector} />
+ </div> : <WorkHubConversation
promptStates={promptStates}
workLinks={linksWithFeedback}
onReadAttachmentBytes={services.readAttachmentBytes}
@@ -374,7 +405,7 @@ function WorkHubContents() {
<p>{t.hint}</p>
</div>
}
- />
+ />}
</div></div>
</div>
</ChatSurfaceLayout>
diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts
index 555595b70f..d1654f37ba 100644
--- a/apps/desktop/src/renderer/locales/conversation-copy.ts
+++ b/apps/desktop/src/renderer/locales/conversation-copy.ts
@@ -17,6 +17,8 @@
* under the License.
*/
+import type { InspectorCopy } from '../application/contracts/session-inspector/copy.js';
+
import type { ChatConfigurationReason } from '@maka/core/connection-readiness';
import type { SessionSendProjection } from '@maka/core/session-send-projection';
@@ -176,130 +178,7 @@ export interface DesktopConversationCopy {
writeFailed: string;
stopFailed: string;
};
- inspector: {
- ariaLabel: string;
- /** Copy action and success copy for an unpriced model call's exact Pricing key. */
- copyPricingKey: string;
- pricingKeyCopied: string;
- unpricedPricingKey: string;
- /** Toast title when the clipboard write is denied or unavailable. */
- copyFailed: string;
- copyFailedDetail: string;
- loadFailed: string;
- retry: string;
- empty: string;
- /** The panel-empty (tier 2) sentence under `empty`. */
- emptyHelp: string;
- costUnavailable: string;
- costEstimateHelp: string;
- loadEarlier: string;
- hideEarlier: string;
- loadingEarlier: string;
- loadingTrace: string;
- loadingSummary: string;
- summaryUnavailable: string;
- /** Label for the complete Session cost estimate. */
- totals: {
- cost: string;
- };
- /**
- * The session-wide metered-token split, read like a bill: what the
- * provider's cache served, what was paid as uncached input, what was paid
- * as output. Names the bands of the token track, in the track's order.
- */
- tokenUsage: {
- title: string;
- segment: { cacheRead: string; cacheMiss: string; output: string };
- };
- /**
- * Where the session's recorded time went. Names the bands of the duration
- * track; each row also states how many times its kind ran.
- */
- durationUsage: {
- title: string;
- /** Label under the ring's total figure. */
- center: string;
- segment: {
- model: (count: number) => string;
- tool: (count: number) => string;
- };
- };
- /**
- * The coverage notice, composed with its own breakdown: the separators
- * belong to the language, not to the layout, so a Chinese sentence gets
- * `:` and `、` where an English one gets `:` and `,`.
- */
- coveragePartial: (parts: readonly string[]) => string;
- coverageAbsent: (parts: readonly string[]) => string;
- /** Each states its own count, so English can say "1 turn" and not "1 turns". */
- unreadable: (count: number) => string;
- oversizedRuns: (count: number) => string;
- turnsMissing: (count: number) => string;
- turnsShort: (count: number) => string;
- /**
- * Names a step whose kind IS its identity — a compaction, an error, a
- * permission prompt with no tool attached. Rows that carry a real
- * identifier (a model id, a tool name) print that instead.
- */
- stepKind: { permission: string; compaction: string; error: string };
- /** Why a model was called, when the reason was not the turn itself. */
- callKind: (kind: string) => string;
- /** How a permission request was answered. */
- permissionDecision: (decision: string) => string;
- /** What a tool that failed was recovered as. */
- recoveredAs: (disposition: string) => string;
- /** Attempts beyond the first, in words rather than as `×N`. */
- retries: (count: number) => string;
- /**
- * What ended the turn badly, in words. The trace's codes are engineering
- * vocabulary (`tool_failed`, `turn_aborted`); this is the sentence a
- * reader gets, with a plain fallback for a code nobody has named yet.
- */
- turnFailure: (code: string) => string;
- /** Stable display name of one turn, qualified by its recorded start time. */
- turnLabel: (startedAt: string) => string;
- /** Summary above the raw timeline. */
- overview: {
- context: string;
- /** Names the bands of the context bar, in the bar's own order. */
- segment: {
- cacheRead: string;
- fresh: string;
- used: string;
- free: string;
- };
- /** The three figures a reader opens this tab for, as headline stats. */
- cacheHit: string;
- /** Heading over the causal record. */
- timelineTab: string;
- /**
- * What filled the context, under the bar that says how full it is.
- *
- * Kept verbally separate from the bar on purpose: these are estimates
- * over serialized bytes and do not sum to the provider-reported prompt
- * (#2323), so the heading says estimate and every figure carries a `≈`.
- */
- composition: {
- title: string;
- /** States the unit and its authority, once, under the heading. */
- basis: string;
- part: {
- system_instructions: string;
- tool_definitions: string;
- messages: string;
- other: string;
- };
- /** Heading over the per-tool rows. */
- tools: string;
- /** The tools below the visible rows, folded into one. */
- remainingTools: (count: number) => string;
- /** Tool schemas the payload never named. */
- unlabelled: string;
- /** The metered call carried no capture — a gap, not an empty prompt. */
- unrecorded: string;
- };
- };
- };
+ inspector: InspectorCopy;
quoteCompanion: {
/** Prefix for the companion fork's session name (followed by the excerpt). */
namePrefix: string;
@@ -1177,18 +1056,3 @@ export function getDesktopConversationCopy(locale: UiLocale): DesktopConversatio
return COPY[locale];
}
-export type InspectorCopy = DesktopConversationCopy['inspector'];
-
-/**
- * The name a step falls back to when it has no identifier of its own. A model
- * call and a tool call always carry one, so they never reach here.
- *
- * It lives beside the words rather than in the panel so fallback labels stay
- * part of the locale's vocabulary instead of being reconstructed by the view.
- */
-export function inspectorStepKindLabel(copy: InspectorCopy, kind: string): string {
- if (kind === 'permission') return copy.stepKind.permission;
- if (kind === 'compaction') return copy.stepKind.compaction;
- if (kind === 'error') return copy.stepKind.error;
- return kind;
-}
diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
index 9941054271..09fc2c183f 100644
--- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
@@ -339,7 +339,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
removeErrors: { invalid_id: '宠物 ID 无效。', remove_failed: '无法删除本地宠物包。' },
},
general: {
- codeModeHelp: '开启后,新任务通过代码编排和调用工具。仅影响新建任务,已有任务保持不变。',
+ codeModeHelp: '开启后,新任务通过代码编排和调用工具。WorkHub 从下一轮起采用新设置,其他已有任务保持不变。',
incognito: '隐身模式', incognitoHelp: '开启后暂停本地记忆读写、联网搜索和定时任务触发。', enableIncognito: '启用隐身模式', incognitoFailed: '隐身模式切换失败', notifications: '完成时发送系统通知', notificationsHelp: '窗口不在前台时,在回答完成或出错后发送桌面通知。', notificationsFailed: '通知设置切换失败', workspaceInstructions: '遵循项目指令', workspaceInstructionsHelp: '自动读取每个项目中已有的 AGENTS.md、CLAUDE.md 或 GEMINI.md;文件仍由各自项目管理。', workspaceInstructionsFailed: '项目指令设置切换失败', workHub: '启用 WorkHub', workHubHelp: 'WorkHub 目前仍不可用。此开关仅供开发测试,开启后也不能保证正常使用。', workHubFailed: 'Wor [...]
defaultModel: '默认模型', defaultModelHelp: '新任务默认使用的模型。', notSet: '未设置', saveDefaultModelFailed: '保存默认模型失败', defaultPermission: '默认权限模式', defaultPermissionHelp: '新任务默认使用的权限模式;可在任务内随时切换。', saveDefaultPermissionFailed: '保存默认权限模式失败', defaultThinking: '默认思考级别', defaultThinkingHelp: '新任务的思考级别;当前模型不支持所选级别时用模型默认。', followModelDefault: '跟随模型默认', saveDefaultThinkingFailed: '保存默认思考级别失败',
shellPreference: 'Bash 工具 shell', shellPreferenceHelp: '自动模式保持 Windows 的 PowerShell 优先规则;Git Bash 是仅对当前 Runtime Host 生效的显式覆盖。', shellAuto: '自动(推荐)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash 可执行文件', shellExecutableHelp: '填写 Runtime Host 所在 Windows 机器上 bash.exe 的绝对路径。也支持该机器上的旧版 System32 WSL Bash;保存时会验证 GNU Bash。', saveShell: '保存 shell 设置', savingShell: '正在保存…', shellSaved: '已保存', saveShellFailed: '保存 shell 设置失败', shellExecutableRejected: '当前 Runtime Host 无法把该路径作为 GNU Bash [...]
@@ -444,7 +444,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
removeErrors: { invalid_id: '寵物 ID 無效。', remove_failed: '無法刪除本機寵物包。' },
},
general: {
- codeModeHelp: '開啟後,新任務透過程式碼編排和呼叫工具。僅影響新建任務,既有任務保持不變。',
+ codeModeHelp: '開啟後,新任務透過程式碼編排和呼叫工具。WorkHub 從下一輪起套用新設定,其他既有任務保持不變。',
incognito: '隱身模式', incognitoHelp: '開啟後暫停本地記憶讀寫、聯網搜尋和定時任務觸發。', enableIncognito: '啟用隱身模式', incognitoFailed: '隱身模式切換失敗', notifications: '完成時傳送系統通知', notificationsHelp: '視窗不在前臺時,在回答完成或出錯後傳送桌面通知。', notificationsFailed: '通知設定切換失敗', workspaceInstructions: '遵循專案指令', workspaceInstructionsHelp: '自動讀取每個專案中已有的 AGENTS.md、CLAUDE.md 或 GEMINI.md;檔案仍由各自專案管理。', workspaceInstructionsFailed: '專案指令設定切換失敗', workHub: '啟用 WorkHub', workHubHelp: '在一個入口檢視已有工作,並將新輸入保守地送往普通任務。', workHubFailed: 'WorkHub 設定切換失敗 [...]
defaultModel: '預設模型', defaultModelHelp: '新任務預設使用的模型。', notSet: '未設定', saveDefaultModelFailed: '儲存預設模型失敗', defaultPermission: '預設權限模式', defaultPermissionHelp: '新任務預設使用的權限模式;可在任務內隨時切換。', saveDefaultPermissionFailed: '儲存預設權限模式失敗', defaultThinking: '預設思考級別', defaultThinkingHelp: '新任務的思考級別;目前模型不支援所選級別時用模型預設。', followModelDefault: '跟隨模型預設', saveDefaultThinkingFailed: '儲存預設思考級別失敗',
shellPreference: 'Bash 工具 shell', shellPreferenceHelp: '自動模式保持 Windows 的 PowerShell 優先規則;Git Bash 是僅對目前 Runtime Host 生效的顯式覆蓋。', shellAuto: '自動(推薦)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash 執行檔', shellExecutableHelp: '填寫 Runtime Host 所在 Windows 機器上 bash.exe 的絕對路徑。也支援該機器上的舊版 System32 WSL Bash;儲存時會驗證 GNU Bash。', saveShell: '儲存 shell 設定', savingShell: '正在儲存…', shellSaved: '已儲存', saveShellFailed: '儲存 shell 設定失敗', shellExecutableRejected: '目前 Runtime Host 無法把該路徑作為 GNU Bash 執 [...]
@@ -506,7 +506,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
removeErrors: { invalid_id: 'The pet ID is invalid.', remove_failed: 'The local pet pack could not be removed.' },
},
general: {
- codeModeHelp: 'New tasks use code to compose and call tools. Changes apply only to new tasks; existing tasks keep their mode.',
+ codeModeHelp: 'New tasks use code to compose and call tools. WorkHub applies changes on its next turn; other existing tasks keep their mode.',
incognito: 'Incognito mode', incognitoHelp: 'Pause local memory, web search, and scheduled task triggers.', enableIncognito: 'Enable incognito mode', incognitoFailed: 'Could not change incognito mode', notifications: 'Send a system notification when finished', notificationsHelp: 'Notify when a response finishes or fails while the window is in the background.', notificationsFailed: 'Could not change notification settings', workspaceInstructions: 'Follow project instructions', worksp [...]
shellPreference: 'Bash tool shell', shellPreferenceHelp: 'Automatic keeps the PowerShell-first Windows default. Git Bash is an explicit override for the current Runtime Host.', shellAuto: 'Automatic (recommended)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash executable', shellExecutableHelp: 'Enter the absolute path to bash.exe on the Windows machine running the Runtime Host. The legacy System32 WSL Bash shim is also recognized; Maka verifies GNU Bash before saving.', save [...]
passwordSavedPlaceholder: 'Password saved; enter a new password to replace it',
diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
index bd1e2d637f..f6886fee5e 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
@@ -55,6 +55,16 @@ function isDesktopTerminal(update: ShellRunUpdate): boolean {
}
/** The only Desktop-to-Workbar adapter. It narrows the preload bridge by tool. */
+export function createDesktopInspectorService(bridge: Pick<MakaBridge, 'inspector' | 'sessions'>) {
+ return {
+ trace: (sessionId: string, cursor?: string) => bridge.inspector.trace(sessionId, cursor),
+ summary: (sessionId: string) => bridge.inspector.summary(sessionId),
+ context: (sessionId: string) => bridge.inspector.context(sessionId),
+ subscribeSessionEvents: (sessionId: string, handler: Parameters<MakaBridge['sessions']['subscribeEvents']>[1]) => bridge.sessions.subscribeEvents(sessionId, handler),
+ subscribeUsageChanges: (sessionId: string, handler: () => void) => bridge.inspector.subscribeUsageChanges(sessionId, handler),
+ };
+}
+
export function createDesktopWorkbarServices(
bridge: DesktopWorkbarBridge = window.maka,
dependencies: DesktopWorkbarServiceDependencies = DEFAULT_DEPENDENCIES,
@@ -143,15 +153,7 @@ export function createDesktopWorkbarServices(
saveAs: (sessionId, artifactId) =>
bridge.app.saveArtifactAs(sessionId, artifactId),
},
- inspector: {
- trace: (sessionId, cursor) => bridge.inspector.trace(sessionId, cursor),
- summary: (sessionId) => bridge.inspector.summary(sessionId),
- context: (sessionId) => bridge.inspector.context(sessionId),
- subscribeSessionEvents: (sessionId, handler) =>
- bridge.sessions.subscribeEvents(sessionId, handler),
- subscribeUsageChanges: (sessionId, handler) =>
- bridge.inspector.subscribeUsageChanges(sessionId, handler),
- },
+ inspector: createDesktopInspectorService(bridge),
attachments: bridge.attachments,
...(bridge.workBoard
? {
diff --git a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts
index e629a4c0ac..c90014325e 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { createDesktopInspectorService } from './create-workbar-services.js';
import { resolveSystemUiLocale, resolveUiLocale } from '@maka/core/ui-locale';
import { DEFAULT_UI_FONT_SIZE } from '@maka/core/settings';
import { AttachmentIngestBlockedError } from '@maka/core/attachments';
@@ -100,6 +101,7 @@ async function readDelegatedTurnResult(
export function createDesktopWorkHubServices(
bridge: Pick<
MakaBridge,
+ | 'inspector'
| 'workHub'
| 'workHubControl'
| 'workHubPresentation'
@@ -113,6 +115,7 @@ export function createDesktopWorkHubServices(
): WorkHubServices {
const delegatedResultCache = new Map<string, string>();
return {
+ inspector: createDesktopInspectorService(bridge),
surface: new URLSearchParams(window.location.search).get('surface') === 'workhub' ? 'workhub' : 'main',
initialLocale: resolveSystemUiLocale(navigator.languages),
subscribeAppearance(handler) {
diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css
index a05497c757..fa3c86bd1f 100644
--- a/apps/desktop/src/renderer/styles/workhub.css
+++ b/apps/desktop/src/renderer/styles/workhub.css
@@ -161,6 +161,21 @@
.workHubLive[data-placement='floating'][data-conversation-expanded='false'] .maka-model-wheel-anchor[data-open='true'] {
width: min(264px, 55vw); height: var(--maka-model-wheel-height);
}
+ .workHubLive .maka-thinking-level-menu {
+ background: color-mix(in oklch, var(--background-elevated) 94%, transparent);
+ backdrop-filter: blur(12px);
+ }
+ /* A compact native window cannot paint the thinking menu outside its bounds.
+ Keep the menu in the window and scroll its options without resizing the composer. */
+ .workHubLive[data-placement='floating'][data-conversation-expanded='false'] [popover]:has(> .maka-thinking-level-menu) {
+ position-area: none !important;
+ inset: 4px max(4px, calc(100vw - anchor(right))) auto auto;
+ margin: 0;
+ }
+ .workHubLive[data-placement='floating'][data-conversation-expanded='false'] .maka-thinking-level-menu {
+ max-height: min(300px, calc(100dvh - 8px));
+ }
+ .workHubContextInspector { height: 100%; overflow: auto; padding: var(--space-3); }
.workHubComposerActions { display: flex; align-items: center; gap: 2px; margin-left: auto; }
.workHubRevealMark { position: absolute; inset: 32px 0 calc(var(--workhub-composer-height, 96px) + 12px); display: grid; place-items: center; z-index: 3; color: var(--foreground); opacity: 0; pointer-events: none; }
.workHubRevealMark svg { filter: drop-shadow(0 2px 12px var(--background)); }
diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx
index 690d277d62..1cceeeda92 100644
--- a/apps/desktop/stories/session-workbar.stories.tsx
+++ b/apps/desktop/stories/session-workbar.stories.tsx
@@ -39,7 +39,7 @@ import {
type QuoteCompanionPanelState,
type SessionWorkbarTab,
type SessionWorkbarTabKind,
- type WorkbarSessionUsageSummary,
+ type SessionUsageSummary,
} from '../src/renderer/features/workbar/testing';
// Fidelity convention (#1433): every story below names the real app path
@@ -683,7 +683,7 @@ const olderTrace: SessionTrace = {
],
};
-const emptyUsageSummary: WorkbarSessionUsageSummary = {
+const emptyUsageSummary: SessionUsageSummary = {
range: { from: NOW, to: NOW },
totalRequests: 0,
totalCostUsd: 0,
@@ -715,7 +715,7 @@ const emptyUsageSummary: WorkbarSessionUsageSummary = {
},
};
-const populatedUsageSummary: WorkbarSessionUsageSummary = {
+const populatedUsageSummary: SessionUsageSummary = {
range: { from: NOW, to: NOW + 43_600 },
totalRequests: 3,
totalCostUsd: 0.0243,
diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx
index 26c645cf37..be0bc2e6d6 100644
--- a/apps/desktop/stories/workhub.stories.tsx
+++ b/apps/desktop/stories/workhub.stories.tsx
@@ -19,6 +19,7 @@
import { useState } from 'react';
import { ToastProvider, LocaleProvider, AstryxLocaleProvider, ChatSurfaceLayout } from '@maka/ui';
+import type { ThinkingLevel } from '@maka/core/model-thinking';
import type { StoredMessage, SessionSummary } from '@maka/core/session';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within, waitFor } from 'storybook/test';
@@ -32,7 +33,7 @@ const targetId = desktopSessionKey({ hostId: 'story-host', sessionId: 'payments'
const writes = { answer: fn(), model: fn(), upload: fn(), open: fn(), question: fn(), form: fn() };
const choices = ['model-a', 'model-b'].map((model, index) => ({
connectionId: 'connection-test', connectionSlug: 'test', connectionName: 'Test', providerType: 'openai' as const,
- providerLabel: 'OpenAI', model, label: model, isDefault: index === 0, thinkingLevels: [],
+ providerLabel: 'OpenAI', model, label: model, contextWindow: 100_000, isDefault: index === 0, thinkingLevels: ['low', 'high'] as ThinkingLevel[],
}));
function makeServices(failFirst: boolean, withHistory: boolean, coloredHistory: boolean, selectTarget = false, question = false, progress = false): WorkHubServices {
let failures = failFirst ? 1 : 0;
@@ -75,6 +76,13 @@ function makeServices(failFirst: boolean, withHistory: boolean, coloredHistory:
const publishExecution = () => updateExecution?.({ type: 'host_execution', available: true, rootTurn: pendingForm ? { sessionId, turnId: pendingForm.turnId, runId: 'selection-run', status: 'waiting_for_user' } : questionPending ? { sessionId, turnId: 'question-turn', runId: 'question-run', status: 'waiting_for_user' } : null });
const publish = () => { publishExecution(); updateTranscript?.({ messages, hasOlder: false, hasNewer: false, ready: true }); };
return {
+ inspector: {
+ context: async () => ({ ok: true, data: { status: 'available', completedAt: 1, modelId: session.model, providerId: 'openai', inputTokens: 1000, contextWindow: 100_000 } }),
+ trace: async () => ({ ok: false, error: { code: 'unavailable', message: 'No trace in this fixture' } }),
+ summary: async () => ({ ok: false, error: { code: 'unavailable', message: 'No usage summary in this fixture' } }),
+ subscribeSessionEvents: () => () => {},
+ subscribeUsageChanges: () => () => {},
+ },
retractQueueEntry: async () => {}, promoteQueueEntry: async () => {},
updateQueueEntry: async () => {}, reorderQueueEntries: async () => {},
enqueueMessage: async () => 'admitted',
@@ -129,7 +137,7 @@ function makeServices(failFirst: boolean, withHistory: boolean, coloredHistory:
publish(); return { kind: 'admitted', turnId: input.turnId };
},
configureModel: async (id, input) => {
- writes.model(id, input); session = { ...session, revision: session.revision + 1, model: input.modelTarget.model }; updateSessions?.();
+ writes.model(id, input); session = { ...session, revision: session.revision + 1, model: input.modelTarget.model, thinkingLevel: input.thinkingLevel ?? undefined }; updateSessions?.();
return { kind: 'committed', session: { ...session, workspace: { target: { kind: 'host_path', path: '/projects/maka' }, hostCwd: '/projects/maka' }, createdAt: 0, activityAt: 0, labelsTruncated: false, llmConnectionId: 'connection-test', collaborationMode: 'agent', orchestrationMode: 'default' } };
},
observe: (_id, _event, _error, _phase, execution) => { updateExecution = execution; publishExecution(); return () => { updateExecution = undefined; }; },
@@ -183,6 +191,7 @@ export const StandardComposer: Story = {
Object.values(writes).forEach((spy) => spy.mockClear());
const canvas = within(canvasElement); const page = within(canvasElement.ownerDocument.body);
await waitFor(() => expect(canvas.getByRole('button', { name: /切换当前任务模型/ })).toBeEnabled());
+ await waitFor(() => expect(canvas.getByRole('button', { name: '打开用量追踪' }).textContent).toContain('1%'));
const trigger = canvas.getByRole('button', { name: /切换当前任务模型/ });
const layout = () => Array.from(canvasElement.querySelectorAll('.maka-composer-editor, .maka-composer button')).map((element) => {
const { x, y, width, height } = element.getBoundingClientRect();
@@ -228,6 +237,29 @@ export const StandardComposer: Story = {
await waitFor(() => expect(canvasElement.querySelectorAll('.maka-composer-attachment-token')).toHaveLength(0));
},
};
+// Real path: WorkHub composer → thinking level → choose an override or restore the default.
+export const ThinkingLevelPicker: Story = {
+ render: () => <Surface />,
+ play: async ({ canvasElement }) => {
+ Object.values(writes).forEach((spy) => spy.mockClear());
+ const canvas = within(canvasElement); const page = within(canvasElement.ownerDocument.body);
+ await waitFor(() => expect(canvas.getByRole('button', { name: '思考级别: 默认' })).toBeEnabled());
+ const usage = canvas.getByRole('button', { name: '打开用量追踪' });
+ await waitFor(() => expect(usage.textContent).toContain('1%'));
+ await userEvent.click(usage);
+ await userEvent.click(await canvas.findByRole('button', { name: '返回对话' }));
+ await userEvent.click(canvas.getByRole('button', { name: '思考级别: 默认' }));
+ await userEvent.click(page.getByRole('menuitemradio', { name: /^高$/ }));
+ await waitFor(() => expect(canvas.getByRole('button', { name: '思考级别: 高' })).toBeEnabled());
+ await expect(writes.model).toHaveBeenCalledWith(sessionId, expect.objectContaining({ thinkingLevel: 'high' }));
+ await userEvent.click(canvas.getByRole('button', { name: '思考级别: 高' }));
+ await userEvent.click(page.getByRole('menuitemradio', { name: /^默认$/ }));
+ await waitFor(() => expect(canvas.getByRole('button', { name: '思考级别: 默认' })).toBeEnabled());
+ await expect(writes.model).toHaveBeenLastCalledWith(sessionId, expect.objectContaining({ expectedRevision: 2, thinkingLevel: null }));
+ await userEvent.click(canvas.getByRole('button', { name: '思考级别: 默认' }));
+ await expect(page.getByRole('menuitemradio', { name: /^默认$/ })).toHaveAttribute('aria-checked', 'true');
+ },
+};
// Real path: a floating WorkHub progress card → edit its composer → open the model picker.
export const ProgressModelPicker: Story = {
render: () => <Surface progress />,
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 55ece72b13..5c33c68720 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -34,6 +34,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/app-shell.tsx` | shell-chrome-or-panel | AppShell | aligned — uses Astryx (AppShell) | aligned |
| `apps/desktop/src/renderer/app.tsx` | other | Theme | aligned — uses Astryx (Theme) | aligned |
| `apps/desktop/src/renderer/application/contracts/feature-services.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel.tsx` | shell-chrome-or-panel | Banner, Button, EmptyState, Heading, Section, Text, VStack | aligned — uses Astryx (Banner, Button, EmptyState, Heading, Section, Text, VStack) | aligned |
| `apps/desktop/src/renderer/application/contracts/settings-presentation/runtime-host-settings-target.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/application/contracts/settings-presentation/settings-route-header.tsx` | other | HStack, Heading, IconButton, Text, Toolbar, VStack | aligned — uses Astryx (HStack, Heading, IconButton, Text, Toolbar, VStack) | aligned |
| `apps/desktop/src/renderer/application/contracts/settings-presentation/settings-section.tsx` | other | Divider, HStack, Heading, Item, Text, VStack | aligned — uses Astryx (Divider, HStack, Heading, Item, Text, VStack) | aligned |
@@ -102,7 +103,6 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx` | shell-chrome-or-panel | Banner, Button, CodeBlock, Spinner | aligned — uses Astryx (Banner, Button, CodeBlock, Spinner) | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx` | shell-chrome-or-panel | EmptyState, IconButton, TextInput, Toolbar, Tooltip | aligned — uses Astryx (EmptyState, IconButton, TextInput, Toolbar, Tooltip) | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
-| `apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx` | shell-chrome-or-panel | Banner, Button, EmptyState, Heading, Section, Text, VStack | aligned — uses Astryx (Banner, Button, EmptyState, Heading, Section, Text, VStack) | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx` | shell-chrome-or-panel | Banner, Button, Collapsible, CollapsibleGroup, EmptyState, HStack, Section, Skeleton, Text, VStack | aligned — uses Astryx (Banner, Button, Collapsible, CollapsibleGroup, EmptyState, HStack, Section, Skeleton) | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx` | shell-chrome-or-panel | Banner | aligned — uses Astryx (Banner) | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx` | shell-chrome-or-panel | Banner, EmptyState | aligned — uses Astryx (Banner, EmptyState) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 66e55bfa33..b2464d002a 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -5,6 +5,7 @@ apps/desktop/src/renderer/app-shell-overlays.tsx
apps/desktop/src/renderer/app-shell.tsx
apps/desktop/src/renderer/app.tsx
apps/desktop/src/renderer/application/contracts/feature-services.tsx
+apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel.tsx
apps/desktop/src/renderer/application/contracts/settings-presentation/runtime-host-settings-target.tsx
apps/desktop/src/renderer/application/contracts/settings-presentation/settings-route-header.tsx
apps/desktop/src/renderer/application/contracts/settings-presentation/settings-section.tsx
@@ -73,7 +74,6 @@ apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-regi
apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx
apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx
apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx
-apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx
apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx
apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx
diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts
index 99f3ccd020..d3cb2d92ce 100644
--- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts
+++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts
@@ -2441,6 +2441,49 @@ test('cold WorkHub recovery waits for Desktop tools across pending-message and a
kind: 'routing',
disposition: 'answer_here',
});
+ if (crashCut === 'pending-message') {
+ // The recovered WorkHub keeps its permanent Session but new turns
+ // must follow the current switch rather than its creation-time default.
+ const currentPolicy = await openInteractiveRuntimePolicyStoresForWrite(owner.lease);
+ const originalMode = (await recoveredStores.sessionStore.readHeader(sessionId)).toolMode;
+ for (const enabled of [true, false]) {
+ const snapshot = await currentPolicy.runtimePolicy.getSnapshot();
+ const changed = await currentPolicy.runtimePolicy.mutate({
+ expectedRevision: snapshot.revision,
+ operation: {
+ kind: 'set_chat_defaults',
+ value: { ...snapshot.policy.chatDefaults, codeModeEnabled: enabled },
+ },
+ });
+ assert.equal(changed.kind, 'committed');
+ const turnId = randomUUID();
+ const started = await composition.handlers['workhub.coordination.answer'](
+ { turnId, text: `Code Mode ${enabled ? 'on' : 'off'}` },
+ context,
+ );
+ assert.ok(started.ok, JSON.stringify(started));
+ const query = await composition.handlers['turn.query']({ sessionId, turnId }, context);
+ assert.ok(query.ok, JSON.stringify(query));
+ const terminal = await waitForTerminal(
+ composition,
+ sessionId,
+ turnId,
+ query.result,
+ context,
+ );
+ assert.equal(terminal.status, 'completed');
+ const run = await readInvocation(recoveredStores, sessionId, terminal.runId!);
+ assert.equal(run.opening.configuration.toolMode, enabled ? 'code_mode' : 'direct');
+ assert.equal(
+ responsesToolNames(provider.requests.at(-1)?.body).includes('exec'),
+ enabled,
+ );
+ assert.equal(
+ (await recoveredStores.sessionStore.readHeader(sessionId)).toolMode,
+ originalMode,
+ );
+ }
+ }
assert.equal(drained, false);
} finally {
await composition?.close();
diff --git a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts
index e2ede9da6d..1ae7df98f1 100644
--- a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts
+++ b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts
@@ -21,6 +21,10 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import type { MakaTool } from '@maka/runtime/tool-runtime';
import { buildBuiltinTools } from '@maka/runtime/builtin-tools';
+import { AiSdkBackend } from '@maka/runtime/ai-sdk-backend';
+import { createBypassExecutionBoundary } from '@maka/core/sandbox-boundary';
+import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session';
+import type { SessionEvent } from '@maka/core/events';
import { z } from 'zod';
import { decodeHostedExecutionStartInput } from '../protocol/index.js';
import {
@@ -136,7 +140,7 @@ test('the WorkHub coordination profile has conversational authority but zero too
assert.deepEqual(projectHostedExecutionTools([productTool], 'workhub-coordination-v1'), []);
});
-test('WorkHub v2 can read its attachments without inheriting terminal, browser, or filesystem access', async () => {
+test('WorkHub v2 keeps its attachment-only tool ceiling visible in direct and Code Mode', async () => {
const makeTool = (name: string): MakaTool => ({
name,
description: name,
@@ -201,6 +205,119 @@ test('WorkHub v2 can read its attachments without inheriting terminal, browser,
assert.match(prompt, /only when the user explicitly asks to create new work/u);
assert.match(prompt, /never implies create_new/u);
assert.match(prompt, /ordinary request to continue work is routing, not a linked resume/u);
+
+ let providerCatalog = '';
+ const backend = new AiSdkBackend({
+ sessionId: WORKHUB_COORDINATION_SESSION_ID,
+ header: {
+ id: WORKHUB_COORDINATION_SESSION_ID,
+ workspaceRoot: '/workspace',
+ cwd: '/workspace',
+ createdAt: 1,
+ name: 'WorkHub',
+ titleIsManual: true,
+ isFlagged: false,
+ labels: [],
+ isArchived: false,
+ status: 'active',
+ statusUpdatedAt: 1,
+ hasUnread: false,
+ backend: 'ai-sdk',
+ llmConnectionSlug: 'test',
+ connectionLocked: true,
+ model: 'test',
+ permissionMode: 'bypass',
+ toolProfile: 'workhub-coordination-v2',
+ toolMode: 'code_mode',
+ schemaVersion: 1,
+ },
+ connection: {
+ slug: 'test',
+ providerType: 'openai',
+ defaultModel: 'test',
+ },
+ apiKey: 'test',
+ modelId: 'test',
+ tools: [...projected],
+ maxSteps: 1,
+ readExecutionBoundary: async () => createBypassExecutionBoundary(0),
+ readPermissionMode: async () => 'bypass',
+ modelFactory: () => ({
+ specificationVersion: 'v4',
+ provider: 'test',
+ modelId: 'test',
+ supportedUrls: {},
+ doStream: async ({ tools: modelTools }: { tools?: unknown }) => {
+ providerCatalog = JSON.stringify(modelTools);
+ return {
+ stream: new ReadableStream({
+ start(controller) {
+ controller.enqueue({ type: 'stream-start', warnings: [] });
+ controller.enqueue({
+ type: 'tool-call',
+ toolCallId: 'exec-1',
+ toolName: 'exec',
+ input: JSON.stringify({
+ code: `return {
+ names: Object.keys(tools).sort(),
+ allowed: ['Read', 'mcp__desktop_workhub__control'].map(name =>
+ [name in tools, typeof tools[name]]),
+ forbidden: ['Bash', 'Write', 'mcp__desktop_browser__browser_navigate'].map(name =>
+ [name in tools, typeof tools[name]]),
+ result: await tools.mcp__desktop_workhub__control({})
+ };`,
+ }),
+ });
+ controller.enqueue({
+ type: 'finish',
+ finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
+ usage: { inputTokens: { total: 0 }, outputTokens: { total: 0 } },
+ });
+ controller.close();
+ },
+ }),
+ };
+ },
+ }),
+ });
+ const events: SessionEvent[] = [];
+ try {
+ for await (const event of backend.send({
+ turnId: 'code-mode-ceiling',
+ text: 'Inspect tools',
+ context: [],
+ })) {
+ events.push(event);
+ }
+ } finally {
+ await backend.dispose();
+ }
+ for (const name of projected.map((tool) => tool.name)) assert.ok(providerCatalog.includes(name));
+ assert.doesNotMatch(providerCatalog, /Bash|Write|mcp__desktop_browser__browser_navigate/u);
+ const result = events.find(
+ (event) => event.type === 'tool_result' && event.toolUseId === 'exec-1',
+ );
+ assert.ok(result?.type === 'tool_result', JSON.stringify(events));
+ assert.deepEqual(result.content, {
+ kind: 'json',
+ value: {
+ ok: true,
+ value: {
+ names: projected.map((tool) => tool.name).sort(),
+ allowed: [
+ [true, 'function'],
+ [true, 'function'],
+ ],
+ forbidden: [
+ [false, 'undefined'],
+ [false, 'undefined'],
+ [false, 'undefined'],
+ ],
+ result: control.name,
+ },
+ toolCalls: [{ index: 1, name: control.name }],
+ },
+ });
});
test('WorkHub routing prompt binds the exact recalled candidate without granting authority', () => {
diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts
index 409d09698e..8512a27443 100644
--- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts
@@ -629,6 +629,7 @@ test('WorkHub model authority preserves its execution policy and uses versioned
});
const input = {
expectedRevision: fixture.revision(),
+ thinkingLevel: null,
modelTarget: {
kind: 'explicit' as const,
connectionId: 'connection-1',
@@ -650,6 +651,43 @@ test('WorkHub model authority preserves its execution policy and uses versioned
assert.equal(corrupt.revision(), 3);
});
+test('WorkHub thinking level persists, clears to default and rejects unsupported levels', async () => {
+ const fixture = createFixture({
+ header: {
+ id: WORKHUB_COORDINATION_SESSION_ID,
+ role: WORKHUB_COORDINATION_SESSION_ROLE,
+ toolProfile: 'workhub-coordination-v2',
+ permissionMode: 'bypass',
+ },
+ connection: {
+ providerType: 'openai-compatible',
+ modelOverrides: { 'model-1': { thinkingLevels: ['low', 'high'] } },
+ },
+ });
+ const modelTarget = {
+ kind: 'explicit' as const,
+ connectionId: 'connection-1',
+ connectionSlug: 'test',
+ model: 'model-1',
+ };
+ const set = (thinkingLevel: 'low' | 'high' | 'xhigh' | null) =>
+ fixture.coordinator.configureWorkHubModel({
+ expectedRevision: fixture.revision(),
+ modelTarget,
+ thinkingLevel,
+ });
+ assert.equal((await set('high')).ok, true);
+ assert.equal(fixture.header().thinkingLevel, 'high');
+ const revision = fixture.revision();
+ assert.equal((await set('xhigh')).ok, false);
+ assert.equal(fixture.revision(), revision);
+ assert.equal(fixture.header().thinkingLevel, 'high');
+ assert.equal((await set(null)).ok, true);
+ assert.equal(fixture.header().thinkingLevel, undefined);
+ assert.equal(fixture.header().permissionMode, 'bypass');
+ assert.equal(fixture.header().toolProfile, 'workhub-coordination-v2');
+});
+
test('ordinary metadata and configuration reject a corrupt Coordination role on another identity', async () => {
const corrupt = {
...sessionHeader('session-1', ['user-label']),
diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts
index eafaffab63..0c981c433c 100644
--- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts
@@ -50,9 +50,10 @@ test('WorkHub Coordination resolve has a closed empty input and bounded identity
);
});
-test('WorkHub model configuration only accepts a revision and explicit model identity', () => {
+test('WorkHub model configuration accepts thinking levels without widening its authority', () => {
const input = {
expectedRevision: 3,
+ thinkingLevel: null,
modelTarget: {
kind: 'explicit',
connectionId: 'connection-1',
@@ -61,9 +62,18 @@ test('WorkHub model configuration only accepts a revision and explicit model ide
},
};
assert.deepEqual(decodeWorkHubCoordinationConfigureModelInput(input), input);
+ for (const thinkingLevel of ['high', null]) {
+ assert.deepEqual(decodeWorkHubCoordinationConfigureModelInput({ ...input, thinkingLevel }), {
+ ...input,
+ thinkingLevel,
+ });
+ }
for (const invalid of [
+ { expectedRevision: input.expectedRevision, modelTarget: input.modelTarget },
+ { ...input, thinkingLevel: undefined },
{ ...input, sessionId: 'another-session' },
{ ...input, permissionMode: 'bypass' },
+ { ...input, thinkingLevel: 'extreme' },
{ ...input, expectedRevision: -1 },
{ ...input, modelTarget: { kind: 'default' } },
])
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index b1f16e7e5b..10235f989e 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -101,7 +101,7 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
// Increment when the same protocol version no longer guarantees safe Client-Host
// interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 154 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 155 as const;
// 154: External Session import results distinguish committed Sessions from typed source limits.
// 153: Sessions may select plugin executors and Plugin Platform queries expose them.
// 152: Assistant completions and transcript rows preserve interrupted responses.
diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts
index d3c24206fc..730520a288 100644
--- a/packages/runtime-host/src/protocol/workhub-coordination.ts
+++ b/packages/runtime-host/src/protocol/workhub-coordination.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import type { ThinkingLevel } from '@maka/core/model-thinking';
import type { AttachmentRef } from '@maka/core/events';
import { isWorkHubActionResult, type WorkHubActionResult } from '@maka/core/workhub-action-result';
import { decodeMessageContent } from './turn.js';
@@ -54,6 +55,7 @@ import {
export interface WorkHubCoordinationConfigureModelInput {
readonly expectedRevision: number;
readonly modelTarget: Extract<SessionModelTarget, { readonly kind: 'explicit' }>;
+ readonly thinkingLevel: ThinkingLevel | null;
}
export function decodeWorkHubCoordinationConfigureModelInput(
@@ -62,15 +64,20 @@ export function decodeWorkHubCoordinationConfigureModelInput(
const input = requireExactRecord(value, 'WorkHub model configuration', [
'expectedRevision',
'modelTarget',
+ 'thinkingLevel',
]);
const decoded = decodeSessionConfigurationUpdateInput({
sessionId: WORKHUB_COORDINATION_SESSION_ID,
expectedRevision: input.expectedRevision,
- patch: { modelTarget: input.modelTarget },
+ patch: {
+ modelTarget: input.modelTarget,
+ thinkingLevel: input.thinkingLevel,
+ },
});
return {
expectedRevision: decoded.expectedRevision,
modelTarget: decoded.patch.modelTarget!,
+ thinkingLevel: decoded.patch.thinkingLevel ?? null,
};
}
@@ -255,7 +262,10 @@ export const WORKHUB_COORDINATION_OPERATION_SPECS = {
{
sessionId: WORKHUB_COORDINATION_SESSION_ID,
expectedRevision: input.expectedRevision,
- patch: { modelTarget: input.modelTarget },
+ patch: {
+ modelTarget: input.modelTarget,
+ thinkingLevel: input.thinkingLevel,
+ },
},
output,
),
diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts
index c07bf840bb..962bba453c 100644
--- a/packages/runtime-host/src/server/execution-composition.ts
+++ b/packages/runtime-host/src/server/execution-composition.ts
@@ -1368,6 +1368,15 @@ export async function createExecutionRuntimeHostComposition(
},
}),
runBackendActivation: (operation) => runtimePolicyActivation.runBackendActivation(operation),
+ resolveFreshTurnToolMode: async (header) => {
+ // WorkHub keeps one permanent Session, so creation-time defaults cannot
+ // track this setting. Snapshot it per turn without rewriting that Session.
+ if (header.id !== WORKHUB_COORDINATION_SESSION_ID) return undefined;
+ return (await runtimePolicyStores.runtimePolicy.getSnapshot()).policy.chatDefaults
+ .codeModeEnabled
+ ? 'code_mode'
+ : 'direct';
+ },
messageAuthority: runtimeAuthority,
hostedAgentGraphExecution: {
readAgentGraphIntentClaim: (graphId, intentId) =>
diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts
index bb52aaa743..adf3e743b8 100644
--- a/packages/runtime-host/src/server/session-catalog-coordinator.ts
+++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts
@@ -715,7 +715,10 @@ export class HostSessionCatalogCoordinator {
{
sessionId: WORKHUB_COORDINATION_SESSION_ID,
expectedRevision: input.expectedRevision,
- patch: { modelTarget: input.modelTarget },
+ patch: {
+ modelTarget: input.modelTarget,
+ thinkingLevel: input.thinkingLevel,
+ },
},
'workhub',
);
diff --git a/packages/runtime/src/__tests__/code-mode-backend.test.ts b/packages/runtime/src/__tests__/code-mode-backend.test.ts
index f6de93fb99..25b16d45de 100644
--- a/packages/runtime/src/__tests__/code-mode-backend.test.ts
+++ b/packages/runtime/src/__tests__/code-mode-backend.test.ts
@@ -601,7 +601,7 @@ test('keeps direct-only tools out of the cell snapshot', async () => {
(event): event is Extract<SessionEvent, { type: 'tool_result' }> =>
event.type === 'tool_result' && event.toolUseId === 'exec-1',
);
- assert.match(JSON.stringify(execResult?.content), /unknown_tool/);
+ assert.match(JSON.stringify(execResult?.content), /execution_error/);
});
test('keeps provider-native tools out of the cell snapshot', async () => {
@@ -638,7 +638,7 @@ test('keeps provider-native tools out of the cell snapshot', async () => {
(event): event is Extract<SessionEvent, { type: 'tool_result' }> =>
event.type === 'tool_result' && event.toolUseId === 'exec-1',
);
- assert.match(JSON.stringify(execResult?.content), /unknown_tool/);
+ assert.match(JSON.stringify(execResult?.content), /execution_error/);
});
test('validates nested arguments before ToolRuntime implementation dispatch', async () => {
diff --git a/packages/runtime/src/__tests__/code-mode.test.ts b/packages/runtime/src/__tests__/code-mode.test.ts
index 9c02e81320..bd2b7d5d0c 100644
--- a/packages/runtime/src/__tests__/code-mode.test.ts
+++ b/packages/runtime/src/__tests__/code-mode.test.ts
@@ -271,7 +271,7 @@ test('uses normal partial-execution semantics before an unknown tool failure', a
assert.deepEqual(calls, ['allowed']);
assert.equal(result.ok, false);
- if (!result.ok) assert.equal(result.error.kind, 'unknown_tool');
+ if (!result.ok) assert.equal(result.error.kind, 'execution_error');
});
test('does not dispatch tools inherited from Object.prototype', async () => {
@@ -289,7 +289,7 @@ test('does not dispatch tools inherited from Object.prototype', async () => {
try {
const result = await execute('return await tools.inheritedCodeModeTool({});');
- assert.equal(result.ok ? undefined : result.error.kind, 'unknown_tool');
+ assert.equal(result.ok ? undefined : result.error.kind, 'execution_error');
assert.equal(inheritedCalls, 0);
} finally {
delete (Object.prototype as Record<string, unknown>).inheritedCodeModeTool;
diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts
index 584126dad2..40fdd19e9a 100644
--- a/packages/runtime/src/__tests__/session-manager.test.ts
+++ b/packages/runtime/src/__tests__/session-manager.test.ts
@@ -5891,6 +5891,59 @@ describe('SessionManager permission mode updates', () => {
assert.strictEqual(plan.continuation?.sourceRunId, 'source-run-newer');
});
+ test('fresh-turn tool policy changes only new runs and preserves Session defaults', async () => {
+ const store = new MemorySessionStore();
+ const runStore = new MemoryAgentRunStore();
+ const backends = new BackendRegistry();
+ const instances = new Map<string, TestBackend>();
+ const gate = makeGate();
+ let mode: ToolMode = 'code_mode';
+ let dynamicSessionId: string | undefined;
+ backends.register('ai-sdk', (ctx) => {
+ const backend = new TestBackend(ctx, gate);
+ instances.set(ctx.sessionId, backend);
+ return backend;
+ });
+ const manager = new SessionManager({
+ store,
+ runStore,
+ runtimeEventStore: runStore,
+ backends,
+ newId: nextId(),
+ now: nextNow(6_450),
+ resolveFreshTurnToolMode: async (header) =>
+ header.id === dynamicSessionId ? mode : undefined,
+ });
+ const dynamic = await manager.createSession({ ...makeInput(), toolMode: 'direct' });
+ const ordinary = await manager.createSession({ ...makeInput(), toolMode: 'code_mode' });
+ dynamicSessionId = dynamic.id;
+ const active = manager
+ .sendMessage(dynamic.id, { turnId: 'dynamic-1', text: 'first' })
+ [Symbol.asyncIterator]();
+ await active.next();
+ mode = 'direct';
+ assert.equal(instances.get(dynamic.id)?.sendInputs[0]?.toolMode, 'code_mode');
+ gate.release();
+ while (!(await active.next()).done) {
+ /* Drain the running turn. */
+ }
+ await drainAll(manager.sendMessage(dynamic.id, { turnId: 'dynamic-2', text: 'second' }));
+ await drainAll(manager.sendMessage(ordinary.id, { turnId: 'ordinary-1', text: 'ordinary' }));
+ assert.deepEqual(
+ instances.get(dynamic.id)?.sendInputs.map((input) => input.toolMode),
+ ['code_mode', 'direct'],
+ );
+ assert.equal(instances.get(ordinary.id)?.sendInputs[0]?.toolMode, 'code_mode');
+ assert.equal((await store.readHeader(dynamic.id)).toolMode, 'direct');
+ assert.equal((await store.readHeader(ordinary.id)).toolMode, 'code_mode');
+ assert.deepEqual(
+ (await runStore.listSessionInvocations(dynamic.id)).map(
+ (run) => run.opening.configuration.toolMode,
+ ),
+ ['code_mode', 'direct'],
+ );
+ });
+
test('RuntimeKernel drives the backend while preserving the SessionEvent stream', async () => {
const store = new MemorySessionStore();
const runStore = new MemoryAgentRunStore();
@@ -6417,6 +6470,7 @@ describe('SessionManager permission mode updates', () => {
runStore,
runtimeEventStore: runStore,
toolBoundaryProtocol: 't1_after_preflight_v1',
+ resolveFreshTurnToolMode: async () => 'direct',
backends,
childTools: [testTool('Read')],
inspectContinuationSafety: async () => ({
@@ -6658,6 +6712,8 @@ describe('SessionManager permission mode updates', () => {
provenance: 'runtime',
providerStateIdentity,
});
+ assert.equal(followUpRun?.opening.configuration.toolMode, 'direct');
+ assert.equal(backend?.sendInputs.at(-1)?.toolMode, 'direct');
});
test('authenticates the exact target-aware continuation projection that reaches the provider', async () => {
@@ -13890,6 +13946,7 @@ class MemorySessionStore implements SessionStore {
model: input.model ?? 'fake-model',
...(input.thinkingLevel !== undefined ? { thinkingLevel: input.thinkingLevel } : {}),
permissionMode: input.permissionMode,
+ ...(input.toolMode !== undefined ? { toolMode: input.toolMode } : {}),
collaborationMode: input.collaborationMode ?? 'agent',
orchestrationMode: input.orchestrationMode ?? 'default',
transcriptLedgerVersion: 1,
diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts
index 2308947335..a98b858948 100644
--- a/packages/runtime/src/runtime-kernel.ts
+++ b/packages/runtime/src/runtime-kernel.ts
@@ -287,6 +287,8 @@ export interface RuntimeKernelDeps {
safeBoundaryResumeEnabled?: boolean;
continuationFailpoint?: (point: RuntimeContinuationFailpoint) => Promise<void>;
runBackendActivation?: BackendActivationBoundary;
+ /** Host policy for a fresh turn; continuations retain their invocation snapshot. */
+ resolveFreshTurnToolMode?: (header: SessionHeader) => Promise<ToolMode | undefined>;
/** Hosted composition capability. When present, the Host owns all message queues. */
messageAuthority?: RuntimeMessageAuthority;
/** Hosted composition capability. Omit for embedded interaction ownership. */
@@ -685,6 +687,7 @@ export class RuntimeKernel implements RuntimeKernelLike {
const run = new AgentRun({
sessionId,
header,
+ effectiveToolMode: await this.deps.resolveFreshTurnToolMode?.(header),
userInput: input,
runId: options.runId,
userMessageId: options.userMessageId,
diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts
index 214c6efc2c..1e241812b9 100644
--- a/packages/runtime/src/session-manager.ts
+++ b/packages/runtime/src/session-manager.ts
@@ -71,7 +71,7 @@ import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
import type { UserQuestionResponse } from '@maka/core/user-question';
import type { PermissionMode } from '@maka/core/permission';
import { isCanonicalReadOnlyPermissionProfile } from '@maka/core/permission-profile';
-import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode';
+import { DEFAULT_TOOL_MODE, type ToolMode } from '@maka/core/tool-mode';
import type {
CreateSandboxBoundaryRequest,
ExecutionBoundary,
@@ -846,6 +846,8 @@ interface SessionManagerBaseDeps {
inspectContinuationSafety?: (sessionId: string) => Promise<RuntimeContinuationSafetyObservation>;
continuationFailpoint?: (point: RuntimeContinuationFailpoint) => Promise<void>;
runBackendActivation?: BackendActivationBoundary;
+ /** Host policy for a fresh turn; continuations retain their invocation snapshot. */
+ resolveFreshTurnToolMode?: (header: SessionHeader) => Promise<ToolMode | undefined>;
safeBoundaryResumeEnabled?: boolean;
/** Hosted composition capability. Omit for the production embedded queue. */
messageAuthority?: RuntimeMessageAuthority;
diff --git a/packages/ui/src/chat-model-switcher.tsx b/packages/ui/src/chat-model-switcher.tsx
index f107ed7325..c654985b5d 100644
--- a/packages/ui/src/chat-model-switcher.tsx
+++ b/packages/ui/src/chat-model-switcher.tsx
@@ -99,7 +99,7 @@ export function ThinkingLevelSelector(props: {
<DropdownMenu
placement="above"
hasChevron={false}
- className="maka-composer-quiet-menu"
+ className="maka-composer-quiet-menu maka-thinking-level-menu"
button={{
label: currentLabel,
variant: 'ghost',
diff --git a/patches/@maka+runtime++@ai-sdk+code-mode+1.0.56.patch b/patches/@maka+runtime++@ai-sdk+code-mode+1.0.56.patch
index 7e185cfa33..016ec09876 100644
--- a/patches/@maka+runtime++@ai-sdk+code-mode+1.0.56.patch
+++ b/patches/@maka+runtime++@ai-sdk+code-mode+1.0.56.patch
@@ -12,7 +12,7 @@ index b317e9d..5beb1c2 100644
timeoutMs?: number;
/** @defaultValue `64 * 1024 * 1024` */
diff --git a/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/src/run-code-mode.ts b/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/src/run-code-mode.ts
-index 95435bd..fb7ba60 100644
+index 95435bd..d69b159 100644
--- a/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/src/run-code-mode.ts
+++ b/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/src/run-code-mode.ts
@@ -159,6 +159,7 @@
@@ -31,7 +31,41 @@ index 95435bd..fb7ba60 100644
memoryLimitBytes: policy.memoryLimitBytes ?? DEFAULT_MEMORY_LIMIT_BYTES,
maxStackSizeBytes: policy.maxStackSizeBytes ?? DEFAULT_STACK_LIMIT_BYTES,
maxResultBytes: policy.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES,
-@@ -363,6 +365,7 @@
+@@ -202,13 +204,11 @@
+ }
+
+ function createCodeModeSource(js: string, toolNames: string[]): string {
+- const bindings = Object.fromEntries(
+- toolNames.map((toolName, index) => [toolName, `__codeMode.tool${index}`]),
+- );
+- const bindingSource = Object.entries(bindings)
+- .map(([name, reference]) => `${JSON.stringify(name)}:${reference}`)
++ // Expose only the active catalog: unknown names must not look callable.
++ const bindingSource = toolNames
++ .map((name, index) => `[${JSON.stringify(name)}]:(input)=>__codeMode.tool${index}(input)`)
+ .join(',');
+- return `const __codeModeBindings={${bindingSource}};const tools=new Proxy(Object.create(null),{get(_target,name){const binding=__codeModeBindings[name];return typeof binding==="function"?(input)=>binding(input):(input)=>__codeMode.missing(String(name),input);}});const __codeModeResult=await(async()=>{\n${js}\n})();if(__codeModeResult===undefined)return undefined;return JSON.parse(JSON.stringify(__codeModeResult));`;
++ return `const tools=Object.freeze({__proto__:null,${bindingSource}});const __codeModeResult=await(async()=>{\n${js}\n})();if(__codeModeResult===undefined)return undefined;return JSON.parse(JSON.stringify(__codeModeResult));`;
+ }
+
+ function createHostFunctions({
+@@ -236,15 +236,6 @@
+ toolName,
+ });
+ }
+- group.missing = async (toolName: unknown, toolInput: unknown) =>
+- await invokeCodeModeTool({
+- codeModeErrors,
+- input,
+- outerToolCallId,
+- policy,
+- toolInput,
+- toolName: String(toolName),
+- });
+ return { __codeMode: group } as HostFunctions;
+ }
+
+@@ -363,6 +354,7 @@
): RunLimits {
return {
timeoutMs: policy.timeoutMs,
@@ -39,6 +73,28 @@ index 95435bd..fb7ba60 100644
memoryLimitBytes: policy.memoryLimitBytes,
maxStackSizeBytes: policy.maxStackSizeBytes,
maxResultBytes: expandedSerializationLimit(policy.maxResultBytes),
+@@ -545,10 +537,7 @@
+ interruptId: `${toolCallId}:interrupt`,
+ toolName,
+ toolCallId,
+- input:
+- interruption.hostFunctionName === '__codeMode.missing'
+- ? interruption.arguments[1]
+- : interruption.arguments[0],
++ input: interruption.arguments[0],
+ payload: assertInterruptPayload(interruption.payload),
+ };
+ });
+@@ -568,9 +557,6 @@
+ interruption: RunInterruption,
+ toolNames: string[],
+ ): string {
+- if (interruption.hostFunctionName === '__codeMode.missing') {
+- return String(interruption.arguments[0]);
+- }
+ const match = /^__codeMode\.tool(\d+)$/u.exec(interruption.hostFunctionName);
+ const toolName = match === null ? undefined : toolNames[Number(match[1])];
+ if (toolName === undefined) {
diff --git a/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/dist/index.d.ts b/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/dist/index.d.ts
index 9cafdbb..3c924f1 100644
--- a/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/dist/index.d.ts
@@ -53,7 +109,7 @@ index 9cafdbb..3c924f1 100644
timeoutMs?: number;
/** @defaultValue `64 * 1024 * 1024` */
diff --git a/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/dist/run-code-mode.js b/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/dist/run-code-mode.js
-index 257b976..8095276 100644
+index 257b976..67d8409 100644
--- a/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/dist/run-code-mode.js
+++ b/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/dist/run-code-mode.js
@@ -117,6 +117,7 @@
@@ -64,7 +120,41 @@ index 257b976..8095276 100644
memoryLimitBytes: policy.memoryLimitBytes ?? DEFAULT_MEMORY_LIMIT_BYTES,
maxStackSizeBytes: policy.maxStackSizeBytes ?? DEFAULT_STACK_LIMIT_BYTES,
maxResultBytes: policy.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES,
-@@ -260,6 +261,7 @@
+@@ -138,13 +139,11 @@
+ }
+ }
+ function createCodeModeSource(js, toolNames) {
+- const bindings = Object.fromEntries(
+- toolNames.map((toolName, index) => [toolName, `__codeMode.tool${index}`])
+- );
+- const bindingSource = Object.entries(bindings).map(([name, reference]) => `${JSON.stringify(name)}:${reference}`).join(",");
+- return `const __codeModeBindings={${bindingSource}};const tools=new Proxy(Object.create(null),{get(_target,name){const binding=__codeModeBindings[name];return typeof binding==="function"?(input)=>binding(input):(input)=>__codeMode.missing(String(name),input);}});const __codeModeResult=await(async()=>{
+-${js}
+-})();if(__codeModeResult===undefined)return undefined;return JSON.parse(JSON.stringify(__codeModeResult));`;
++ // Expose only the active catalog: unknown names must not look callable.
++ const bindingSource = toolNames
++ .map((name, index) => `[${JSON.stringify(name)}]:(input)=>__codeMode.tool${index}(input)`)
++ .join(",");
++ return `const tools=Object.freeze({__proto__:null,${bindingSource}});const __codeModeResult=await(async()=>{\n${js}\n})();if(__codeModeResult===undefined)return undefined;return JSON.parse(JSON.stringify(__codeModeResult));`;
+ }
+ function createHostFunctions({
+ codeModeErrors,
+@@ -164,14 +163,6 @@
+ toolName
+ });
+ }
+- group.missing = async (toolName, toolInput) => await invokeCodeModeTool({
+- codeModeErrors,
+- input,
+- outerToolCallId,
+- policy,
+- toolInput,
+- toolName: String(toolName)
+- });
+ return { __codeMode: group };
+ }
+ async function invokeCodeModeTool({
+@@ -260,6 +251,7 @@
function toRunLimits(policy, source, userSource) {
return {
timeoutMs: policy.timeoutMs,
@@ -72,3 +162,22 @@ index 257b976..8095276 100644
memoryLimitBytes: policy.memoryLimitBytes,
maxStackSizeBytes: policy.maxStackSizeBytes,
maxResultBytes: expandedSerializationLimit(policy.maxResultBytes),
+@@ -402,7 +394,7 @@
+ interruptId: `${toolCallId}:interrupt`,
+ toolName,
+ toolCallId,
+- input: interruption.hostFunctionName === "__codeMode.missing" ? interruption.arguments[1] : interruption.arguments[0],
++ input: interruption.arguments[0],
+ payload: assertInterruptPayload(interruption.payload)
+ };
+ });
+@@ -417,9 +409,6 @@
+ return Number(match[1]);
+ }
+ function toolNameForInterruption(interruption, toolNames) {
+- if (interruption.hostFunctionName === "__codeMode.missing") {
+- return String(interruption.arguments[0]);
+- }
+ const match = /^__codeMode\.tool(\d+)$/u.exec(interruption.hostFunctionName);
+ const toolName = match === null ? void 0 : toolNames[Number(match[1])];
+ if (toolName === void 0) {
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org