diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bcd5f5ee4..8b4bf4ff4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -38,6 +38,25 @@ Do not split an already active batch again just to adopt this workflow. Preserve
confirmed native differences and queue nonblocking findings without expanding the
milestone; stop after completing it when the user has set that boundary.
+For the continuous official-semantics alignment campaign, repeat owned-resource
+API/documentation comparisons, bounded implementation batches, acceptance and
+rescan until reasonably addressable discovered differences are removed. Maintain
+operation-level evidence, including unverified behavior and approved native
+harness/daemon differences. A merged batch does not finish the campaign. Discuss
+uncertain designs before expanding mechanisms; simplify repeated patch loops and
+record unresolved low-ROI cases with evidence and impact. Never defer a safety or
+data-consistency blocker while claiming the affected workflow passed.
+
+Session creation requires initial input for `none`, and for streaming creation
+outside `self_hosted`. Check these conditions before creation retry lookup or
+resource resolution. The parser remains shared with subsequent message admission;
+non-streaming hosted and self-hosted requests may omit input. Do not retain an
+idle-none creation compatibility exception. Valid requests retain their documented
+local idempotency behavior; clients may use the same request/key with stream=false
+to recover a lost creation response. Session metadata updates require a supplied
+metadata field, with null/empty clearing it. Validate an empty update before any
+resource lookup, after authentication.
+
Keep runtime state, test artifacts and build output under `~/.parsar/`. Require
absolute user-supplied working directories. Keep credentials out of source and
logs. Update this guide when architecture, ownership or generated contracts change.
@@ -1943,7 +1962,8 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
or user-message array through the same parser and admission path. Commit the
Session, initial input, first Turn and Item/event projections in one transaction.
A creation retry returns the existing Session without re-admitting initial work,
- including after terminal or later Turns. Omitted/null input retains idle creation.
+ including after terminal or later Turns. Omitted/null input is permitted only
+ for non-streaming hosted creation and self-hosted creation.
Creation streaming uses the shared live path above; non-text messages remain a gap.
- Enabling `AGENTS_API_DAEMON_WS_URL` also starts a bounded execution worker. Select
only connected, capable devices owned by the authenticated tenant; bind once and
diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts
index d179f0e0f..b2ec55bea 100644
--- a/apps/web/e2e/agents-lifecycle.spec.ts
+++ b/apps/web/e2e/agents-lifecycle.spec.ts
@@ -75,6 +75,7 @@ async function createFixtureSession(request: APIRequestContext, label: string) {
agent_id: "agent_b",
environment: { type: "none" },
metadata: { filter_fixture: label },
+ input: `Review the filter fixture ${label}.`,
stream: false,
vault_ids: [],
},
@@ -137,7 +138,12 @@ async function startSessionWithSecondAgent(page: Page) {
await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click();
const dialog = page.getByRole("dialog", { name: "Create a Session" });
await expect(dialog).toBeVisible();
+ await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review the selected Agent configuration.");
+ const liveHandoff = page.waitForResponse((response) => (
+ response.request().method() === "GET" && /\/agents\/sessions\/session_created_[^/]+\/events$/u.test(new URL(response.url()).pathname)
+ ));
await dialog.getByRole("button", { name: "Create Session" }).click();
+ await liveHandoff;
}
async function openAdvancedSessionSettings(dialog: Locator) {
@@ -536,7 +542,7 @@ test("supports global Create keyboard navigation and consumes setup requests onc
expect(creates[0]?.body).not.toHaveProperty("reasoning");
});
-test("continues from a default Agent definition into an admitted idle Session", async ({ page, request }) => {
+test("continues from a default Agent definition into a Session with initial input", async ({ page, request }) => {
await openAgents(page, request);
await page.getByRole("button", { name: /^Create agent/ }).click();
await page.getByLabel("Name").fill("Session-safe Agent");
@@ -555,6 +561,7 @@ test("continues from a default Agent definition into an admitted idle Session",
await expect(page.getByRole("button", { name: "Start Session" })).toBeEnabled();
await page.getByRole("button", { name: "Start Session" }).click();
const sessionDialog = page.getByRole("dialog", { name: "Create a Session" });
+ await sessionDialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review this Agent definition.");
await expect(sessionDialog).toBeVisible();
await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue(/^agent_created_/);
await expect(sessionDialog.getByRole("button", { name: /Advanced settings/ })).toHaveAttribute("aria-expanded", "false");
@@ -575,7 +582,7 @@ test("continues from a default Agent definition into an admitted idle Session",
expect(sessionCreates[0]?.body).toMatchObject({
agent_id: expect.stringMatching(/^agent_created_/),
environment: { type: "none" },
- stream: false,
+ stream: true,
});
});
@@ -644,6 +651,7 @@ test("starts only Agents that pass known Session admission", async ({ page, requ
)).length;
await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click();
const sessionDialog = page.getByRole("dialog", { name: "Create a Session" });
+ await sessionDialog.getByRole("textbox", { name: /^First message\b/u }).fill("Check the selected Agent configuration.");
await expect(sessionDialog).toBeVisible();
await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_b");
await sessionDialog.getByRole("button", { name: "Create Session" }).click();
@@ -656,15 +664,16 @@ test("starts only Agents that pass known Session admission", async ({ page, requ
expect(sessionCreates.at(-1)?.body).toMatchObject({
agent_id: "agent_b",
environment: { type: "none" },
- stream: false,
+ stream: true,
});
});
-test("serializes complete saved-Agent overrides while keeping idle creation unstreamed", async ({ page, request }) => {
+test("serializes complete saved-Agent overrides with initial input", async ({ page, request }) => {
await openAgents(page, request);
await page.getByRole("button", { name: "Sessions", exact: true }).click();
await page.getByRole("button", { name: "New Session" }).click();
let dialog = page.getByRole("dialog", { name: "Create a Session" });
+ await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review the Session-specific settings.");
await dialog.getByLabel("Saved Agent", { exact: true }).selectOption("agent_a");
await expect(dialog.getByRole("alert")).toContainText("multi-agent execution is not supported");
@@ -687,6 +696,7 @@ test("serializes complete saved-Agent overrides while keeping idle creation unst
await page.getByRole("button", { name: "New Session" }).click();
dialog = page.getByRole("dialog", { name: "Create a Session" });
+ await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review the Session-specific settings.");
await dialog.getByLabel("Saved Agent", { exact: true }).selectOption("agent_b");
await openAdvancedSessionSettings(dialog);
await expect(dialog.getByRole("checkbox", { name: /Stream idle creation events/ })).toHaveCount(0);
@@ -710,18 +720,20 @@ test("serializes complete saved-Agent overrides while keeping idle creation unst
},
environment: { type: "none" },
metadata: {},
- stream: false,
+ input: "Review the Session-specific settings.",
+ stream: true,
vault_ids: [],
});
expect(creates[1]?.body).toMatchObject({
agent_id: "agent_b",
environment: { type: "none" },
metadata: {},
- stream: false,
+ input: "Review the Session-specific settings.",
+ stream: true,
vault_ids: [],
});
expect(creates[1]?.body).not.toHaveProperty("agent");
- expect(creates[1]?.body).not.toHaveProperty("input");
+ expect(creates[1]?.body?.input).toBe("Review the Session-specific settings.");
});
test("derives manual Vault attachments for anonymous and explicit MCP Credentials", async ({ page, request }) => {
@@ -772,6 +784,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential
await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click();
let dialog = page.getByRole("dialog", { name: "Create a Session" });
+ await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources.");
await openAdvancedSessionSettings(dialog);
await expect(dialog).toContainText("Anonymous for this Session");
await dialog.getByRole("button", { name: "Create Session" }).click();
@@ -780,6 +793,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential
await page.getByRole("button", { name: "Agents", exact: true }).click();
await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click();
dialog = page.getByRole("dialog", { name: "Create a Session" });
+ await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources.");
await openAdvancedSessionSettings(dialog);
await dialog.getByRole("checkbox", { name: "Vault Alpha" }).check();
await expect(dialog).toContainText("Implicit unique match · Credential Alpha · Vault Alpha");
@@ -789,6 +803,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential
await page.getByRole("button", { name: "Agents", exact: true }).click();
await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click();
dialog = page.getByRole("dialog", { name: "Create a Session" });
+ await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources.");
await openAdvancedSessionSettings(dialog);
await dialog.getByRole("checkbox", { name: "Vault Alpha" }).check();
await dialog.getByRole("checkbox", { name: "Vault Beta" }).check();
@@ -799,6 +814,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential
await page.getByRole("button", { name: "Agents", exact: true }).click();
await page.getByRole("button", { name: /^Start a Session with Explicit MCP Agent/ }).click();
dialog = page.getByRole("dialog", { name: "Create a Session" });
+ await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources.");
await openAdvancedSessionSettings(dialog);
const requiredVault = dialog.getByRole("checkbox", { name: /Vault Alpha · attached automatically/ });
await expect(requiredVault).toBeChecked();
@@ -821,6 +837,7 @@ test("derives manual Vault attachments for anonymous and explicit MCP Credential
await page.getByRole("button", { name: "Agents", exact: true }).click();
await page.getByRole("button", { name: /^Start a Session with Explicit MCP Agent/ }).click();
dialog = page.getByRole("dialog", { name: "Create a Session" });
+ await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources.");
const deleted = await request.delete(`${fixtureBaseUrl}/v1/vaults/${vaultAlpha.id}/credentials/${credentialAlpha.id}`);
expect(deleted.ok()).toBe(true);
await dialog.getByRole("button", { name: "Create Session" }).click();
@@ -859,6 +876,7 @@ test("clears manual Vault attachments when overrides remove HTTP MCP tools", asy
await page.getByRole("button", { name: "Agents", exact: true }).click();
await page.getByRole("button", { name: /^Start a Session with MCP Clear Agent/ }).click();
const dialog = page.getByRole("dialog", { name: "Create a Session" });
+ await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Explain the Session without MCP tools.");
await openAdvancedSessionSettings(dialog);
await dialog.getByRole("checkbox", { name: "Vault to clear" }).check();
await dialog.getByRole("checkbox", { name: /Configure Session-only overrides/ }).check();
@@ -990,7 +1008,7 @@ test("saves a reusable Environment Template and references it from a managed Ses
type: "openai_hosted",
environment_template_id: selectedTemplateId,
});
- expect(latest?.stream).toBe(true);
+ expect(latest?.stream).toBe(false);
});
test("hides Template selection when the connected Core lacks the resource", async ({ page, request }) => {
@@ -1008,7 +1026,7 @@ test("hides Template selection when the connected Core lacks the resource", asyn
await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled();
});
-test("creates managed hosted default, enabled and disabled profiles through POST SSE", async ({ page, request }) => {
+test("creates managed hosted profiles with JSON for empty input and SSE for initial input", async ({ page, request }) => {
await openAgents(page, request);
const profiles = [
{ label: "default", option: "default", environment: { type: "openai_hosted" }, input: undefined },
@@ -1037,7 +1055,7 @@ test("creates managed hosted default, enabled and disabled profiles through POST
expect(latest).toMatchObject({
agent_id: "agent_b",
environment: profile.environment,
- stream: true,
+ stream: profile.input !== undefined,
vault_ids: [],
});
expect(latest?.environment).toEqual(profile.environment);
@@ -1151,7 +1169,7 @@ test("blocks hosted MCP before persistence while allowing a Function-only manage
entry.method === "POST" && entry.path === "/v1/agents/sessions"
));
expect(creates).toHaveLength(before + 1);
- expect(creates.at(-1)?.body).toMatchObject({ environment: { type: "openai_hosted" }, stream: true });
+ expect(creates.at(-1)?.body).toMatchObject({ environment: { type: "openai_hosted" }, stream: false });
});
test("keeps managed Environment resource and terminal event states fail-closed", async ({ page, request }) => {
@@ -1513,6 +1531,42 @@ test("keeps the Agent card grid, setup, and delete confirmation usable at 390 px
await expect(editSetup.getByRole("button", { name: "Delete Agent" })).toBeFocused();
});
+test("requires a first message without an Environment and preserves the draft across environment changes", async ({ page, request }) => {
+ await openAgents(page, request);
+ const before = await fixtureState(request);
+ for (const input of [undefined, null]) {
+ for (const fields of [{ environment: { type: "none" } }, { environment: { type: "openai_hosted" }, stream: true }]) {
+ const response = await request.post(`${fixtureBaseUrl}/v1/agents/sessions`, { data: {
+ agent_id: "agent_b", ...fields, ...(input === undefined ? {} : { input }),
+ } });
+ expect(response.status()).toBe(400);
+ }
+ }
+ expect((await fixtureState(request)).sessions).toEqual(before.sessions);
+ const postsBefore = (await fixtureRequests(request)).filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions").length;
+ await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click();
+ const dialog = page.getByRole("dialog", { name: "Create a Session" });
+ const firstMessage = dialog.getByRole("textbox", { name: /^First message\b/u });
+ const create = dialog.getByRole("button", { name: "Create Session" });
+ await expect(firstMessage).toHaveAttribute("aria-required", "true");
+ await expect(create).toBeDisabled();
+ await firstMessage.fill(" \t\u0085 ");
+ await expect(create).toBeDisabled();
+ expect((await fixtureRequests(request)).filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions")).toHaveLength(postsBefore);
+ await dialog.getByRole("radio", { name: /Managed hosted/ }).check();
+ await expect(create).toBeEnabled();
+ await expect(firstMessage).toHaveAttribute("aria-required", "false");
+ const input = " Explain this Agent's capabilities.\n";
+ await firstMessage.fill(input);
+ await dialog.getByRole("radio", { name: /No environment/ }).check();
+ await expect(firstMessage).toHaveValue(input);
+ await create.click();
+ await expect(dialog).toHaveCount(0);
+ const creates = (await fixtureRequests(request)).filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions");
+ expect(creates).toHaveLength(postsBefore + 1);
+ expect(creates.at(-1)?.body).toMatchObject({ environment: { type: "none" }, input, stream: true });
+});
+
test("starts one Session with an idempotency key and without browser authorization", async ({ page, request }) => {
await openAgents(page, request);
await startSessionWithSecondAgent(page);
@@ -1522,7 +1576,7 @@ test("starts one Session with an idempotency key and without browser authorizati
const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions");
expect(creates).toHaveLength(1);
expect(creates[0]?.idempotencyKeyPresent).toBe(true);
- expect(creates[0]?.body).toMatchObject({ agent_id: "agent_b", environment: { type: "none" }, stream: false });
+ expect(creates[0]?.body).toMatchObject({ agent_id: "agent_b", environment: { type: "none" }, input: "Review the selected Agent configuration.", stream: true });
for (const entry of requests.filter((candidate) => candidate.path.startsWith("/v1/"))) {
expect(entry.beta).toBe("agents=v1");
expect(entry.authorizationPresent).toBe(false);
@@ -1713,37 +1767,51 @@ test("streams initial Session creation, captures early events, then hands off to
).toBeVisible();
});
-test("keeps one Session create attempt across response loss and an unchanged manual retry", async ({ page, request }) => {
- await openAgents(page, request);
- await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click();
- const dialog = page.getByRole("dialog", { name: "Create a Session" });
- const create = dialog.getByRole("button", { name: "Create Session" });
- await controlFixture(request, { sessionCreateDelayMs: 1_500, sessionCreateResponseLoss: 1 });
-
- await create.evaluate((button) => {
- button.click();
- button.click();
- });
- await expect(dialog.getByRole("alert")).toContainText("Agent core request failed (502).");
- await expect(dialog.getByText("Retrying this unchanged request reuses the original idempotency key.")).toBeVisible();
+for (const failure of [
+ { label: "response loss", control: { sessionCreateResponseLoss: 1 }, message: "Agent core request failed (502)." },
+ { label: "creation-stream EOF before identity", control: { sessionCreateStreamMissingIdentity: 1 }, message: "Agent core returned an empty event stream." },
+]) {
+ test(`keeps one Session create attempt across ${failure.label} and an unchanged manual retry`, async ({ page, request }) => {
+ await openAgents(page, request);
+ await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click();
+ const dialog = page.getByRole("dialog", { name: "Create a Session" });
+ await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review this request once after response recovery.");
+ const create = dialog.getByRole("button", { name: "Create Session" });
+ await controlFixture(request, { sessionCreateDelayMs: 1_500, ...failure.control });
- let creates = (await fixtureRequests(request)).filter((entry) => (
- entry.method === "POST" && entry.path === "/v1/agents/sessions"
- ));
- expect(creates).toHaveLength(1);
- const originalKey = creates[0]?.idempotencyKey;
- expect(originalKey).toBeTruthy();
- expect((await fixtureState(request)).sessions).toHaveLength(2);
+ await create.evaluate((button) => {
+ button.click();
+ button.click();
+ });
+ await expect(dialog.getByRole("alert")).toContainText(failure.message);
+ await expect(dialog.getByText("Retrying this unchanged request reuses the original idempotency key.")).toBeVisible();
- await create.click();
- await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible();
- creates = (await fixtureRequests(request)).filter((entry) => (
- entry.method === "POST" && entry.path === "/v1/agents/sessions"
- ));
- expect(creates).toHaveLength(2);
- expect(creates[1]?.idempotencyKey).toBe(originalKey);
- expect((await fixtureState(request)).sessions).toHaveLength(2);
-});
+ let creates = (await fixtureRequests(request)).filter((entry) => (
+ entry.method === "POST" && entry.path === "/v1/agents/sessions"
+ ));
+ expect(creates).toHaveLength(1);
+ const originalKey = creates[0]?.idempotencyKey;
+ expect(originalKey).toBeTruthy();
+ expect((await fixtureState(request)).sessions).toHaveLength(2);
+
+ await create.click();
+ await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible();
+ creates = (await fixtureRequests(request)).filter((entry) => (
+ entry.method === "POST" && entry.path === "/v1/agents/sessions"
+ ));
+ expect(creates).toHaveLength(2);
+ expect(creates[1]?.idempotencyKey).toBe(originalKey);
+ expect(creates.map((entry) => entry.body?.stream)).toEqual([true, false]);
+ expect(creates[1]?.body?.input).toBe(creates[0]?.body?.input);
+ const recovered = (await fixtureState(request)).sessions;
+ expect(recovered).toHaveLength(2);
+ const sessionId = recovered.find((session) => session.id !== "session_snapshot")?.id;
+ const turns = await request.get(`${fixtureBaseUrl}/v1/agents/sessions/${sessionId}/turns`);
+ const items = await request.get(`${fixtureBaseUrl}/v1/agents/sessions/${sessionId}/items`);
+ expect((await turns.json() as { data: unknown[] }).data).toHaveLength(1);
+ expect((await items.json() as { data: unknown[] }).data).toHaveLength(1);
+ });
+}
test("shows composer activity only for a Core-reported in-progress Session", async ({ page, request }, testInfo) => {
await resetFixture(request);
@@ -3032,7 +3100,7 @@ test("drops a delayed Turn page after switching Sessions", async ({ page, reques
const nextDiagnostics = page.locator("details.trace-turn-diagnostics");
await nextDiagnostics.locator("summary").click();
const nextTimeline = nextDiagnostics.getByRole("region", { name: "Turn timeline" });
- await expect(nextTimeline).toContainText("No Turns reported yet.");
+ await expect(nextTimeline).toContainText("Queued");
await page.waitForTimeout(3_000);
await expect(nextTimeline).not.toContainText("turn_queued");
await expect(page.getByText("Completed Turn output remains in the conversation.")).toHaveCount(0);
diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs
index 218be1efa..97845a260 100644
--- a/apps/web/e2e/fixture-core.mjs
+++ b/apps/web/e2e/fixture-core.mjs
@@ -389,6 +389,7 @@ function initialState() {
sessionCreateDelayMs: 0,
sessionCreateStatus: 201,
sessionCreateResponseLoss: 0,
+ sessionCreateStreamMissingIdentity: 0,
sessionCreateStreamCloseDelayMs: 120,
sessionListDelayMs: 0,
sessionListStatus: 200,
@@ -1012,6 +1013,13 @@ const server = http.createServer(async (request, response) => {
}
if (request.method === "POST" && url.pathname === "/v1/agents/sessions") {
+ const hasInitialInput = body.input !== undefined && body.input !== null;
+ const initialInputMessages = hasInitialInput ? sessionInitialInputMessages(body.input) : [];
+ const requiresInitialInput = body.environment?.type === "none"
+ || (body.stream === true && body.environment?.type !== "self_hosted");
+ if ((requiresInitialInput && !hasInitialInput) || (hasInitialInput && !initialInputMessages)) {
+ return sendError(response, 400, "Fixture Session requires valid initial input for this Environment and response mode.");
+ }
const idempotencyKey = request.headers["idempotency-key"];
const { stream: _streamResponseMode, ...creationIntent } = body;
const fingerprint = JSON.stringify(creationIntent);
@@ -1038,6 +1046,8 @@ const server = http.createServer(async (request, response) => {
const control = consumeControl("sessionCreate", 201);
const responseLoss = state.controls.sessionCreateResponseLoss;
state.controls.sessionCreateResponseLoss = 0;
+ const missingIdentity = state.controls.sessionCreateStreamMissingIdentity;
+ state.controls.sessionCreateStreamMissingIdentity = 0;
if (control.delayMs) await wait(control.delayMs);
if (control.status !== 201) return sendError(response, control.status, "Fixture Session create failed.");
const savedAgent = typeof body.agent_id === "string"
@@ -1067,11 +1077,6 @@ const server = http.createServer(async (request, response) => {
typeof value !== "string" || [...key].length > 64 || [...value].length > 512
)) || Object.keys(body.metadata).length > 16)
) return sendError(response, 400, "Fixture Session metadata is invalid.");
- const hasInitialInput = body.input !== undefined && body.input !== null;
- const initialInputMessages = hasInitialInput ? sessionInitialInputMessages(body.input) : [];
- if (hasInitialInput && !initialInputMessages) {
- return sendError(response, 400, "Fixture initial Session input is invalid.");
- }
state.sequence += 1;
const created = {
id: `session_created_${state.sequence}`,
@@ -1087,6 +1092,38 @@ const server = http.createServer(async (request, response) => {
created_at: baseline + state.sequence,
last_active_at: baseline + state.sequence,
};
+ const createdSnapshot = structuredClone(created);
+ let initialTurn = null;
+ let initialItems = [];
+ if (hasInitialInput && initialInputMessages) {
+ state.sequence += 1;
+ const turn = {
+ id: `turn_created_${state.sequence}`,
+ agent_id: created.agent.id,
+ session_id: created.id,
+ object: "agent.session.turn",
+ status: "queued",
+ created_at: baseline + state.sequence,
+ started_at: null,
+ completed_at: null,
+ error: null,
+ usage: null,
+ };
+ const items = initialInputMessages.map((message, index) => ({
+ id: `item_created_${state.sequence}_${index + 1}`,
+ turn_id: turn.id,
+ type: "message",
+ status: "completed",
+ role: "user",
+ content: message.content,
+ }));
+ state.turns.push(turn);
+ state.createdSessionItems.set(created.id, items);
+ initialTurn = turn;
+ initialItems = items;
+ created.status = "in_progress";
+ created.last_active_at = baseline + state.sequence;
+ }
state.sessions.unshift(created);
if (typeof idempotencyKey === "string") {
state.sessionCreateReceipts.set(idempotencyKey, { fingerprint, session: created });
@@ -1096,43 +1133,25 @@ const server = http.createServer(async (request, response) => {
return;
}
if (body.stream === true) {
- const createdSnapshot = structuredClone(created);
response.writeHead(201, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
});
response.write(": connected\n\n");
+ if (missingIdentity) {
+ response.end();
+ return;
+ }
state.sequence += 1;
response.write(`event: agent.session.created\nid: create_${state.sequence}\ndata: ${JSON.stringify({
type: "agent.session.created",
event_id: `create_${state.sequence}`,
session: createdSnapshot,
})}\n\n`);
- if (hasInitialInput && initialInputMessages) {
- state.sequence += 1;
- const turn = {
- id: `turn_created_${state.sequence}`,
- agent_id: created.agent.id,
- session_id: created.id,
- object: "agent.session.turn",
- status: "queued",
- created_at: baseline + state.sequence,
- started_at: null,
- completed_at: null,
- error: null,
- usage: null,
- };
- const items = initialInputMessages.map((message, index) => ({
- id: `item_created_${state.sequence}_${index + 1}`,
- turn_id: turn.id,
- type: "message",
- status: "completed",
- role: "user",
- content: message.content,
- }));
- state.turns.push(turn);
- state.createdSessionItems.set(created.id, items);
+ if (initialTurn) {
+ const turn = initialTurn;
+ const items = initialItems;
response.write(`event: agent.session.turn.created\nid: turn_${state.sequence}\ndata: ${JSON.stringify({
type: "agent.session.turn.created",
event_id: `turn_${state.sequence}`,
@@ -1140,8 +1159,6 @@ const server = http.createServer(async (request, response) => {
turn_id: turn.id,
turn,
})}\n\n`);
- created.status = "in_progress";
- created.last_active_at = baseline + state.sequence;
response.write(`event: agent.session.in_progress\nid: progress_${state.sequence}\ndata: ${JSON.stringify({
type: "agent.session.in_progress",
event_id: `progress_${state.sequence}`,
diff --git a/apps/web/e2e/vault-credentials.spec.ts b/apps/web/e2e/vault-credentials.spec.ts
index 25d9a354d..6892686e2 100644
--- a/apps/web/e2e/vault-credentials.spec.ts
+++ b/apps/web/e2e/vault-credentials.spec.ts
@@ -213,8 +213,9 @@ test("creates, replaces, uses, and deletes a write-only Vault Credential", async
await openAdvancedSessionSettings(sessionDialog);
await expect(sessionDialog.getByRole("heading", { name: "Tools & Vaults" })).toBeVisible();
await expect(sessionDialog).toContainText("Private docs MCP · Runtime credentials");
+ await sessionDialog.getByRole("textbox", { name: /^First message\b/u }).fill("Find the documentation available through this Credential.");
await sessionDialog.getByRole("button", { name: "Create Session" }).click();
- await expect(page.locator(".toast-region:not(.toast-region-assertive)")).toContainText("Idle Session created");
+ await expect(page.locator(".toast-region:not(.toast-region-assertive)")).toContainText("Session created with initial input");
requests = await fixtureRequests(request);
const vaultCreate = requests.find((entry) => entry.method === "POST" && entry.path === "/v1/vaults");
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index e4a7d6f85..41a29971a 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -39,6 +39,7 @@ import {
type StreamState,
} from "./features/sessions/SessionsView";
import type { SessionStartInput } from "./features/sessions/create/SessionStartDialog";
+import { sessionInitialInputError } from "./features/sessions/create/session-initial-input";
import { sessionCreateRequestPayload } from "./features/sessions/create/session-create-attempt";
import { normalizeSessionEnvironmentInput } from "./features/sessions/create/session-environment";
import { validateSessionAgentSubmission } from "./features/sessions/create/session-start-draft";
@@ -1487,6 +1488,12 @@ export function App() {
notify(error.message, "error");
throw error;
}
+ const inputError = sessionInitialInputError(input.input, environmentInput.type);
+ if (inputError) {
+ const error = new Error(`Session was not created. ${inputError}`);
+ notify(error.message, "error");
+ throw error;
+ }
const request = sessionCreateRequestPayload({
...(input.agentMode === "saved" ? { agentId: input.agentId } : {}),
...(submittedAgent.requestAgent ? { agent: submittedAgent.requestAgent } : {}),
@@ -1526,7 +1533,7 @@ export function App() {
throw new Error("The Session creation outcome could not be confirmed.");
}
openSession(session);
- notify("Idle Session created. Opening live events…", "success");
+ notify(input.input === undefined ? "Idle Session created. Opening live events…" : "Session opened. Connecting live events…", "success");
return;
}
diff --git a/apps/web/src/features/sessions/create/SessionInitialInputEditor.tsx b/apps/web/src/features/sessions/create/SessionInitialInputEditor.tsx
index b6c5b72f2..d99c05210 100644
--- a/apps/web/src/features/sessions/create/SessionInitialInputEditor.tsx
+++ b/apps/web/src/features/sessions/create/SessionInitialInputEditor.tsx
@@ -13,6 +13,7 @@ import "./SessionInitialInputEditor.css";
export interface SessionInitialInputEditorProps {
draft: SessionInitialInputDraft;
disabled?: boolean;
+ required?: boolean;
showTextField?: boolean;
onChange: (draft: SessionInitialInputDraft) => void;
}
@@ -20,6 +21,7 @@ export interface SessionInitialInputEditorProps {
export function SessionInitialInputEditor({
draft,
disabled = false,
+ required = false,
showTextField = true,
onChange,
}: SessionInitialInputEditorProps) {
@@ -96,10 +98,11 @@ export function SessionInitialInputEditor({
value={draft.text}
onChange={(event) => dispatch({ type: "set-text", value: event.target.value })}
rows={5}
- placeholder="Optional first message…"
+ placeholder={required ? "Write the first message…" : "Optional first message…"}
+ aria-required={required}
disabled={disabled}
/>
- Optional. Nonblank input is preserved exactly and starts the initial Turn during Session creation.
+ {required ? "Required without an Environment. " : "Optional. "}Nonblank input is preserved exactly and starts the initial Turn during Session creation.
) : draft.mode === "text" ? (
diff --git a/apps/web/src/features/sessions/create/SessionStartDialog.test.tsx b/apps/web/src/features/sessions/create/SessionStartDialog.test.tsx
index 4f0619834..5298e0aaa 100644
--- a/apps/web/src/features/sessions/create/SessionStartDialog.test.tsx
+++ b/apps/web/src/features/sessions/create/SessionStartDialog.test.tsx
@@ -7,7 +7,6 @@ import type { VaultCatalog } from "../../vaults/vault-catalog";
import {
genericSessionStartError,
safeSessionStartError,
- sessionCreationUsesStream,
SessionStartDialog,
} from "./SessionStartDialog";
@@ -100,11 +99,11 @@ describe("SessionStartDialog", () => {
expect(html).not.toContain("template_id");
});
- it("uses POST SSE for managed idle and initial creation without changing none/self-hosted idle", () => {
- expect(sessionCreationUsesStream(false, { type: "openai_hosted" })).toBe(true);
- expect(sessionCreationUsesStream(true, { type: "openai_hosted", network: { access: "disabled" } })).toBe(true);
- expect(sessionCreationUsesStream(false, { type: "none" })).toBe(false);
- expect(sessionCreationUsesStream(true, { type: "none" })).toBe(true);
+ it("requires a first message for the default no-Environment selection", () => {
+ const html = render(false, compatible.id);
+ expect(html).toContain("Required without an Environment.");
+ expect(html).toContain('aria-required="true"');
+ expect(html).toMatch(/