fix: bind deeplink recipe parameters to startup session (#11123)
This commit is contained in:
@@ -261,6 +261,8 @@ pub struct RecipeListEntryDto {
|
||||
pub struct RequestRecipeParams {
|
||||
pub session_id: String,
|
||||
pub parameters: Vec<RecipeParameterDto>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parameter_scope_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
|
||||
|
||||
@@ -6847,6 +6847,12 @@
|
||||
"items": {
|
||||
"$ref": "#/$defs/RecipeParameterDto"
|
||||
}
|
||||
},
|
||||
"parameterScopeId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -243,14 +243,11 @@ fn meta_string(
|
||||
|
||||
fn agent_capabilities_meta() -> Option<Meta> {
|
||||
let mut goose = serde_json::Map::new();
|
||||
goose.insert("recipeParameterScopes".to_string(), serde_json::json!({}));
|
||||
if cfg!(feature = "local-inference") {
|
||||
goose.insert("localInference".to_string(), serde_json::json!({}));
|
||||
}
|
||||
|
||||
if goose.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut meta = serde_json::Map::new();
|
||||
meta.insert("goose".to_string(), serde_json::Value::Object(goose));
|
||||
Some(meta)
|
||||
@@ -2755,6 +2752,16 @@ print(\"hello, world\")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_capabilities_advertise_recipe_parameter_scopes() {
|
||||
assert_eq!(
|
||||
agent_capabilities_meta()
|
||||
.and_then(|meta| meta.get("goose").cloned())
|
||||
.and_then(|goose| goose.get("recipeParameterScopes").cloned()),
|
||||
Some(serde_json::json!({}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_goose_custom_notifications_capability_reads_client_meta() {
|
||||
let mut goose_meta = serde_json::Map::new();
|
||||
|
||||
@@ -122,8 +122,14 @@ impl GooseAcpAgent {
|
||||
recipe: Option<(Recipe, PathBuf)>,
|
||||
meta: NewSessionMetaFields,
|
||||
) -> Result<Option<Recipe>, agent_client_protocol::Error> {
|
||||
let recipe_parameter_scope_id = meta_string(args.meta.as_ref(), "recipeParameterScopeId")?;
|
||||
let (rendered, user_recipe_values) = self
|
||||
.render_recipe_for_session(cx, &session.id, recipe.as_ref())
|
||||
.render_recipe_for_session(
|
||||
cx,
|
||||
&session.id,
|
||||
recipe.as_ref(),
|
||||
recipe_parameter_scope_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let recipe_settings = rendered.as_ref().and_then(|r| r.settings.as_ref());
|
||||
|
||||
@@ -355,13 +355,14 @@ impl GooseAcpAgent {
|
||||
cx: &ConnectionTo<Client>,
|
||||
session_id: &str,
|
||||
recipe: Option<&(Recipe, PathBuf)>,
|
||||
parameter_scope_id: Option<&str>,
|
||||
) -> Result<(Option<Recipe>, Option<HashMap<String, String>>), agent_client_protocol::Error>
|
||||
{
|
||||
let Some((recipe, recipe_dir)) = recipe else {
|
||||
return Ok((None, None));
|
||||
};
|
||||
let (rendered, values) = self
|
||||
.render_recipe_with_params(cx, session_id, recipe, recipe_dir)
|
||||
.render_recipe_with_params(cx, session_id, recipe, recipe_dir, parameter_scope_id)
|
||||
.await?;
|
||||
Ok((Some(rendered), values))
|
||||
}
|
||||
@@ -372,6 +373,7 @@ impl GooseAcpAgent {
|
||||
session_id: &str,
|
||||
recipe: &Recipe,
|
||||
recipe_dir: &Path,
|
||||
parameter_scope_id: Option<&str>,
|
||||
) -> Result<(Recipe, Option<HashMap<String, String>>), agent_client_protocol::Error> {
|
||||
let parameters = recipe.parameters.clone().unwrap_or_default();
|
||||
|
||||
@@ -385,7 +387,7 @@ impl GooseAcpAgent {
|
||||
}
|
||||
|
||||
let response = self
|
||||
.request_recipe_params(cx, session_id, parameters)
|
||||
.request_recipe_params(cx, session_id, parameters, parameter_scope_id)
|
||||
.await?;
|
||||
if matches!(response.action, RecipeParamsAction::Cancel) {
|
||||
return Err(recipe_params_cancelled_error());
|
||||
@@ -403,6 +405,7 @@ impl GooseAcpAgent {
|
||||
cx: &ConnectionTo<Client>,
|
||||
session_id: &str,
|
||||
parameters: Vec<RecipeParameter>,
|
||||
parameter_scope_id: Option<&str>,
|
||||
) -> Result<RecipeParamsResponse, agent_client_protocol::Error> {
|
||||
let request = RequestRecipeParams {
|
||||
session_id: session_id.to_string(),
|
||||
@@ -410,6 +413,7 @@ impl GooseAcpAgent {
|
||||
.into_iter()
|
||||
.map(RecipeParameterDto::from)
|
||||
.collect(),
|
||||
parameter_scope_id: parameter_scope_id.map(str::to_string),
|
||||
};
|
||||
let (tx, rx) = oneshot::channel();
|
||||
cx.send_request(RequestRecipeParamsMessage(request))
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
import React from 'react';
|
||||
import { screen, render, waitFor } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { AppInner, resolveSessionInitialMessage } from './App';
|
||||
import { AppInner, PairRouteWrapper, resolveSessionInitialMessage } from './App';
|
||||
import { IntlTestWrapper } from './i18n/test-utils';
|
||||
import { FeaturesProvider } from './contexts/FeaturesContext';
|
||||
import { reconnectAcpAfterSystemResume } from './acp/acpConnection';
|
||||
import { createSession } from './sessions';
|
||||
import { RecipeParameterScopesUnsupportedError } from './acp/errors';
|
||||
|
||||
const mockToastError = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Set up globals for jsdom
|
||||
Object.defineProperty(window, 'location', {
|
||||
@@ -40,7 +44,8 @@ vi.mock('./acp/sessions', () => ({
|
||||
acpDeleteSession: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('./sessions', () => ({
|
||||
vi.mock('./sessions', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./sessions')>()),
|
||||
fetchSessionDetails: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ sessionId: 'test', messages: [], metadata: { description: '' } }),
|
||||
@@ -122,6 +127,9 @@ vi.mock('./components/ui/ConfirmationModal', () => ({
|
||||
|
||||
vi.mock('react-toastify', () => ({
|
||||
ToastContainer: () => null,
|
||||
toast: {
|
||||
error: mockToastError,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./components/GoosehintsModal', () => ({
|
||||
@@ -284,6 +292,26 @@ describe('App Component - Brand New State', () => {
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the scoped-parameter incompatibility before returning home', async () => {
|
||||
mockAppConfig.get.mockImplementation((key: string): string | null => {
|
||||
if (key === 'GOOSE_WORKING_DIR') return '/test/dir';
|
||||
if (key === 'recipeDeeplink') return 'goose://recipe?url=example';
|
||||
return null;
|
||||
});
|
||||
vi.mocked(createSession).mockRejectedValueOnce(new RecipeParameterScopesUnsupportedError());
|
||||
|
||||
render(<PairRouteWrapper activeSessions={[]} setActiveSessions={vi.fn()} />, {
|
||||
wrapper: AppInnerTestWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
'The connected Goose server does not support securely scoped deeplink recipe parameters. Update the server and try again.'
|
||||
);
|
||||
});
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('should navigate home when the main process emits new-chat', async () => {
|
||||
mockElectron.getConfig.mockReturnValue({
|
||||
GOOSE_DEFAULT_PROVIDER: 'openai',
|
||||
|
||||
+8
-10
@@ -1,18 +1,11 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { IpcRendererEvent } from 'electron';
|
||||
import {
|
||||
HashRouter,
|
||||
Routes,
|
||||
Route,
|
||||
useNavigate,
|
||||
useLocation,
|
||||
useSearchParams,
|
||||
} from 'react-router';
|
||||
import { HashRouter, Routes, Route, useNavigate, useLocation, useSearchParams } from 'react-router';
|
||||
import { importNostrSessionFromDeepLink } from './sessionLinks';
|
||||
import { ErrorUI } from './components/ErrorBoundary';
|
||||
import { ExtensionInstallModal } from './components/ExtensionInstallModal';
|
||||
import RecipeParamsModalContainer from './components/RecipeParamsModalContainer';
|
||||
import { isRecipeParamsCancelled } from './acp/errors';
|
||||
import { isRecipeParamsCancelled, isRecipeParameterScopesUnsupported } from './acp/errors';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import AnnouncementModal from './components/AnnouncementModal';
|
||||
import TelemetryConsentPrompt from './components/TelemetryConsentPrompt';
|
||||
@@ -81,7 +74,7 @@ export function resolveSessionInitialMessage(
|
||||
);
|
||||
}
|
||||
|
||||
const PairRouteWrapper = ({
|
||||
export const PairRouteWrapper = ({
|
||||
activeSessions,
|
||||
}: {
|
||||
activeSessions: Array<{
|
||||
@@ -144,6 +137,11 @@ const PairRouteWrapper = ({
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
if (isRecipeParameterScopesUnsupported(error)) {
|
||||
toast.error(error.message);
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
console.error('Failed to create session:', error);
|
||||
trackErrorWithContext(error, {
|
||||
component: 'PairRouteWrapper',
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { FixedExtensionEntry } from '../components/ConfigContext';
|
||||
import type { GooseExtension, GooseExtensionEntry } from '@aaif/goose-sdk';
|
||||
import { getConfiguredGooseExtensions } from '../acp/extensions';
|
||||
import { acpChatSessionController } from '../acp/chatSessionController';
|
||||
import { beginConfiguredRecipeParameterScope } from '../acp/recipeParamRequests';
|
||||
import { getAcpFeatureCapabilities } from '../acp/capabilities';
|
||||
|
||||
vi.mock('../acp/extensions', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../acp/extensions')>();
|
||||
@@ -21,6 +23,14 @@ vi.mock('../acp/chatSessionController', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../acp/recipeParamRequests', () => ({
|
||||
beginConfiguredRecipeParameterScope: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../acp/capabilities', () => ({
|
||||
getAcpFeatureCapabilities: vi.fn(),
|
||||
}));
|
||||
|
||||
const testSession: Session = {
|
||||
id: 'session-1',
|
||||
name: 'untitled',
|
||||
@@ -55,6 +65,9 @@ const gooseExtensionEntry = (name: string): GooseExtensionEntry => ({
|
||||
|
||||
const mockedGetConfiguredGooseExtensions = vi.mocked(getConfiguredGooseExtensions);
|
||||
const mockedCreateAcpSession = vi.mocked(acpChatSessionController.createSession);
|
||||
const mockedBeginConfiguredRecipeParameterScope = vi.mocked(beginConfiguredRecipeParameterScope);
|
||||
const mockedGetAcpFeatureCapabilities = vi.mocked(getAcpFeatureCapabilities);
|
||||
const finishConfiguredRecipeParameterScope = vi.fn();
|
||||
|
||||
describe('createSession ACP session extensions', () => {
|
||||
beforeEach(() => {
|
||||
@@ -65,6 +78,17 @@ describe('createSession ACP session extensions', () => {
|
||||
]);
|
||||
mockedCreateAcpSession.mockReset();
|
||||
mockedCreateAcpSession.mockResolvedValue(testSession);
|
||||
finishConfiguredRecipeParameterScope.mockReset();
|
||||
mockedBeginConfiguredRecipeParameterScope.mockReset();
|
||||
mockedBeginConfiguredRecipeParameterScope.mockReturnValue({
|
||||
id: 'scope-1',
|
||||
finish: finishConfiguredRecipeParameterScope,
|
||||
});
|
||||
mockedGetAcpFeatureCapabilities.mockReset();
|
||||
mockedGetAcpFeatureCapabilities.mockResolvedValue({
|
||||
localInference: false,
|
||||
recipeParameterScopes: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends non-empty extension configs as ACP session extensions', async () => {
|
||||
@@ -76,6 +100,7 @@ describe('createSession ACP session extensions', () => {
|
||||
expect(mockedCreateAcpSession).toHaveBeenCalledWith('/tmp', [gooseExtension('developer')], {
|
||||
recipeDeeplink: undefined,
|
||||
recipeId: undefined,
|
||||
recipeParameterScopeId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,6 +114,7 @@ describe('createSession ACP session extensions', () => {
|
||||
expect(mockedCreateAcpSession).toHaveBeenCalledWith('/tmp', [gooseExtension('developer')], {
|
||||
recipeDeeplink: undefined,
|
||||
recipeId: undefined,
|
||||
recipeParameterScopeId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +127,68 @@ describe('createSession ACP session extensions', () => {
|
||||
expect(mockedCreateAcpSession).toHaveBeenCalledWith('/tmp', [], {
|
||||
recipeDeeplink: undefined,
|
||||
recipeId: undefined,
|
||||
recipeParameterScopeId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes startup parameters to recipe deeplink session creation', async () => {
|
||||
await createSession('/tmp', { recipeDeeplink: 'goose://recipe?url=example' });
|
||||
|
||||
expect(mockedBeginConfiguredRecipeParameterScope).toHaveBeenCalledOnce();
|
||||
expect(mockedCreateAcpSession).toHaveBeenCalledWith('/tmp', [], {
|
||||
recipeDeeplink: 'goose://recipe?url=example',
|
||||
recipeId: undefined,
|
||||
recipeParameterScopeId: 'scope-1',
|
||||
});
|
||||
expect(finishConfiguredRecipeParameterScope).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('finishes the deeplink parameter scope when session creation fails', async () => {
|
||||
mockedCreateAcpSession.mockRejectedValueOnce(new Error('session creation failed'));
|
||||
|
||||
await expect(
|
||||
createSession('/tmp', { recipeDeeplink: 'goose://recipe?url=example' })
|
||||
).rejects.toThrow('session creation failed');
|
||||
|
||||
expect(mockedBeginConfiguredRecipeParameterScope).toHaveBeenCalledOnce();
|
||||
expect(finishConfiguredRecipeParameterScope).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('finishes the deeplink parameter scope when extension lookup fails', async () => {
|
||||
mockedGetConfiguredGooseExtensions.mockRejectedValueOnce(new Error('extension lookup failed'));
|
||||
|
||||
await expect(
|
||||
createSession('/tmp', {
|
||||
recipeDeeplink: 'goose://recipe?url=example',
|
||||
extensionConfigs: [extensionConfig('developer')],
|
||||
})
|
||||
).rejects.toThrow('extension lookup failed');
|
||||
|
||||
expect(mockedBeginConfiguredRecipeParameterScope).toHaveBeenCalledOnce();
|
||||
expect(mockedCreateAcpSession).not.toHaveBeenCalled();
|
||||
expect(finishConfiguredRecipeParameterScope).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reports incompatible Goose servers before sending scoped parameters', async () => {
|
||||
mockedGetAcpFeatureCapabilities.mockResolvedValueOnce({
|
||||
localInference: false,
|
||||
recipeParameterScopes: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
createSession('/tmp', { recipeDeeplink: 'goose://recipe?url=example' })
|
||||
).rejects.toThrow(
|
||||
'The connected Goose server does not support securely scoped deeplink recipe parameters. Update the server and try again.'
|
||||
);
|
||||
|
||||
expect(mockedCreateAcpSession).not.toHaveBeenCalled();
|
||||
expect(finishConfiguredRecipeParameterScope).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not activate startup parameters for ordinary or recipe-id sessions', async () => {
|
||||
await createSession('/tmp');
|
||||
await createSession('/tmp', { recipeId: 'recipe-1' });
|
||||
|
||||
expect(mockedBeginConfiguredRecipeParameterScope).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { InitializeResponse } from '@agentclientprotocol/sdk';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hasLocalInferenceCapability } from '../capabilities';
|
||||
import { hasLocalInferenceCapability, hasRecipeParameterScopesCapability } from '../capabilities';
|
||||
|
||||
function initializeResponseWithMeta(meta?: unknown): Pick<InitializeResponse, 'agentCapabilities'> {
|
||||
return {
|
||||
@@ -23,6 +23,29 @@ describe('ACP capabilities', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('detects scoped recipe-parameter support from Goose metadata', () => {
|
||||
expect(
|
||||
hasRecipeParameterScopesCapability(
|
||||
initializeResponseWithMeta({
|
||||
goose: {
|
||||
recipeParameterScopes: {},
|
||||
},
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('treats missing or malformed scoped recipe-parameter metadata as unsupported', () => {
|
||||
expect(hasRecipeParameterScopesCapability(initializeResponseWithMeta())).toBe(false);
|
||||
expect(hasRecipeParameterScopesCapability(initializeResponseWithMeta({}))).toBe(false);
|
||||
expect(hasRecipeParameterScopesCapability(initializeResponseWithMeta({ goose: {} }))).toBe(
|
||||
false
|
||||
);
|
||||
expect(hasRecipeParameterScopesCapability(initializeResponseWithMeta({ goose: true }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('treats missing local inference metadata as unsupported', () => {
|
||||
expect(hasLocalInferenceCapability(initializeResponseWithMeta())).toBe(false);
|
||||
expect(hasLocalInferenceCapability(initializeResponseWithMeta({}))).toBe(false);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { RequestRecipeParams_unstable } from '@aaif/goose-sdk';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
cancelAcpRecipeParamRequest,
|
||||
getAcpRecipeParamRequestsSnapshot,
|
||||
requestAcpRecipeParams,
|
||||
resolveAcpRecipeParamRequest,
|
||||
} from '../recipeParamRequests';
|
||||
|
||||
function recipeParamRequest(): RequestRecipeParams_unstable {
|
||||
type RecipeParamRequestsModule = typeof import('../recipeParamRequests');
|
||||
|
||||
function recipeParamRequest(
|
||||
sessionId = 'session-1',
|
||||
parameterScopeId?: string
|
||||
): RequestRecipeParams_unstable {
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
sessionId,
|
||||
parameterScopeId,
|
||||
parameters: [
|
||||
{
|
||||
key: 'topic',
|
||||
@@ -21,113 +21,198 @@ function recipeParamRequest(): RequestRecipeParams_unstable {
|
||||
};
|
||||
}
|
||||
|
||||
function optionalRecipeParamRequest(): RequestRecipeParams_unstable {
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
parameters: [
|
||||
{
|
||||
key: 'tone',
|
||||
description: 'Tone',
|
||||
input_type: 'string',
|
||||
requirement: 'optional',
|
||||
default: 'concise',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function setRecipeParameters(values: Record<string, string>): void {
|
||||
function setRecipeParameters(values: Record<string, string>) {
|
||||
const get = vi.fn((key: string) => (key === 'recipeParameters' ? values : undefined));
|
||||
Object.defineProperty(window, 'appConfig', {
|
||||
configurable: true,
|
||||
value: {
|
||||
get: vi.fn((key: string) => (key === 'recipeParameters' ? values : undefined)),
|
||||
},
|
||||
value: { get },
|
||||
});
|
||||
}
|
||||
|
||||
function cancelPendingRecipeParamRequests(): void {
|
||||
for (const request of getAcpRecipeParamRequestsSnapshot()) {
|
||||
cancelAcpRecipeParamRequest(request.id);
|
||||
}
|
||||
return get;
|
||||
}
|
||||
|
||||
describe('ACP recipe param requests', () => {
|
||||
beforeEach(() => {
|
||||
cancelPendingRecipeParamRequests();
|
||||
let requests: RecipeParamRequestsModule;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
requests = await import('../recipeParamRequests');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cancelPendingRecipeParamRequests();
|
||||
for (const request of requests.getAcpRecipeParamRequestsSnapshot()) {
|
||||
requests.cancelAcpRecipeParamRequest(request.id);
|
||||
}
|
||||
Reflect.deleteProperty(window, 'appConfig');
|
||||
});
|
||||
|
||||
it('keeps missing user_prompt parameters pending for user input', async () => {
|
||||
setRecipeParameters({});
|
||||
|
||||
const response = requestAcpRecipeParams(recipeParamRequest());
|
||||
const [pendingRequest] = getAcpRecipeParamRequestsSnapshot();
|
||||
|
||||
expect(pendingRequest).toMatchObject({
|
||||
sessionId: 'session-1',
|
||||
parameters: [
|
||||
{
|
||||
key: 'topic',
|
||||
requirement: 'user_prompt',
|
||||
},
|
||||
],
|
||||
initialValues: {},
|
||||
});
|
||||
|
||||
cancelAcpRecipeParamRequest(pendingRequest.id);
|
||||
await expect(response).resolves.toEqual({ action: 'cancel' });
|
||||
});
|
||||
|
||||
it('keeps user_prompt parameters pending when configured values are available', async () => {
|
||||
it('keeps ordinary requests pending without startup values', async () => {
|
||||
setRecipeParameters({ topic: 'release notes' });
|
||||
|
||||
const response = requestAcpRecipeParams(recipeParamRequest());
|
||||
const [pendingRequest] = getAcpRecipeParamRequestsSnapshot();
|
||||
const response = requests.requestAcpRecipeParams(recipeParamRequest());
|
||||
const [pendingRequest] = requests.getAcpRecipeParamRequestsSnapshot();
|
||||
|
||||
expect(pendingRequest).toMatchObject({
|
||||
sessionId: 'session-1',
|
||||
parameters: [
|
||||
{
|
||||
key: 'topic',
|
||||
requirement: 'user_prompt',
|
||||
},
|
||||
],
|
||||
initialValues: { topic: 'release notes' },
|
||||
expect(pendingRequest.initialValues).toEqual({});
|
||||
requests.resolveAcpRecipeParamRequest(pendingRequest.id, { topic: 'manual value' });
|
||||
await expect(response).resolves.toEqual({
|
||||
action: 'submit',
|
||||
values: { topic: 'manual value' },
|
||||
});
|
||||
});
|
||||
|
||||
it('offers startup values only to the callback carrying the deeplink scope id', async () => {
|
||||
setRecipeParameters({ topic: 'release notes' });
|
||||
const scope = requests.beginConfiguredRecipeParameterScope()!;
|
||||
|
||||
const otherResponse = requests.requestAcpRecipeParams(recipeParamRequest('session-2'));
|
||||
const ownerResponse = requests.requestAcpRecipeParams(
|
||||
recipeParamRequest('session-1', scope.id)
|
||||
);
|
||||
const pending = requests.getAcpRecipeParamRequestsSnapshot();
|
||||
|
||||
expect(pending.find((request) => request.sessionId === 'session-2')?.initialValues).toEqual({});
|
||||
expect(pending.find((request) => request.sessionId === 'session-1')?.initialValues).toEqual({
|
||||
topic: 'release notes',
|
||||
});
|
||||
|
||||
resolveAcpRecipeParamRequest(pendingRequest.id, { topic: 'release notes' });
|
||||
await expect(response).resolves.toEqual({
|
||||
for (const request of pending) {
|
||||
requests.cancelAcpRecipeParamRequest(request.id);
|
||||
}
|
||||
await expect(otherResponse).resolves.toEqual({ action: 'cancel' });
|
||||
await expect(ownerResponse).resolves.toEqual({ action: 'cancel' });
|
||||
scope.finish();
|
||||
});
|
||||
|
||||
it('allows same-session retries until a terminal response consumes the values', async () => {
|
||||
setRecipeParameters({ topic: 'release notes' });
|
||||
const scope = requests.beginConfiguredRecipeParameterScope()!;
|
||||
|
||||
const firstResponse = requests.requestAcpRecipeParams(
|
||||
recipeParamRequest('session-1', scope.id)
|
||||
);
|
||||
const retryResponse = requests.requestAcpRecipeParams(
|
||||
recipeParamRequest('session-1', scope.id)
|
||||
);
|
||||
const [firstRequest, retryRequest] = requests.getAcpRecipeParamRequestsSnapshot();
|
||||
|
||||
expect(firstRequest.initialValues).toEqual({ topic: 'release notes' });
|
||||
expect(retryRequest.initialValues).toEqual({ topic: 'release notes' });
|
||||
|
||||
requests.resolveAcpRecipeParamRequest(firstRequest.id, { topic: 'release notes' });
|
||||
await expect(firstResponse).resolves.toEqual({
|
||||
action: 'submit',
|
||||
values: { topic: 'release notes' },
|
||||
});
|
||||
expect(requests.getAcpRecipeParamRequestsSnapshot()[0].initialValues).toEqual({});
|
||||
|
||||
requests.cancelAcpRecipeParamRequest(retryRequest.id);
|
||||
await expect(retryResponse).resolves.toEqual({ action: 'cancel' });
|
||||
scope.finish();
|
||||
});
|
||||
|
||||
it('keeps optional-only parameters pending for user confirmation', async () => {
|
||||
setRecipeParameters({});
|
||||
it('does not reuse startup values after submission', async () => {
|
||||
setRecipeParameters({ topic: 'release notes' });
|
||||
const scope = requests.beginConfiguredRecipeParameterScope()!;
|
||||
|
||||
const response = requestAcpRecipeParams(optionalRecipeParamRequest());
|
||||
const [pendingRequest] = getAcpRecipeParamRequestsSnapshot();
|
||||
const firstResponse = requests.requestAcpRecipeParams(
|
||||
recipeParamRequest('session-1', scope.id)
|
||||
);
|
||||
const [firstRequest] = requests.getAcpRecipeParamRequestsSnapshot();
|
||||
requests.resolveAcpRecipeParamRequest(firstRequest.id, { topic: 'release notes' });
|
||||
await firstResponse;
|
||||
|
||||
expect(pendingRequest).toMatchObject({
|
||||
sessionId: 'session-1',
|
||||
parameters: [
|
||||
{
|
||||
key: 'tone',
|
||||
requirement: 'optional',
|
||||
default: 'concise',
|
||||
},
|
||||
],
|
||||
initialValues: {},
|
||||
});
|
||||
const laterResponse = requests.requestAcpRecipeParams(
|
||||
recipeParamRequest('session-1', scope.id)
|
||||
);
|
||||
const [laterRequest] = requests.getAcpRecipeParamRequestsSnapshot();
|
||||
expect(laterRequest.initialValues).toEqual({});
|
||||
|
||||
resolveAcpRecipeParamRequest(pendingRequest.id, { tone: 'detailed' });
|
||||
await expect(response).resolves.toEqual({
|
||||
action: 'submit',
|
||||
values: { tone: 'detailed' },
|
||||
});
|
||||
requests.cancelAcpRecipeParamRequest(laterRequest.id);
|
||||
await laterResponse;
|
||||
scope.finish();
|
||||
});
|
||||
|
||||
it('does not reuse startup values after cancellation', async () => {
|
||||
setRecipeParameters({ topic: 'release notes' });
|
||||
const scope = requests.beginConfiguredRecipeParameterScope()!;
|
||||
|
||||
const firstResponse = requests.requestAcpRecipeParams(
|
||||
recipeParamRequest('session-1', scope.id)
|
||||
);
|
||||
const [firstRequest] = requests.getAcpRecipeParamRequestsSnapshot();
|
||||
requests.cancelAcpRecipeParamRequest(firstRequest.id);
|
||||
await firstResponse;
|
||||
|
||||
const laterResponse = requests.requestAcpRecipeParams(
|
||||
recipeParamRequest('session-1', scope.id)
|
||||
);
|
||||
const [laterRequest] = requests.getAcpRecipeParamRequestsSnapshot();
|
||||
expect(laterRequest.initialValues).toEqual({});
|
||||
|
||||
requests.cancelAcpRecipeParamRequest(laterRequest.id);
|
||||
await laterResponse;
|
||||
scope.finish();
|
||||
});
|
||||
|
||||
it('does not let an unrelated cancellation consume the owner values', async () => {
|
||||
setRecipeParameters({ topic: 'release notes' });
|
||||
const scope = requests.beginConfiguredRecipeParameterScope()!;
|
||||
|
||||
const ownerResponse = requests.requestAcpRecipeParams(
|
||||
recipeParamRequest('session-1', scope.id)
|
||||
);
|
||||
const otherResponse = requests.requestAcpRecipeParams(recipeParamRequest('session-2'));
|
||||
const otherRequest = requests
|
||||
.getAcpRecipeParamRequestsSnapshot()
|
||||
.find((request) => request.sessionId === 'session-2')!;
|
||||
requests.cancelAcpRecipeParamRequest(otherRequest.id);
|
||||
await otherResponse;
|
||||
|
||||
const retryResponse = requests.requestAcpRecipeParams(
|
||||
recipeParamRequest('session-1', scope.id)
|
||||
);
|
||||
const ownerRequests = requests
|
||||
.getAcpRecipeParamRequestsSnapshot()
|
||||
.filter((request) => request.sessionId === 'session-1');
|
||||
expect(ownerRequests).toHaveLength(2);
|
||||
expect(ownerRequests[1].initialValues).toEqual({ topic: 'release notes' });
|
||||
|
||||
for (const request of ownerRequests) {
|
||||
requests.cancelAcpRecipeParamRequest(request.id);
|
||||
}
|
||||
await ownerResponse;
|
||||
await retryResponse;
|
||||
scope.finish();
|
||||
});
|
||||
|
||||
it('consumes startup values when a scope finishes without a callback', async () => {
|
||||
setRecipeParameters({ topic: 'release notes' });
|
||||
const scope = requests.beginConfiguredRecipeParameterScope()!;
|
||||
scope.finish();
|
||||
|
||||
const laterResponse = requests.requestAcpRecipeParams(
|
||||
recipeParamRequest('session-1', scope.id)
|
||||
);
|
||||
const [laterRequest] = requests.getAcpRecipeParamRequestsSnapshot();
|
||||
expect(laterRequest.initialValues).toEqual({});
|
||||
|
||||
requests.cancelAcpRecipeParamRequest(laterRequest.id);
|
||||
await laterResponse;
|
||||
});
|
||||
|
||||
it('reads app configuration only once after consumption', async () => {
|
||||
const get = setRecipeParameters({ topic: 'release notes' });
|
||||
const scope = requests.beginConfiguredRecipeParameterScope()!;
|
||||
scope.finish();
|
||||
|
||||
const secondScope = requests.beginConfiguredRecipeParameterScope();
|
||||
const laterResponse = requests.requestAcpRecipeParams(recipeParamRequest());
|
||||
const [laterRequest] = requests.getAcpRecipeParamRequestsSnapshot();
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
expect(secondScope).toBeUndefined();
|
||||
expect(laterRequest.initialValues).toEqual({});
|
||||
|
||||
requests.cancelAcpRecipeParamRequest(laterRequest.id);
|
||||
await laterResponse;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { SessionInfo } from '@agentclientprotocol/sdk';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getAcpClient } from '../acpConnection';
|
||||
import { acpGetSessionListItem, acpLoadSession, sessionInfoToSession } from '../sessions';
|
||||
import {
|
||||
acpGetSessionListItem,
|
||||
acpLoadSession,
|
||||
acpNewSession,
|
||||
sessionInfoToSession,
|
||||
} from '../sessions';
|
||||
|
||||
vi.mock('../acpConnection', () => ({
|
||||
getAcpClient: vi.fn(),
|
||||
@@ -76,6 +81,34 @@ describe('ACP sessions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the recipe parameter scope id in new-session metadata', async () => {
|
||||
const createdSessionInfo = sessionInfo();
|
||||
const client = {
|
||||
goose: {
|
||||
sessionInfo_unstable: vi.fn().mockResolvedValue({ session: createdSessionInfo }),
|
||||
},
|
||||
newSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }),
|
||||
};
|
||||
vi.mocked(getAcpClient).mockResolvedValue(
|
||||
client as unknown as Awaited<ReturnType<typeof getAcpClient>>
|
||||
);
|
||||
|
||||
await acpNewSession('/tmp', [], {
|
||||
recipeDeeplink: 'goose://recipe?url=example',
|
||||
recipeParameterScopeId: 'scope-1',
|
||||
});
|
||||
|
||||
expect(client.newSession).toHaveBeenCalledWith({
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
client: 'goose-desktop',
|
||||
recipeDeeplink: 'goose://recipe?url=example',
|
||||
recipeParameterScopeId: 'scope-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a list item from ACP session info', async () => {
|
||||
const client = {
|
||||
goose: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getAcpInitializeResponse } from './acpConnection';
|
||||
|
||||
export interface AcpFeatureCapabilities {
|
||||
localInference: boolean;
|
||||
recipeParameterScopes: boolean;
|
||||
}
|
||||
|
||||
export async function getAcpFeatureCapabilities(): Promise<AcpFeatureCapabilities> {
|
||||
@@ -10,6 +11,7 @@ export async function getAcpFeatureCapabilities(): Promise<AcpFeatureCapabilitie
|
||||
|
||||
return {
|
||||
localInference: hasLocalInferenceCapability(initializeResponse),
|
||||
recipeParameterScopes: hasRecipeParameterScopesCapability(initializeResponse),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +36,27 @@ export function hasLocalInferenceCapability(
|
||||
return 'localInference' in goose;
|
||||
}
|
||||
|
||||
export function hasRecipeParameterScopesCapability(
|
||||
initializeResponse: Pick<InitializeResponse, 'agentCapabilities'>
|
||||
): boolean {
|
||||
const agentCapabilities = initializeResponse.agentCapabilities;
|
||||
if (!agentCapabilities) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const meta = agentCapabilities._meta;
|
||||
if (!isRecord(meta)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const goose = meta.goose;
|
||||
if (!isRecord(goose)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'recipeParameterScopes' in goose;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,22 @@ const CREDITS_EXHAUSTED_REASON = 'credits_exhausted';
|
||||
// Kept in sync with RECIPE_PARAMS_CANCELLED_REASON in crates/goose/src/acp/server/recipe.rs.
|
||||
const RECIPE_PARAMS_CANCELLED_REASON = 'recipe_params_cancelled';
|
||||
|
||||
export const RECIPE_PARAMETER_SCOPES_UNSUPPORTED_MESSAGE =
|
||||
'The connected Goose server does not support securely scoped deeplink recipe parameters. Update the server and try again.';
|
||||
|
||||
export class RecipeParameterScopesUnsupportedError extends Error {
|
||||
constructor() {
|
||||
super(RECIPE_PARAMETER_SCOPES_UNSUPPORTED_MESSAGE);
|
||||
this.name = 'RecipeParameterScopesUnsupportedError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isRecipeParameterScopesUnsupported(
|
||||
error: unknown
|
||||
): error is RecipeParameterScopesUnsupportedError {
|
||||
return error instanceof RecipeParameterScopesUnsupportedError;
|
||||
}
|
||||
|
||||
export function isRecipeParamsCancelled(error: unknown): boolean {
|
||||
return asAcpJsonRpcError(error)?.data?.reason === RECIPE_PARAMS_CANCELLED_REASON;
|
||||
}
|
||||
|
||||
@@ -15,11 +15,28 @@ export interface AcpRecipeParamRequest {
|
||||
interface PendingRecipeParamRequest {
|
||||
request: AcpRecipeParamRequest;
|
||||
resolve: (response: RecipeParamsResponse_unstable) => void;
|
||||
usesConfiguredParameters: boolean;
|
||||
}
|
||||
|
||||
type ConfiguredParameterState =
|
||||
| { status: 'uninitialized' }
|
||||
| {
|
||||
status: 'active';
|
||||
scopeId: string;
|
||||
values: Record<string, string>;
|
||||
sessionId?: string;
|
||||
}
|
||||
| { status: 'consumed' };
|
||||
|
||||
export interface ConfiguredRecipeParameterScope {
|
||||
id: string;
|
||||
finish(): void;
|
||||
}
|
||||
|
||||
const pendingRequests = new Map<string, PendingRecipeParamRequest>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: AcpRecipeParamRequest[] = [];
|
||||
let configuredParameterState: ConfiguredParameterState = { status: 'uninitialized' };
|
||||
|
||||
function emit(): void {
|
||||
snapshot = Array.from(pendingRequests.values(), (pending) => pending.request);
|
||||
@@ -39,17 +56,82 @@ export function getAcpRecipeParamRequestsSnapshot(): AcpRecipeParamRequest[] {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function configuredParameterValues(): Record<string, string> {
|
||||
function consumeConfiguredParameters(): boolean {
|
||||
if (configuredParameterState.status !== 'active') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionId = configuredParameterState.sessionId;
|
||||
configuredParameterState = { status: 'consumed' };
|
||||
let scrubbedPendingRequest = false;
|
||||
if (sessionId) {
|
||||
for (const pending of pendingRequests.values()) {
|
||||
if (pending.usesConfiguredParameters && pending.request.sessionId === sessionId) {
|
||||
pending.request.initialValues = {};
|
||||
scrubbedPendingRequest = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return scrubbedPendingRequest;
|
||||
}
|
||||
|
||||
export function beginConfiguredRecipeParameterScope(): ConfiguredRecipeParameterScope | undefined {
|
||||
if (configuredParameterState.status !== 'uninitialized') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const configured = window.appConfig?.get('recipeParameters') as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
return configured ?? {};
|
||||
if (!configured || Object.keys(configured).length === 0) {
|
||||
configuredParameterState = { status: 'consumed' };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scopeId = `configured_recipe_parameters_${uuidv7()}`;
|
||||
configuredParameterState = {
|
||||
status: 'active',
|
||||
scopeId,
|
||||
values: { ...configured },
|
||||
};
|
||||
return {
|
||||
id: scopeId,
|
||||
finish: () => {
|
||||
if (
|
||||
configuredParameterState.status === 'active' &&
|
||||
configuredParameterState.scopeId === scopeId &&
|
||||
consumeConfiguredParameters()
|
||||
) {
|
||||
emit();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function configuredParameterValues(request: RequestRecipeParams_unstable): {
|
||||
values: Record<string, string>;
|
||||
usesConfiguredParameters: boolean;
|
||||
} {
|
||||
if (
|
||||
configuredParameterState.status !== 'active' ||
|
||||
request.parameterScopeId !== configuredParameterState.scopeId
|
||||
) {
|
||||
return { values: {}, usesConfiguredParameters: false };
|
||||
}
|
||||
configuredParameterState.sessionId ??= request.sessionId;
|
||||
if (configuredParameterState.sessionId !== request.sessionId) {
|
||||
return { values: {}, usesConfiguredParameters: false };
|
||||
}
|
||||
return {
|
||||
values: { ...configuredParameterState.values },
|
||||
usesConfiguredParameters: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestAcpRecipeParams(
|
||||
request: RequestRecipeParams_unstable
|
||||
): Promise<RecipeParamsResponse_unstable> {
|
||||
const initialValues = configuredParameterValues();
|
||||
const { values: initialValues, usesConfiguredParameters } = configuredParameterValues(request);
|
||||
const paramRequest: AcpRecipeParamRequest = {
|
||||
id: `acp_recipe_params_${uuidv7()}`,
|
||||
sessionId: request.sessionId,
|
||||
@@ -58,7 +140,11 @@ export async function requestAcpRecipeParams(
|
||||
};
|
||||
|
||||
return new Promise<RecipeParamsResponse_unstable>((resolve) => {
|
||||
pendingRequests.set(paramRequest.id, { request: paramRequest, resolve });
|
||||
pendingRequests.set(paramRequest.id, {
|
||||
request: paramRequest,
|
||||
resolve,
|
||||
usesConfiguredParameters,
|
||||
});
|
||||
emit();
|
||||
});
|
||||
}
|
||||
@@ -69,6 +155,9 @@ export function resolveAcpRecipeParamRequest(id: string, values: Record<string,
|
||||
return false;
|
||||
}
|
||||
pendingRequests.delete(id);
|
||||
if (pending.usesConfiguredParameters) {
|
||||
consumeConfiguredParameters();
|
||||
}
|
||||
emit();
|
||||
pending.resolve({ action: 'submit', values });
|
||||
return true;
|
||||
@@ -80,6 +169,9 @@ export function cancelAcpRecipeParamRequest(id: string): void {
|
||||
return;
|
||||
}
|
||||
pendingRequests.delete(id);
|
||||
if (pending.usesConfiguredParameters) {
|
||||
consumeConfiguredParameters();
|
||||
}
|
||||
emit();
|
||||
pending.resolve({ action: 'cancel' });
|
||||
}
|
||||
|
||||
@@ -223,6 +223,7 @@ export interface AcpNewSessionResult {
|
||||
export interface AcpRecipeOptions {
|
||||
recipeId?: string;
|
||||
recipeDeeplink?: string;
|
||||
recipeParameterScopeId?: string;
|
||||
}
|
||||
|
||||
export async function acpNewSession(
|
||||
@@ -240,6 +241,9 @@ export async function acpNewSession(
|
||||
} else if (recipe?.recipeDeeplink) {
|
||||
meta.recipeDeeplink = recipe.recipeDeeplink;
|
||||
}
|
||||
if (recipe?.recipeParameterScopeId) {
|
||||
meta.recipeParameterScopeId = recipe.recipeParameterScopeId;
|
||||
}
|
||||
const request: NewSessionRequest = { cwd, mcpServers: [], _meta: meta };
|
||||
const response = await client.newSession(request);
|
||||
const sessionId = String(response.sessionId);
|
||||
|
||||
+28
-11
@@ -5,6 +5,9 @@ import type { FixedExtensionEntry } from './components/ConfigContext';
|
||||
import { AppEvents } from './constants/events';
|
||||
import { acpChatSessionController } from './acp/chatSessionController';
|
||||
import { getConfiguredGooseExtensions, gooseExtensionName } from './acp/extensions';
|
||||
import { beginConfiguredRecipeParameterScope } from './acp/recipeParamRequests';
|
||||
import { getAcpFeatureCapabilities } from './acp/capabilities';
|
||||
import { RecipeParameterScopesUnsupportedError } from './acp/errors';
|
||||
|
||||
export function getSessionDisplayName(session: Session): string {
|
||||
if (session.user_set_name) {
|
||||
@@ -42,17 +45,31 @@ async function createAcpSession(
|
||||
workingDir: string,
|
||||
options?: CreateSessionOptions
|
||||
): Promise<Session> {
|
||||
const selectedNames = new Set(selectedExtensionConfigs(options).map((config) => config.name));
|
||||
const gooseExtensions =
|
||||
selectedNames.size > 0
|
||||
? (await getConfiguredGooseExtensions())
|
||||
.filter((entry) => selectedNames.has(gooseExtensionName(entry.extension)))
|
||||
.map((entry) => entry.extension)
|
||||
: [];
|
||||
return acpChatSessionController.createSession(workingDir, gooseExtensions, {
|
||||
recipeId: options?.recipeId,
|
||||
recipeDeeplink: options?.recipeDeeplink,
|
||||
});
|
||||
const configuredParameterScope = options?.recipeDeeplink
|
||||
? beginConfiguredRecipeParameterScope()
|
||||
: undefined;
|
||||
try {
|
||||
if (configuredParameterScope) {
|
||||
const capabilities = await getAcpFeatureCapabilities();
|
||||
if (!capabilities.recipeParameterScopes) {
|
||||
throw new RecipeParameterScopesUnsupportedError();
|
||||
}
|
||||
}
|
||||
const selectedNames = new Set(selectedExtensionConfigs(options).map((config) => config.name));
|
||||
const gooseExtensions =
|
||||
selectedNames.size > 0
|
||||
? (await getConfiguredGooseExtensions())
|
||||
.filter((entry) => selectedNames.has(gooseExtensionName(entry.extension)))
|
||||
.map((entry) => entry.extension)
|
||||
: [];
|
||||
return await acpChatSessionController.createSession(workingDir, gooseExtensions, {
|
||||
recipeId: options?.recipeId,
|
||||
recipeDeeplink: options?.recipeDeeplink,
|
||||
recipeParameterScopeId: configuredParameterScope?.id,
|
||||
});
|
||||
} finally {
|
||||
configuredParameterScope?.finish();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
|
||||
@@ -2622,6 +2622,7 @@ export type MessageUsageUpdate = {
|
||||
export type RequestRecipeParams_unstable = {
|
||||
sessionId: string;
|
||||
parameters: Array<RecipeParameterDto>;
|
||||
parameterScopeId?: string | null;
|
||||
};
|
||||
|
||||
export type RecipeParamsResponse_unstable = {
|
||||
|
||||
@@ -2811,7 +2811,11 @@ export const zGooseSessionNotification_unstable = z.object({
|
||||
|
||||
export const zRequestRecipeParams_unstable = z.object({
|
||||
sessionId: z.string(),
|
||||
parameters: z.array(zRecipeParameterDto)
|
||||
parameters: z.array(zRecipeParameterDto),
|
||||
parameterScopeId: z.union([
|
||||
z.string(),
|
||||
z.null()
|
||||
]).optional()
|
||||
});
|
||||
|
||||
export const zRecipeParamsAction = z.enum(['submit', 'cancel']);
|
||||
|
||||
Reference in New Issue
Block a user