Archived
feat(sessions): deliver ask_user answers
Integrate the pending-ask store into the session service so an open ask is
visible, observable, and closable.
- `statusFromSession` projects `pendingAsk`, so a browser rehydrates an open
ask from `GET /sessions/:sessionId/status` after reload or a web/API restart.
- `openAsk` publishes `ask.opened`, and publishes `ask.closed` first when the
new ask supersedes an unanswered one.
- `submitAsk` / `cancelAsk` close the ask and hand the outcome to the model as
a `pi-web.ask.answers` follow-up custom message (`triggerTurn`,
`deliverAs: "followUp"`), the same delivery subsession notices use. A stale
ask id is reported, not thrown: losing the race against a supersede or
another browser is ordinary. Cancel still reports every question as
unanswered so the model is not left waiting for a promised message.
- The open ask is forgotten when its runtime closes; nothing is left to
receive the answers.
- `POST /sessions/:sessionId/ask/{submit,cancel}` behind the existing
`/api/sessions/*` daemon proxy, allowlisted for machine federation.
This commit is contained in:
@@ -1,13 +1,20 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { ASK_USER_ANSWERS_CUSTOM_TYPE } from "../../shared/apiTypes.js";
|
||||||
import { createPiWebCustomToolDefinitions, PiSessionService } from "./piSessionService.js";
|
import { createPiWebCustomToolDefinitions, PiSessionService } from "./piSessionService.js";
|
||||||
import { PendingAskStore, PendingAskValidationError } from "./pendingAskStore.js";
|
import { PendingAskStore, PendingAskValidationError } from "./pendingAskStore.js";
|
||||||
import { CapturingSessionEventHub, emptyArchiveStore, sessionGateway, testModelRuntime } from "./piSessionService.testSupport.js";
|
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js";
|
||||||
|
|
||||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||||
|
const ACTIVE_SESSION_ID = "session-1";
|
||||||
|
|
||||||
const questions = [{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] }];
|
const questions = [{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] }];
|
||||||
|
|
||||||
function askService() {
|
/**
|
||||||
|
* Service over a clocked store with sequential ask ids, so asks are named
|
||||||
|
* `ask-1`, `ask-2`, … and timestamps are fixed. Pass `withActiveSession` when the
|
||||||
|
* test needs a live runtime to deliver answers into.
|
||||||
|
*/
|
||||||
|
function askService(options: { withActiveSession?: boolean } = {}) {
|
||||||
const store = new PendingAskStore({
|
const store = new PendingAskStore({
|
||||||
now: () => new Date("2026-02-01T10:00:00.000Z"),
|
now: () => new Date("2026-02-01T10:00:00.000Z"),
|
||||||
createAskId: (() => {
|
createAskId: (() => {
|
||||||
@@ -15,18 +22,28 @@ function askService() {
|
|||||||
return () => { next += 1; return `ask-${next.toString()}`; };
|
return () => { next += 1; return `ask-${next.toString()}`; };
|
||||||
})(),
|
})(),
|
||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const fake = fakeRuntime(ACTIVE_SESSION_ID);
|
||||||
|
const events = new CapturingSessionEventHub();
|
||||||
|
const service = new PiSessionService(events, {
|
||||||
agentDir: TEST_AGENT_DIR,
|
agentDir: TEST_AGENT_DIR,
|
||||||
modelRuntime: testModelRuntime,
|
modelRuntime: testModelRuntime,
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway(options.withActiveSession === true ? [sessionRecord(ACTIVE_SESSION_ID)] : []),
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
|
...(options.withActiveSession === true ? { createAgentRuntime: runtimeCreator(fake.runtime) } : {}),
|
||||||
pendingAskStore: store,
|
pendingAskStore: store,
|
||||||
askUserEnabled: true,
|
askUserEnabled: true,
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
});
|
});
|
||||||
return { service, store };
|
return { service, store, events, fake };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function askEvents(events: CapturingSessionEventHub) {
|
||||||
|
return events.sessionEvents
|
||||||
|
.filter(({ event }) => event.type === "ask.opened" || event.type === "ask.closed")
|
||||||
|
.map(({ sessionId, event }) => ({ sessionId, event }));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
describe("ask_user registration", () => {
|
describe("ask_user registration", () => {
|
||||||
it("offers ask_user whenever the capability is configured, including to restricted tracked children", () => {
|
it("offers ask_user whenever the capability is configured, including to restricted tracked children", () => {
|
||||||
const askUser = { open: vi.fn() };
|
const askUser = { open: vi.fn() };
|
||||||
@@ -82,11 +99,155 @@ describe("PiSessionService.openAsk", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an unanswerable question set without opening it", async () => {
|
it("rejects an unanswerable question set without opening it", async () => {
|
||||||
const { service, store } = askService();
|
const { service, store, events } = askService();
|
||||||
|
|
||||||
await expect(service.openAsk({ sessionId: "session-1", questions: [{ id: "empty", question: "No way to answer?", options: [] }] }))
|
await expect(service.openAsk({ sessionId: "session-1", questions: [{ id: "empty", question: "No way to answer?", options: [] }] }))
|
||||||
.rejects.toThrow(PendingAskValidationError);
|
.rejects.toThrow(PendingAskValidationError);
|
||||||
expect(store.pendingAsk("session-1")).toBeUndefined();
|
expect(store.pendingAsk("session-1")).toBeUndefined();
|
||||||
|
expect(askEvents(events)).toEqual([]);
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes ask.opened so a watching browser renders the card without refetching status", async () => {
|
||||||
|
const { service, events } = askService();
|
||||||
|
|
||||||
|
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||||
|
|
||||||
|
expect(askEvents(events)).toEqual([
|
||||||
|
{ sessionId: ACTIVE_SESSION_ID, event: { type: "ask.opened", ask: { askId: "ask-1", askedAt: "2026-02-01T10:00:00.000Z", questions } } },
|
||||||
|
]);
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes the supersede as a close before the replacement opens", async () => {
|
||||||
|
const { service, events } = askService();
|
||||||
|
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||||
|
|
||||||
|
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions: [{ id: "again", question: "Still?", options: [], allowOther: true }] });
|
||||||
|
|
||||||
|
expect(askEvents(events).map(({ event }) => event)).toEqual([
|
||||||
|
{ type: "ask.opened", ask: { askId: "ask-1", askedAt: "2026-02-01T10:00:00.000Z", questions } },
|
||||||
|
{ type: "ask.closed", askId: "ask-1", reason: "superseded" },
|
||||||
|
{ type: "ask.opened", ask: { askId: "ask-2", askedAt: "2026-02-01T10:00:00.000Z", questions: [{ id: "again", question: "Still?", options: [], allowOther: true }] } },
|
||||||
|
]);
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PiSessionService ask status projection", () => {
|
||||||
|
it("reports the open ask in status so a reloading browser rehydrates it", async () => {
|
||||||
|
const { service } = askService({ withActiveSession: true });
|
||||||
|
|
||||||
|
const before = await service.status(sessionRef(ACTIVE_SESSION_ID));
|
||||||
|
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||||
|
const during = await service.status(sessionRef(ACTIVE_SESSION_ID));
|
||||||
|
await service.submitAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1", { answers: [{ id: "db", values: ["pg"] }] });
|
||||||
|
const after = await service.status(sessionRef(ACTIVE_SESSION_ID));
|
||||||
|
|
||||||
|
expect(before).not.toHaveProperty("pendingAsk");
|
||||||
|
expect(during.pendingAsk).toMatchObject({ askId: "ask-1", questions });
|
||||||
|
expect(after).not.toHaveProperty("pendingAsk");
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the open ask when the runtime that posted it is closed", async () => {
|
||||||
|
const { service, store } = askService({ withActiveSession: true });
|
||||||
|
await service.status(sessionRef(ACTIVE_SESSION_ID));
|
||||||
|
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||||
|
|
||||||
|
await service.stop(sessionRef(ACTIVE_SESSION_ID));
|
||||||
|
|
||||||
|
expect(store.pendingAsk(ACTIVE_SESSION_ID)).toBeUndefined();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PiSessionService.submitAsk", () => {
|
||||||
|
it("delivers the answers as a follow-up custom message that wakes the session", async () => {
|
||||||
|
const { service, store, events, fake } = askService({ withActiveSession: true });
|
||||||
|
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||||
|
|
||||||
|
const response = await service.submitAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1", { answers: [{ id: "db", values: ["pg"] }] });
|
||||||
|
|
||||||
|
expect(response).toMatchObject({ result: "closed", outcome: { askId: "ask-1", reason: "submitted", answeredCount: 1, unansweredIds: [] } });
|
||||||
|
expect(response.sessionStatus.sessionId).toBe(ACTIVE_SESSION_ID);
|
||||||
|
expect(fake.calls.sendCustomMessage).toHaveLength(1);
|
||||||
|
const [delivered] = fake.calls.sendCustomMessage;
|
||||||
|
expect(delivered?.message.customType).toBe(ASK_USER_ANSWERS_CUSTOM_TYPE);
|
||||||
|
expect(delivered?.message.display).toBe(true);
|
||||||
|
expect(delivered?.message.content).toContain("The user submitted answers to your questions.");
|
||||||
|
expect(delivered?.message.content).toContain("Answered 1 of 1");
|
||||||
|
expect(delivered?.message.details).toMatchObject({ askId: "ask-1", reason: "submitted" });
|
||||||
|
expect(delivered?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" });
|
||||||
|
expect(askEvents(events).map(({ event }) => event.type)).toEqual(["ask.opened", "ask.closed"]);
|
||||||
|
expect(store.pendingAsk(ACTIVE_SESSION_ID)).toBeUndefined();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the questions the user left unanswered in the message the model reads", async () => {
|
||||||
|
const { service, fake } = askService({ withActiveSession: true });
|
||||||
|
await service.openAsk({
|
||||||
|
sessionId: ACTIVE_SESSION_ID,
|
||||||
|
questions: [...questions, { id: "cache", question: "Which cache?", options: [{ value: "redis", label: "Redis" }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await service.submitAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1", { answers: [{ id: "db", values: ["pg"] }] });
|
||||||
|
|
||||||
|
expect(response.outcome).toMatchObject({ answeredCount: 1, unansweredIds: ["cache"] });
|
||||||
|
expect(fake.calls.sendCustomMessage[0]?.message.content).toContain("Answered 1 of 2; unanswered: cache");
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a stale ask id without delivering anything or closing the open ask", async () => {
|
||||||
|
const { service, store, events, fake } = askService({ withActiveSession: true });
|
||||||
|
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||||
|
|
||||||
|
const response = await service.submitAsk(sessionRef(ACTIVE_SESSION_ID), "ask-superseded", { answers: [] });
|
||||||
|
|
||||||
|
expect(response.result).toBe("stale");
|
||||||
|
expect(response).not.toHaveProperty("outcome");
|
||||||
|
expect(response.sessionStatus.pendingAsk).toMatchObject({ askId: "ask-1" });
|
||||||
|
expect(fake.calls.sendCustomMessage).toEqual([]);
|
||||||
|
expect(askEvents(events).map(({ event }) => event.type)).toEqual(["ask.opened"]);
|
||||||
|
expect(store.pendingAsk(ACTIVE_SESSION_ID)).toMatchObject({ askId: "ask-1" });
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the ask open when the submitted answers do not fit its questions", async () => {
|
||||||
|
const { service, store, fake } = askService({ withActiveSession: true });
|
||||||
|
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||||
|
|
||||||
|
await expect(service.submitAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1", { answers: [{ id: "nope", values: [] }] }))
|
||||||
|
.rejects.toThrow(PendingAskValidationError);
|
||||||
|
|
||||||
|
expect(store.pendingAsk(ACTIVE_SESSION_ID)).toMatchObject({ askId: "ask-1" });
|
||||||
|
expect(fake.calls.sendCustomMessage).toEqual([]);
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PiSessionService.cancelAsk", () => {
|
||||||
|
it("tells the model every question went unanswered rather than leaving it waiting", async () => {
|
||||||
|
const { service, store, events, fake } = askService({ withActiveSession: true });
|
||||||
|
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||||
|
|
||||||
|
const response = await service.cancelAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1");
|
||||||
|
|
||||||
|
expect(response).toMatchObject({ result: "closed", outcome: { reason: "cancelled", answeredCount: 0, unansweredIds: ["db"] } });
|
||||||
|
expect(fake.calls.sendCustomMessage[0]?.message.content).toContain("closed (cancelled) before it was fully answered");
|
||||||
|
expect(fake.calls.sendCustomMessage[0]?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" });
|
||||||
|
expect(askEvents(events).at(-1)?.event).toEqual({ type: "ask.closed", askId: "ask-1", reason: "cancelled" });
|
||||||
|
expect(store.pendingAsk(ACTIVE_SESSION_ID)).toBeUndefined();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a stale cancel of an ask that is already gone", async () => {
|
||||||
|
const { service, fake } = askService({ withActiveSession: true });
|
||||||
|
|
||||||
|
const response = await service.cancelAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1");
|
||||||
|
|
||||||
|
expect(response.result).toBe("stale");
|
||||||
|
expect(fake.calls.sendCustomMessage).toEqual([]);
|
||||||
await service.dispose();
|
await service.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,8 +33,11 @@ import { deterministicSessionName, fallbackSessionName, generateShortSessionName
|
|||||||
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||||
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||||
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
||||||
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
|
import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
|
||||||
import type {
|
import type {
|
||||||
|
AskUserCloseResponse,
|
||||||
|
AskUserOutcome,
|
||||||
|
AskUserSubmission,
|
||||||
SavedPromptAttachment,
|
SavedPromptAttachment,
|
||||||
SessionBulkArchiveResponse,
|
SessionBulkArchiveResponse,
|
||||||
SessionBulkDeleteArchivedResponse,
|
SessionBulkDeleteArchivedResponse,
|
||||||
@@ -55,7 +58,7 @@ import { type AuthChange } from "./authService.js";
|
|||||||
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
||||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||||
import { createAskUserToolDefinition, type AskUserInvocation, type AskUserToolDeps } from "./askUserTool.js";
|
import { createAskUserToolDefinition, type AskUserInvocation, type AskUserToolDeps } from "./askUserTool.js";
|
||||||
import { PendingAskStore, type PendingAskOpenResult } from "./pendingAskStore.js";
|
import { PendingAskStore, renderAskUserAnswersText, type PendingAskCloseResult, type PendingAskOpenResult } from "./pendingAskStore.js";
|
||||||
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
|
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
|
||||||
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
||||||
import { buildTranscriptView } from "./subsessionTranscript.js";
|
import { buildTranscriptView } from "./subsessionTranscript.js";
|
||||||
@@ -952,7 +955,10 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
const pendingOpens = this.pendingSessionOpenPromises();
|
const pendingOpens = this.pendingSessionOpenPromises();
|
||||||
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
|
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
|
||||||
const activeSessions = Array.from(new Set(this.active.values()));
|
const activeSessions = Array.from(new Set(this.active.values()));
|
||||||
for (const active of activeSessions) this.forgetUnreadActivity(active.runtime.session);
|
for (const active of activeSessions) {
|
||||||
|
this.forgetUnreadActivity(active.runtime.session);
|
||||||
|
this.pendingAskStore.forgetSession(active.runtime.session.sessionId);
|
||||||
|
}
|
||||||
this.active.clear();
|
this.active.clear();
|
||||||
this.pendingSessionOpens.clear();
|
this.pendingSessionOpens.clear();
|
||||||
this.activities.clear();
|
this.activities.clear();
|
||||||
@@ -1096,7 +1102,73 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await -- async so a rejected question set becomes a rejection rather than a synchronous throw from a promise-returning method.
|
// eslint-disable-next-line @typescript-eslint/require-await -- async so a rejected question set becomes a rejection rather than a synchronous throw from a promise-returning method.
|
||||||
async openAsk(input: AskUserInvocation): Promise<PendingAskOpenResult> {
|
async openAsk(input: AskUserInvocation): Promise<PendingAskOpenResult> {
|
||||||
return this.pendingAskStore.open(input);
|
const result = this.pendingAskStore.open(input);
|
||||||
|
// A supersede closes the earlier ask, so the browsers watching it must hear
|
||||||
|
// that before they hear about its replacement.
|
||||||
|
if (result.superseded !== undefined) this.publishAskClosed(input.sessionId, result.superseded);
|
||||||
|
this.events.publish(input.sessionId, { type: "ask.opened", ask: result.ask });
|
||||||
|
this.publishStatusForSessionId(input.sessionId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the user's answers to the session's open ask and hand them to the
|
||||||
|
* model. The answers travel as a system-authored custom message rather than a
|
||||||
|
* user message, so they are not attributed to the human in the transcript;
|
||||||
|
* they still wake an idle session (`triggerTurn`) and queue behind in-flight
|
||||||
|
* work (`deliverAs: "followUp"`), which is how the run that `ask_user`
|
||||||
|
* terminated continues.
|
||||||
|
*/
|
||||||
|
async submitAsk(ref: PiSessionLookup, askId: string, submission: AskUserSubmission): Promise<AskUserCloseResponse> {
|
||||||
|
await this.assertWritable(ref);
|
||||||
|
const session = await this.getOrOpen(ref);
|
||||||
|
// Checked before the store closes the ask so a refused delivery cannot
|
||||||
|
// discard answers the user already submitted.
|
||||||
|
this.assertTreeNavigationInactive(session, "answer questions");
|
||||||
|
return this.closeAsk(session, this.pendingAskStore.submit(session.sessionId, askId, submission));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the open ask without answers. The model is still told, naming every
|
||||||
|
* question as unanswered: it was promised a follow-up message and would
|
||||||
|
* otherwise wait for one that never comes.
|
||||||
|
*/
|
||||||
|
async cancelAsk(ref: PiSessionLookup, askId: string): Promise<AskUserCloseResponse> {
|
||||||
|
await this.assertWritable(ref);
|
||||||
|
const session = await this.getOrOpen(ref);
|
||||||
|
this.assertTreeNavigationInactive(session, "dismiss questions");
|
||||||
|
return this.closeAsk(session, this.pendingAskStore.cancel(session.sessionId, askId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish and deliver a closed ask. A stale close is reported rather than
|
||||||
|
* thrown: losing the race against a supersede, another browser, or a session
|
||||||
|
* that went away is ordinary, and the returned status tells the browser what
|
||||||
|
* the session's open ask is now.
|
||||||
|
*/
|
||||||
|
private async closeAsk(session: PiAgentSession, result: PendingAskCloseResult): Promise<AskUserCloseResponse> {
|
||||||
|
if (result.status === "stale") return { result: "stale", sessionStatus: this.statusFromSession(session) };
|
||||||
|
const { outcome } = result;
|
||||||
|
this.publishAskClosed(session.sessionId, outcome);
|
||||||
|
await this.runSessionEntryMutation(session, "deliver answers to your questions", () => session.sendCustomMessage(
|
||||||
|
{ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome },
|
||||||
|
{ triggerTurn: true, deliverAs: "followUp" },
|
||||||
|
));
|
||||||
|
this.publishStatus(session);
|
||||||
|
return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishAskClosed(sessionId: string, outcome: AskUserOutcome): void {
|
||||||
|
this.events.publish(sessionId, { type: "ask.closed", askId: outcome.askId, reason: outcome.reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish status for a session known only by id, as the ask tools are: they
|
||||||
|
* run inside the session's own runtime, so the active entry is the session.
|
||||||
|
*/
|
||||||
|
private publishStatusForSessionId(sessionId: string): void {
|
||||||
|
const session = this.active.get(sessionId)?.runtime.session;
|
||||||
|
if (session !== undefined) this.publishStatus(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Summaries of the tracked subsessions spawned by `parentSessionId`. */
|
/** Summaries of the tracked subsessions spawned by `parentSessionId`. */
|
||||||
@@ -2251,6 +2323,9 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
}
|
}
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
this.forgetUnreadActivity(active.runtime.session);
|
this.forgetUnreadActivity(active.runtime.session);
|
||||||
|
// An open ask is meaningful only while the runtime that posted it exists: no
|
||||||
|
// one is left to receive the answers, so it is dropped without an outcome.
|
||||||
|
this.pendingAskStore.forgetSession(sessionId);
|
||||||
this.active.delete(sessionId);
|
this.active.delete(sessionId);
|
||||||
this.activities.delete(sessionId);
|
this.activities.delete(sessionId);
|
||||||
this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
|
this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
|
||||||
@@ -3107,6 +3182,7 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
const model = session.model === undefined ? undefined : modelToClientModel(session.model);
|
const model = session.model === undefined ? undefined : modelToClientModel(session.model);
|
||||||
const contextUsage = session.getContextUsage();
|
const contextUsage = session.getContextUsage();
|
||||||
const warnings = this.warningsForSession(session);
|
const warnings = this.warningsForSession(session);
|
||||||
|
const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId);
|
||||||
return {
|
return {
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
persisted: sessionFileExists(session.sessionFile),
|
persisted: sessionFileExists(session.sessionFile),
|
||||||
@@ -3122,6 +3198,7 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
cost: stats.cost,
|
cost: stats.cost,
|
||||||
...(contextUsage === undefined ? {} : { contextUsage }),
|
...(contextUsage === undefined ? {} : { contextUsage }),
|
||||||
...(warnings.length === 0 ? {} : { warnings }),
|
...(warnings.length === 0 ? {} : { warnings }),
|
||||||
|
...(pendingAsk === undefined ? {} : { pendingAsk }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { resolve } from "node:path";
|
|||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import fastifyWebsocket from "@fastify/websocket";
|
import fastifyWebsocket from "@fastify/websocket";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../shared/apiTypes.js";
|
import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../shared/apiTypes.js";
|
||||||
import type {
|
import type {
|
||||||
|
AskUserCloseResponse,
|
||||||
|
AskUserSubmission,
|
||||||
MessagePage,
|
MessagePage,
|
||||||
SessionBulkArchiveResponse,
|
SessionBulkArchiveResponse,
|
||||||
SessionBulkDeleteArchivedResponse,
|
SessionBulkDeleteArchivedResponse,
|
||||||
@@ -337,6 +339,100 @@ describe("session routes", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses ask submissions and reports both closed and stale outcomes", async () => {
|
||||||
|
const routeApp = Fastify({ logger: false });
|
||||||
|
await routeApp.register(fastifyWebsocket);
|
||||||
|
const eventHub = new SessionEventHub();
|
||||||
|
const routeService = new CapturingRouteSessionService();
|
||||||
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const submitted = await routeApp.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/sessions/session-1/ask/submit",
|
||||||
|
payload: {
|
||||||
|
cwd: "/repo/./",
|
||||||
|
askId: "ask-1",
|
||||||
|
answers: [{ id: "db", values: ["pg"] }, { id: "cache", values: [], otherText: "redis" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const cancelled = await routeApp.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/sessions/session-1/ask/cancel",
|
||||||
|
payload: { askId: "ask-2" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(submitted.statusCode).toBe(200);
|
||||||
|
expect(submitted.json()).toMatchObject({ result: "closed", sessionStatus: { sessionId: "session-1" } });
|
||||||
|
expect(routeService.submitAskCalls).toEqual([{
|
||||||
|
lookup: { id: "session-1", cwd: resolve("/repo") },
|
||||||
|
askId: "ask-1",
|
||||||
|
submission: { answers: [{ id: "db", values: ["pg"] }, { id: "cache", values: [], otherText: "redis" }] },
|
||||||
|
}]);
|
||||||
|
expect(cancelled.statusCode).toBe(200);
|
||||||
|
expect(cancelled.json()).toMatchObject({ result: "stale" });
|
||||||
|
expect(routeService.cancelAskCalls).toEqual([{ lookup: "session-1", askId: "ask-2" }]);
|
||||||
|
} finally {
|
||||||
|
await routeService.dispose();
|
||||||
|
await routeApp.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed ask payloads before calling the service", async () => {
|
||||||
|
const routeApp = Fastify({ logger: false });
|
||||||
|
await routeApp.register(fastifyWebsocket);
|
||||||
|
const eventHub = new SessionEventHub();
|
||||||
|
const routeService = new CapturingRouteSessionService();
|
||||||
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
const malformed: Record<string, unknown>[] = [
|
||||||
|
{ answers: [] },
|
||||||
|
{ askId: "", answers: [] },
|
||||||
|
{ askId: "x".repeat(ASK_USER_ID_MAX_LENGTH + 1), answers: [] },
|
||||||
|
{ askId: "ask-1" },
|
||||||
|
{ askId: "ask-1", answers: {} },
|
||||||
|
{ askId: "ask-1", answers: [{ values: ["pg"] }] },
|
||||||
|
{ askId: "ask-1", answers: [{ id: "db" }] },
|
||||||
|
{ askId: "ask-1", answers: [{ id: "db", values: [1] }] },
|
||||||
|
{ askId: "ask-1", answers: [{ id: "db", values: [], otherText: 7 }] },
|
||||||
|
{ askId: "ask-1", answers: [{ id: "db", values: [], otherText: "x".repeat(ASK_USER_OTHER_TEXT_MAX_LENGTH + 1) }] },
|
||||||
|
{ askId: "ask-1", answers: new Array<unknown>(ASK_USER_QUESTION_LIMIT + 1).fill({ id: "db", values: [] }) },
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const payload of malformed) {
|
||||||
|
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/ask/submit", payload });
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
}
|
||||||
|
const cancelWithoutAskId = await routeApp.inject({ method: "POST", url: "/sessions/session-1/ask/cancel", payload: {} });
|
||||||
|
|
||||||
|
expect(cancelWithoutAskId.statusCode).toBe(400);
|
||||||
|
expect(routeService.submitAskCalls).toEqual([]);
|
||||||
|
expect(routeService.cancelAskCalls).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await routeService.dispose();
|
||||||
|
await routeApp.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a missing session on an ask submission to 404", async () => {
|
||||||
|
const routeApp = Fastify({ logger: false });
|
||||||
|
await routeApp.register(fastifyWebsocket);
|
||||||
|
const eventHub = new SessionEventHub();
|
||||||
|
const routeService = new CapturingRouteSessionService();
|
||||||
|
routeService.askError = new Error("Session not found");
|
||||||
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/ask/submit", payload: { askId: "ask-1", answers: [] } });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
expect(response.json()).toEqual({ error: "Session not found" });
|
||||||
|
} finally {
|
||||||
|
await routeService.dispose();
|
||||||
|
await routeApp.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects prompt payloads that omit text without opening a session", async () => {
|
it("rejects prompt payloads that omit text without opening a session", async () => {
|
||||||
const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { body: "Build the thing" } });
|
const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { body: "Build the thing" } });
|
||||||
|
|
||||||
@@ -706,9 +802,24 @@ class CapturingRouteSessionService implements SessionRouteService {
|
|||||||
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
|
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
|
||||||
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
|
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
|
||||||
readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = [];
|
readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = [];
|
||||||
|
readonly submitAskCalls: { lookup: SessionRouteLookup; askId: string; submission: AskUserSubmission }[] = [];
|
||||||
|
readonly cancelAskCalls: { lookup: SessionRouteLookup; askId: string }[] = [];
|
||||||
|
askError: Error | undefined;
|
||||||
reloadError: Error | undefined;
|
reloadError: Error | undefined;
|
||||||
clearQueueError: Error | undefined;
|
clearQueueError: Error | undefined;
|
||||||
|
|
||||||
|
submitAsk(lookup: SessionRouteLookup, askId: string, submission: AskUserSubmission): Promise<AskUserCloseResponse> {
|
||||||
|
if (this.askError !== undefined) return Promise.reject(this.askError);
|
||||||
|
this.submitAskCalls.push({ lookup, askId, submission });
|
||||||
|
return Promise.resolve({ result: "closed", sessionStatus: idleStatus(lookup) });
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAsk(lookup: SessionRouteLookup, askId: string): Promise<AskUserCloseResponse> {
|
||||||
|
if (this.askError !== undefined) return Promise.reject(this.askError);
|
||||||
|
this.cancelAskCalls.push({ lookup, askId });
|
||||||
|
return Promise.resolve({ result: "stale", sessionStatus: idleStatus(lookup) });
|
||||||
|
}
|
||||||
|
|
||||||
cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
|
cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
|
||||||
this.cleanupPreviewCalls.push(request);
|
this.cleanupPreviewCalls.push(request);
|
||||||
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } });
|
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } });
|
||||||
@@ -901,6 +1012,19 @@ function notificationSnapshot(ref: SessionRef): SessionNotificationInboxSnapshot
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function idleStatus(lookup: SessionRouteLookup): SessionStatus {
|
||||||
|
return {
|
||||||
|
sessionId: sessionIdFromLookup(lookup),
|
||||||
|
isStreaming: false,
|
||||||
|
isCompacting: false,
|
||||||
|
isBashRunning: false,
|
||||||
|
pendingMessageCount: 0,
|
||||||
|
queuedMessages: [],
|
||||||
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
cost: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sessionIdFromLookup(lookup: SessionRouteLookup): string {
|
function sessionIdFromLookup(lookup: SessionRouteLookup): string {
|
||||||
return typeof lookup === "string" ? lookup : lookup.id;
|
return typeof lookup === "string" ? lookup : lookup.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type SessionBulkMutationRequest, type SessionBulkMutationRef, type SessionCleanupRequest, type SessionTreeNavigateRequest, type SessionTreeSummaryChoice, type SessionUnreadAcknowledgeRequest } from "../../shared/apiTypes.js";
|
import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type AskUserAnswer, type AskUserSubmission, type SessionBulkMutationRequest, type SessionBulkMutationRef, type SessionCleanupRequest, type SessionTreeNavigateRequest, type SessionTreeSummaryChoice, type SessionUnreadAcknowledgeRequest } from "../../shared/apiTypes.js";
|
||||||
import { projectBrowserMessageResponse } from "../browserMessageProjection.js";
|
import { projectBrowserMessageResponse } from "../browserMessageProjection.js";
|
||||||
import { normalizeRequestCwd } from "../workingDirectory.js";
|
import { normalizeRequestCwd } from "../workingDirectory.js";
|
||||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||||
@@ -266,6 +266,25 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; askId?: unknown; answers?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/ask/submit`, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = requireRecord(request.body);
|
||||||
|
const askId = requireBoundedId(body["askId"], "askId");
|
||||||
|
return await sessions.submitAsk(sessionLookupFromBody(request.params.sessionId, body), askId, askUserSubmissionFromBody(body));
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; askId?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/ask/cancel`, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = requireRecord(request.body);
|
||||||
|
return await sessions.cancelAsk(sessionLookupFromBody(request.params.sessionId, body), requireBoundedId(body["askId"], "askId"));
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; dismissId?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/warnings/dismiss`, async (request, reply) => {
|
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; dismissId?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/warnings/dismiss`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const body = optionalRecord(request.body);
|
const body = optionalRecord(request.body);
|
||||||
@@ -493,6 +512,37 @@ function requireExactFields(record: Record<string, unknown>, fields: readonly st
|
|||||||
if (unexpected !== undefined) throw new Error(`${label} field contains unsupported property: ${unexpected}`);
|
if (unexpected !== undefined) throw new Error(`${label} field contains unsupported property: ${unexpected}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape-check one submitted answer set. Only transport-level checks belong here:
|
||||||
|
* whether the answers fit the questions that were actually asked is the pending
|
||||||
|
* ask store's job, since only it knows the open ask.
|
||||||
|
*/
|
||||||
|
function askUserSubmissionFromBody(body: Record<string, unknown>): AskUserSubmission {
|
||||||
|
const answers = body["answers"];
|
||||||
|
if (!Array.isArray(answers)) throw new Error("answers field must be an array");
|
||||||
|
if (answers.length > ASK_USER_QUESTION_LIMIT) throw new Error("answers field has too many entries");
|
||||||
|
return { answers: answers.map(askUserAnswerFromValue) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function askUserAnswerFromValue(value: unknown): AskUserAnswer {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
const values = record["values"];
|
||||||
|
if (!Array.isArray(values)) throw new Error("values field must be an array");
|
||||||
|
if (values.length > ASK_USER_OPTION_LIMIT) throw new Error("values field has too many entries");
|
||||||
|
const otherText = record["otherText"];
|
||||||
|
if (otherText !== undefined && typeof otherText !== "string") throw new Error("otherText field must be a string");
|
||||||
|
if (typeof otherText === "string" && otherText.length > ASK_USER_OTHER_TEXT_MAX_LENGTH) throw new Error("otherText field is too long");
|
||||||
|
return {
|
||||||
|
id: requireBoundedId(record["id"], "id"),
|
||||||
|
values: values.map((entry) => requireBoundedId(entry, "values entry")),
|
||||||
|
...(otherText === undefined ? {} : { otherText }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireBoundedId(value: unknown, field: string): string {
|
||||||
|
return requireNonEmptyBoundedString(value, field, ASK_USER_ID_MAX_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
function optionalRecord(value: unknown): Record<string, unknown> {
|
function optionalRecord(value: unknown): Record<string, unknown> {
|
||||||
if (value === undefined || value === null) return {};
|
if (value === undefined || value === null) return {};
|
||||||
return requireRecord(value);
|
return requireRecord(value);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AskUserCloseResponse,
|
||||||
|
AskUserSubmission,
|
||||||
SavedPromptAttachment,
|
SavedPromptAttachment,
|
||||||
SessionBulkArchiveResponse,
|
SessionBulkArchiveResponse,
|
||||||
SessionBulkDeleteArchivedResponse,
|
SessionBulkDeleteArchivedResponse,
|
||||||
@@ -51,6 +53,8 @@ export interface SessionRouteService {
|
|||||||
dismissNotification(ref: SessionRouteRef, request: Omit<SessionNotificationDismissRequest, "cwd">): SessionNotificationInboxSnapshot | Promise<SessionNotificationInboxSnapshot>;
|
dismissNotification(ref: SessionRouteRef, request: Omit<SessionNotificationDismissRequest, "cwd">): SessionNotificationInboxSnapshot | Promise<SessionNotificationInboxSnapshot>;
|
||||||
dismissAllNotifications(ref: SessionRouteRef, request: Omit<SessionNotificationDismissAllRequest, "cwd">): SessionNotificationInboxSnapshot | Promise<SessionNotificationInboxSnapshot>;
|
dismissAllNotifications(ref: SessionRouteRef, request: Omit<SessionNotificationDismissAllRequest, "cwd">): SessionNotificationInboxSnapshot | Promise<SessionNotificationInboxSnapshot>;
|
||||||
clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
|
clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
|
||||||
|
submitAsk(ref: SessionRouteLookup, askId: string, submission: AskUserSubmission): Promise<AskUserCloseResponse>;
|
||||||
|
cancelAsk(ref: SessionRouteLookup, askId: string): Promise<AskUserCloseResponse>;
|
||||||
dismissWarning(ref: SessionRouteLookup, dismissId: string): Promise<ClientSessionStatus>;
|
dismissWarning(ref: SessionRouteLookup, dismissId: string): Promise<ClientSessionStatus>;
|
||||||
availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>;
|
availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>;
|
||||||
setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise<ClientSessionStatus>;
|
setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise<ClientSessionStatus>;
|
||||||
|
|||||||
@@ -440,6 +440,12 @@ export interface QueuedSessionMessage {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `customType` of the follow-up custom message that carries a closed ask back to
|
||||||
|
* the model and into the transcript. Its `details` are an {@link AskUserOutcome}.
|
||||||
|
*/
|
||||||
|
export const ASK_USER_ANSWERS_CUSTOM_TYPE = "pi-web.ask.answers";
|
||||||
|
|
||||||
/** Largest question set one `ask_user` call may post. */
|
/** Largest question set one `ask_user` call may post. */
|
||||||
export const ASK_USER_QUESTION_LIMIT = 20;
|
export const ASK_USER_QUESTION_LIMIT = 20;
|
||||||
/** Largest option list one question may offer. */
|
/** Largest option list one question may offer. */
|
||||||
@@ -544,6 +550,21 @@ export interface AskUserOutcome {
|
|||||||
summary: string;
|
summary: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of the browser closing an ask by submitting or cancelling it.
|
||||||
|
*
|
||||||
|
* `"stale"` is an ordinary race rather than an error: the named ask was already
|
||||||
|
* submitted, superseded by a newer one, or gone with its session runtime. The
|
||||||
|
* browser drops its card and trusts `sessionStatus`, which is returned in both
|
||||||
|
* cases so closing an ask needs no follow-up status request.
|
||||||
|
*/
|
||||||
|
export interface AskUserCloseResponse {
|
||||||
|
result: "closed" | "stale";
|
||||||
|
/** Present only when this call is the one that closed the ask. */
|
||||||
|
outcome?: AskUserOutcome;
|
||||||
|
sessionStatus: SessionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Progress of the session startup window, where the daemon is still
|
* Progress of the session startup window, where the daemon is still
|
||||||
* constructing the agent session and no `PiAgentSession` exists yet, so
|
* constructing the agent session and no `PiAgentSession` exists yet, so
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export const FEDERATED_HTTP_ROUTES = [
|
|||||||
{ method: "GET", path: "/sessions/:sessionId/commands" },
|
{ method: "GET", path: "/sessions/:sessionId/commands" },
|
||||||
{ method: "POST", path: "/sessions/:sessionId/prompt" },
|
{ method: "POST", path: "/sessions/:sessionId/prompt" },
|
||||||
{ method: "POST", path: "/sessions/:sessionId/queue/clear" },
|
{ method: "POST", path: "/sessions/:sessionId/queue/clear" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/ask/submit" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/ask/cancel" },
|
||||||
{ method: "POST", path: "/sessions/:sessionId/warnings/dismiss" },
|
{ method: "POST", path: "/sessions/:sessionId/warnings/dismiss" },
|
||||||
{ method: "POST", path: "/sessions/:sessionId/attachments" },
|
{ method: "POST", path: "/sessions/:sessionId/attachments" },
|
||||||
{ method: "POST", path: "/sessions/:sessionId/shell" },
|
{ method: "POST", path: "/sessions/:sessionId/shell" },
|
||||||
|
|||||||
Reference in New Issue
Block a user