From f5bfec80c467a0763f3a1d4593b3adb292ea9331 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Mon, 7 Sep 2026 15:02:18 +0800 Subject: [PATCH] feat(web): integrate terminal details, thinking state and unarchive Retain reviewed contributions from #389, #375 and the unarchive slice of #361, preserving the current Pi trust projection. Add integration coverage for authenticated reads and persisted unarchive behavior. Co-authored-by: testikun <320479488+testikun@users.noreply.github.com> Co-authored-by: seekskyworld --- extensions/background-terminals/index.ts | 7 + extensions/shared/web-observer-registry.ts | 182 +++++++++++++++++++++ tests/web/observer-registry.test.ts | 88 ++++++++++ tests/web/pi-adapter.test.ts | 35 ++++ tests/web/web-host.test.ts | 140 ++++++++++++++++ web/adapter/pi-adapter.ts | 8 + web/host/web-host.ts | 52 ++++++ web/runtime/pi-runtime.ts | 5 + web/runtime/types.ts | 1 + 9 files changed, 518 insertions(+) diff --git a/extensions/background-terminals/index.ts b/extensions/background-terminals/index.ts index 938186ea..7817340a 100644 --- a/extensions/background-terminals/index.ts +++ b/extensions/background-terminals/index.ts @@ -34,6 +34,7 @@ import { } from "../shared/tool-surface.ts"; import { completionOwnerFor } from "../shared/completion-inbox.ts"; import { + projectBackgroundTerminalDetail, projectBackgroundTerminalCapability, registerWebCapability, } from "../shared/web-observer-registry.ts"; @@ -139,6 +140,12 @@ export default function (pi: ExtensionAPI) { kind: "background-terminals", snapshot: () => projectBackgroundTerminalCapability(manager.view.list()), + detail: (id) => { + const terminal = manager.view.get(id); + return terminal + ? projectBackgroundTerminalDetail(terminal) + : undefined; + }, subscribe: (listener) => manager.view.subscribe(listener), }) : undefined; diff --git a/extensions/shared/web-observer-registry.ts b/extensions/shared/web-observer-registry.ts index 61aba682..f4e4b57d 100644 --- a/extensions/shared/web-observer-registry.ts +++ b/extensions/shared/web-observer-registry.ts @@ -6,6 +6,12 @@ export type WebCapabilityKind = export const WEB_MAX_CAPABILITY_ITEMS = 32; const WEB_MAX_ACTIVITY_TEXT = 160; const WEB_MAX_WORKFLOW_AGENTS_SCANNED = 1_024; +const WEB_MAX_CAPABILITY_ID = 160; +const WEB_MAX_TERMINAL_COMMAND_BYTES = 4 * 1024; +const WEB_MAX_TERMINAL_CWD_BYTES = 2 * 1024; +const WEB_MAX_TERMINAL_ERROR_BYTES = 2 * 1024; +const WEB_MAX_TERMINAL_STDOUT_BYTES = 16 * 1024; +const WEB_MAX_TERMINAL_STDERR_BYTES = 8 * 1024; export interface WebSubagentActivity { readonly id: string; @@ -43,6 +49,42 @@ export interface WebBackgroundTerminalActivity { readonly signal?: string; } +export interface WebBackgroundTerminalOutput { + readonly text: string; + readonly totalBytes: number; + readonly retainedBytes: number; + readonly omittedBytes: number; + readonly truncated: boolean; + readonly recoveryAvailable: boolean; +} + +export interface WebBackgroundTerminalDetail { + readonly kind: "background-terminals"; + readonly id: string; + readonly title: string; + readonly command: string; + readonly cwd: string; + readonly pid?: number; + readonly status: WebBackgroundTerminalActivity["status"]; + readonly createdAt: number; + readonly settledAt?: number; + readonly timeoutAt?: number; + readonly exitCode?: number; + readonly signal?: string; + readonly errorText?: string; + readonly stdout: WebBackgroundTerminalOutput; + readonly stderr: WebBackgroundTerminalOutput; + readonly truncated: boolean; +} + +export type WebCapabilityDetail = WebBackgroundTerminalDetail; + +export type WebCapabilityDetailReceipt = + | { readonly status: "found"; readonly detail: WebCapabilityDetail } + | { readonly status: "invalid" } + | { readonly status: "missing" } + | { readonly status: "unavailable" }; + export interface WebCapabilityProjection< Item = | WebSubagentActivity @@ -64,6 +106,7 @@ export interface WebCapabilitySnapshot { export interface WebCapabilityProvider { readonly kind: WebCapabilityKind; readonly snapshot: () => WebCapabilityProjection; + readonly detail?: (id: string) => WebCapabilityDetail | undefined; readonly subscribe?: (listener: () => void) => () => void; } @@ -82,6 +125,53 @@ function boundedActivityText(value: string): BoundedActivityText { }; } +interface BoundedUtf8Text { + readonly value: string; + readonly bytes: number; + readonly truncated: boolean; +} + +function boundedUtf8Tail(value: string, maxBytes: number): BoundedUtf8Text { + const encoded = new TextEncoder().encode(value); + if (encoded.byteLength <= maxBytes) { + return { value, bytes: encoded.byteLength, truncated: false }; + } + let start = encoded.byteLength - maxBytes; + while (start < encoded.byteLength && (encoded[start]! & 0xc0) === 0x80) { + start++; + } + const retained = encoded.slice(start); + return { + value: new TextDecoder().decode(retained), + bytes: retained.byteLength, + truncated: true, + }; +} + +function projectTerminalOutput( + source: { + readonly modelSafeText: string; + readonly totalBytes: number; + readonly truncatedBytes: number; + readonly spillPath?: string; + }, + maxBytes: number, +): WebBackgroundTerminalOutput { + const text = boundedUtf8Tail(source.modelSafeText, maxBytes); + const omittedBytes = Math.max( + source.truncatedBytes, + source.totalBytes - text.bytes, + ); + return { + text: text.value, + totalBytes: source.totalBytes, + retainedBytes: text.bytes, + omittedBytes, + truncated: text.truncated || source.truncatedBytes > 0, + recoveryAvailable: source.spillPath !== undefined, + }; +} + function newestActivityAt(value: { readonly createdAt?: number; readonly startedAt?: number; @@ -258,6 +348,77 @@ export function projectBackgroundTerminalCapability( }); } +export function projectBackgroundTerminalDetail(source: { + readonly id: string; + readonly title: string; + readonly command: string; + readonly cwd: string; + readonly pid?: number; + readonly status: WebBackgroundTerminalActivity["status"]; + readonly createdAt: number; + readonly settledAt?: number; + readonly timeoutAt?: number; + readonly exitCode?: number; + readonly signal?: string; + readonly errorText?: string; + readonly stdout: { + readonly modelSafeText: string; + readonly totalBytes: number; + readonly truncatedBytes: number; + readonly spillPath?: string; + }; + readonly stderr: { + readonly modelSafeText: string; + readonly totalBytes: number; + readonly truncatedBytes: number; + readonly spillPath?: string; + }; +}): WebBackgroundTerminalDetail { + const title = boundedActivityText(source.title); + const command = boundedUtf8Tail( + source.command, + WEB_MAX_TERMINAL_COMMAND_BYTES, + ); + const cwd = boundedUtf8Tail(source.cwd, WEB_MAX_TERMINAL_CWD_BYTES); + const signal = source.signal ? boundedActivityText(source.signal) : undefined; + const errorText = source.errorText + ? boundedUtf8Tail(source.errorText, WEB_MAX_TERMINAL_ERROR_BYTES) + : undefined; + const stdout = projectTerminalOutput( + source.stdout, + WEB_MAX_TERMINAL_STDOUT_BYTES, + ); + const stderr = projectTerminalOutput( + source.stderr, + WEB_MAX_TERMINAL_STDERR_BYTES, + ); + return { + kind: "background-terminals", + id: source.id, + title: title.value, + command: command.value, + cwd: cwd.value, + ...(source.pid !== undefined ? { pid: source.pid } : {}), + status: source.status, + createdAt: source.createdAt, + ...(source.settledAt !== undefined ? { settledAt: source.settledAt } : {}), + ...(source.timeoutAt !== undefined ? { timeoutAt: source.timeoutAt } : {}), + ...(source.exitCode !== undefined ? { exitCode: source.exitCode } : {}), + ...(signal ? { signal: signal.value } : {}), + ...(errorText ? { errorText: errorText.value } : {}), + stdout, + stderr, + truncated: + title.truncated || + command.truncated || + cwd.truncated || + signal?.truncated === true || + errorText?.truncated === true || + stdout.truncated || + stderr.truncated, + }; +} + /** The Pi SessionManager object itself is the capability-lifetime identity. */ export type WebCapabilityScope = object; @@ -385,6 +546,27 @@ export function webCapabilitySnapshot( ); } +export function webCapabilityDetail( + scope: WebCapabilityScope, + kind: WebCapabilityKind, + id: string, +): WebCapabilityDetailReceipt { + if ( + id.length === 0 || + id.length > WEB_MAX_CAPABILITY_ID || + /[\u0000-\u001f\u007f]/u.test(id) + ) { + return { status: "invalid" }; + } + const provider = providers.get(scope)?.get(kind); + if (!provider?.detail) return { status: "unavailable" }; + const detail = provider.detail(id); + if (!detail) return { status: "missing" }; + if (detail.kind !== kind || detail.id !== id) + return { status: "unavailable" }; + return { status: "found", detail }; +} + export function notifyWebCapabilities(scope: WebCapabilityScope) { for (const listener of listeners.keys()) listener(scope); } diff --git a/tests/web/observer-registry.test.ts b/tests/web/observer-registry.test.ts index 77bd89c4..bbc64ff7 100644 --- a/tests/web/observer-registry.test.ts +++ b/tests/web/observer-registry.test.ts @@ -8,11 +8,13 @@ import type { SessionManager } from "@earendil-works/pi-coding-agent"; import { notifyWebCapabilities, projectBackgroundTerminalCapability, + projectBackgroundTerminalDetail, projectSubagentCapability, projectWorkflowCapability, registerWebCapability, subscribeWebCapabilities, type WebCapabilityScope, + webCapabilityDetail, webCapabilitySnapshot, } from "../../extensions/shared/web-observer-registry.ts"; @@ -244,3 +246,89 @@ test("projects bounded canonical activity without private payloads", () => { }, ]); }); + +test("projects bounded terminal detail with exact identity and recovery evidence", () => { + const detail = projectBackgroundTerminalDetail({ + id: "bt-exact", + title: "dev server", + command: `prefix-${"c".repeat(5_000)}`, + cwd: `/workspace/${"d".repeat(3_000)}`, + pid: 42, + status: "failed", + createdAt: 1, + settledAt: 2, + exitCode: 1, + errorText: "e".repeat(3_000), + stdout: { + modelSafeText: `old-${"x".repeat(20_000)}-tail`, + totalBytes: 30_000, + truncatedBytes: 4_000, + spillPath: "/private/full-stdout.log", + }, + stderr: { + modelSafeText: "failure", + totalBytes: 7, + truncatedBytes: 0, + }, + }); + + assert.equal(detail.id, "bt-exact"); + assert.equal(detail.kind, "background-terminals"); + assert.equal(detail.command.endsWith("c".repeat(100)), true); + assert.equal(Buffer.byteLength(detail.command) <= 4 * 1024, true); + assert.equal(Buffer.byteLength(detail.cwd) <= 2 * 1024, true); + assert.equal(Buffer.byteLength(detail.errorText ?? "") <= 2 * 1024, true); + assert.equal(Buffer.byteLength(detail.stdout.text) <= 16 * 1024, true); + assert.equal(detail.stdout.text.endsWith("-tail"), true); + assert.equal(detail.stdout.omittedBytes > 0, true); + assert.equal(detail.stdout.recoveryAvailable, true); + assert.equal("spillPath" in detail.stdout, false); + assert.equal(detail.stderr.truncated, false); + assert.equal(detail.truncated, true); +}); + +test("detail lookup is Session-scoped, exact, and fail-closed", () => { + const scope = sessionScope(); + const otherScope = sessionScope(); + const detail = projectBackgroundTerminalDetail({ + id: "bt-1", + title: "server", + command: "run-server", + cwd: process.cwd(), + status: "running", + createdAt: 1, + stdout: { + modelSafeText: "ready", + totalBytes: 5, + truncatedBytes: 0, + }, + stderr: { modelSafeText: "", totalBytes: 0, truncatedBytes: 0 }, + }); + const unregister = registerWebCapability(scope, { + kind: "background-terminals", + snapshot: () => ({ items: [], omitted: 0, truncated: false }), + detail: (id) => (id === detail.id ? detail : undefined), + }); + try { + assert.deepEqual( + webCapabilityDetail(scope, "background-terminals", "bt-1"), + { + status: "found", + detail, + }, + ); + assert.deepEqual( + webCapabilityDetail(scope, "background-terminals", "bt-missing"), + { status: "missing" }, + ); + assert.deepEqual( + webCapabilityDetail(otherScope, "background-terminals", "bt-1"), + { status: "unavailable" }, + ); + assert.deepEqual(webCapabilityDetail(scope, "background-terminals", ""), { + status: "invalid", + }); + } finally { + unregister(); + } +}); diff --git a/tests/web/pi-adapter.test.ts b/tests/web/pi-adapter.test.ts index 32e770f3..60be746f 100644 --- a/tests/web/pi-adapter.test.ts +++ b/tests/web/pi-adapter.test.ts @@ -682,3 +682,38 @@ test("Session provenance targets the current file even when a copied file retain await rm(root, { recursive: true, force: true }); } }); + +test("unarchive is idempotent across restart and preserves canonical Session data", async () => { + const root = await mkdtemp(join(tmpdir(), "openpi-web-unarchive-")); + const sessionDirectory = join(root, "sessions"); + try { + const manager = SessionManager.create(root, sessionDirectory); + persistSession(manager, "keep original history", 1); + const path = manager.getSessionFile(); + assert.ok(path); + const original = await readFile(path, "utf8"); + const runtime = runtimeFor(root, sessionDirectory, manager); + const adapter = new PiWebAdapter(runtime); + await adapter.archiveSession(path); + assert.equal((await adapter.requireSession(path)).archived, true); + await Promise.all([ + adapter.unarchiveSession(path), + adapter.unarchiveSession(path), + ]); + const restored = await new PiWebAdapter(runtime).requireSession(path); + assert.equal(restored.archived, undefined); + assert.equal(restored.cwd, root); + assert.equal(await readFile(path, "utf8"), original); + const metadata = await readFile( + join(sessionDirectory, "archived-sessions.json"), + "utf8", + ); + await assert.rejects(adapter.unarchiveSession(join(root, "missing.jsonl"))); + assert.equal( + await readFile(join(sessionDirectory, "archived-sessions.json"), "utf8"), + metadata, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index 2a5a950e..c8b459de 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -35,6 +35,39 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn truncated: false, }), }); + const unregisterTerminalDetails = registerWebCapability(sessionManager, { + kind: "background-terminals", + snapshot: () => ({ items: [], omitted: 0, truncated: false }), + detail: (id) => + id === "bt-test" + ? { + kind: "background-terminals", + id, + title: "server", + command: "run-server", + cwd, + status: "running", + createdAt: 1, + stdout: { + text: "ready", + totalBytes: 5, + retainedBytes: 5, + omittedBytes: 0, + truncated: false, + recoveryAvailable: false, + }, + stderr: { + text: "", + totalBytes: 0, + retainedBytes: 0, + omittedBytes: 0, + truncated: false, + recoveryAvailable: false, + }, + truncated: false, + } + : undefined, + }); const prompts: string[] = []; const creationCommandIds: string[] = []; let newSessions = 0; @@ -187,6 +220,40 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn const removedLegacyAsset = await fetch(`${launched.origin}/marked.js`); assert.equal(removedLegacyAsset.status, 401); + let thinkingReads = 0; + runtime.getThinkingState = () => { + thinkingReads++; + return { level: "high", available: ["off", "high"] }; + }; + assert.equal((await fetch(`${launched.origin}/api/thinking`)).status, 401); + assert.equal(thinkingReads, 0); + const thinkingResponse = await fetch(`${launched.origin}/api/thinking`, { + headers: authorized, + }); + assert.deepEqual(await thinkingResponse.json(), { + sessionId: sessionManager.getSessionId(), + level: "high", + available: ["off", "high"], + }); + assert.equal(thinkingReads, 1); + delete runtime.getThinkingState; + const unknownThinking = await fetch(`${launched.origin}/api/thinking`, { + headers: authorized, + }); + assert.deepEqual(await unknownThinking.json(), { + sessionId: sessionManager.getSessionId(), + level: "unknown", + available: [], + }); + assert.equal( + ( + await fetch( + `${launched.origin}/api/capabilities/detail?kind=background-terminals&id=bt-test`, + ) + ).status, + 401, + ); + const trustGetter = runtime.getProjectTrustStatus; assert.ok(trustGetter); let trustReads = 0; @@ -296,6 +363,46 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn maxProviders: 250, }, }); + const terminalDetailResponse = await fetch( + `${launched.origin}/api/capabilities/detail?kind=background-terminals&id=bt-test`, + { headers: authorized }, + ); + assert.equal(terminalDetailResponse.status, 200); + assert.deepEqual((await terminalDetailResponse.json()).detail, { + kind: "background-terminals", + id: "bt-test", + title: "server", + command: "run-server", + cwd, + status: "running", + createdAt: 1, + stdout: { + text: "ready", + totalBytes: 5, + retainedBytes: 5, + omittedBytes: 0, + truncated: false, + recoveryAvailable: false, + }, + stderr: { + text: "", + totalBytes: 0, + retainedBytes: 0, + omittedBytes: 0, + truncated: false, + recoveryAvailable: false, + }, + truncated: false, + }); + const staleTerminalResponse = await fetch( + `${launched.origin}/api/capabilities/detail?kind=background-terminals&id=bt-missing`, + { headers: authorized }, + ); + assert.equal(staleTerminalResponse.status, 404); + assert.deepEqual(await staleTerminalResponse.json(), { + code: "CAPABILITY_NOT_FOUND", + error: "capability resource was not found in the active Session", + }); const unavailableModel = await fetch(`${launched.origin}/api/model`, { method: "POST", headers: authorized, @@ -425,6 +532,38 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn true, ); + const unarchiveUrl = `${launched.origin}/api/sessions/unarchive?path=${encodeURIComponent(currentSessionPath)}`; + assert.equal((await fetch(unarchiveUrl, { method: "POST" })).status, 401); + assert.equal( + ( + await fetch(`${launched.origin}/api/sessions/unarchive`, { + method: "POST", + headers: authorized, + }) + ).status, + 400, + ); + const unarchiveResponse = await fetch(unarchiveUrl, { + method: "POST", + headers: authorized, + }); + assert.equal(unarchiveResponse.status, 200); + assert.deepEqual(await unarchiveResponse.json(), { + path: currentSessionPath, + archived: false, + }); + const restoredSnapshot = (await ( + await fetch(`${launched.origin}/api/snapshot`, { + headers: authorized, + }) + ).json()) as { sessions: Array<{ path: string; archived?: boolean }> }; + assert.equal( + restoredSnapshot.sessions.find( + (session) => session.path === currentSessionPath, + )?.archived, + undefined, + ); + const wrongSession = await fetch(`${launched.origin}/api/prompt`, { method: "POST", headers: authorized, @@ -600,6 +739,7 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn } finally { await host.stop(); assert.equal(disposed, true); + unregisterTerminalDetails(); unregister(); await Promise.all( [cwd, imported].map((path) => rm(path, { recursive: true, force: true })), diff --git a/web/adapter/pi-adapter.ts b/web/adapter/pi-adapter.ts index 32c9e89a..68a5175a 100644 --- a/web/adapter/pi-adapter.ts +++ b/web/adapter/pi-adapter.ts @@ -284,6 +284,14 @@ export class PiWebAdapter { }); } + async unarchiveSession(path: string) { + await this.ensureArchivesLoaded(); + await this.enqueueArchiveMutation(async (draft) => { + const session = await this.requireSession(path); + draft.delete(resolve(session.path)); + }); + } + async removeWorkspace(path: string) { await this.ensureWorkspaceStateLoaded(); const canonical = resolve(path); diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 80407801..8c74f8f1 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -11,6 +11,7 @@ import { URL } from "node:url"; import { promisify } from "node:util"; import { subscribeWebCapabilities, + webCapabilityDetail, webCapabilitySnapshot, } from "../../extensions/shared/web-observer-registry.ts"; import { loadSetupConfig } from "../../extensions/shared/setup-config.ts"; @@ -489,6 +490,13 @@ export class WebHost { this.publish("session_archived", { sessionPath: path }); return this.json(response, 200, { path, archived: true }); } + if (url.pathname === "/api/sessions/unarchive" && request.method === "POST") { + const path = url.searchParams.get("path"); + if (!path) return this.json(response, 400, { error: "session path is required" }); + await this.adapter.unarchiveSession(path); + this.publish("session_unarchived", { sessionPath: path }); + return this.json(response, 200, { path, archived: false }); + } if (url.pathname === "/api/sessions/select" && request.method === "POST") { const body = await this.readJson(request); if (typeof body.path !== "string" || body.path.trim().length === 0) { @@ -692,6 +700,45 @@ export class WebHost { } return this.json(response, 200, this.runtime.getProjectTrustStatus()); } + if (url.pathname === "/api/capabilities/detail") { + const kind = url.searchParams.get("kind"); + const id = url.searchParams.get("id"); + if ( + (kind !== "subagents" && + kind !== "workflows" && + kind !== "background-terminals") || + id === null + ) { + return this.json(response, 400, { + code: "INVALID_CAPABILITY_DETAIL_TARGET", + error: "a supported capability kind and exact id are required", + }); + } + const receipt = webCapabilityDetail( + this.runtime.sessionManager, + kind, + id, + ); + if (receipt.status === "invalid") { + return this.json(response, 400, { + code: "INVALID_CAPABILITY_DETAIL_TARGET", + error: "a supported capability kind and exact id are required", + }); + } + if (receipt.status === "unavailable") { + return this.json(response, 404, { + code: "CAPABILITY_DETAILS_UNAVAILABLE", + error: "capability details are unavailable for the active Session", + }); + } + if (receipt.status === "missing") { + return this.json(response, 404, { + code: "CAPABILITY_NOT_FOUND", + error: "capability resource was not found in the active Session", + }); + } + return this.json(response, 200, { detail: receipt.detail }); + } if (url.pathname === "/api/capabilities") return this.json(response, 200, { sessionId: this.runtime.sessionManager.getSessionId(), @@ -714,6 +761,11 @@ export class WebHost { } return this.json(response, 200, this.runtime.listProviderAuth()); } + if (url.pathname === "/api/thinking") + return this.json(response, 200, { + sessionId: this.runtime.sessionManager.getSessionId(), + ...(this.runtime.getThinkingState?.() ?? { level: "unknown", available: [] }), + }); if (url.pathname === "/api/snapshot") { const cursor = this.sequence; const projection = await this.adapter.getSnapshot( diff --git a/web/runtime/pi-runtime.ts b/web/runtime/pi-runtime.ts index 2c58f332..c9d5cdee 100644 --- a/web/runtime/pi-runtime.ts +++ b/web/runtime/pi-runtime.ts @@ -496,6 +496,11 @@ export class PiWebRuntime implements WebRuntimeController { return () => this.listeners.delete(listener); } + getThinkingState() { + const session = this.runtime.session; + return { level: session.thinkingLevel, available: session.getAvailableThinkingLevels() }; + } + async sendPrompt(content: string, options?: WebPromptOptions) { this.assertActive(); this.assertWorkspaceSelected(); diff --git a/web/runtime/types.ts b/web/runtime/types.ts index 2ce89161..8c60e5c0 100644 --- a/web/runtime/types.ts +++ b/web/runtime/types.ts @@ -123,6 +123,7 @@ export interface WebRuntimeController { switchSession(sessionPath: string): Promise<{ cancelled: boolean }>; listModels(): WebModelSummary[]; listProviderAuth?(): WebProviderAuthProjection; + getThinkingState?(): { level: string; available: readonly string[] }; setModel( provider: string, modelId: string,