Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
152 changes: 110 additions & 42 deletions apps/web/e2e/agents-lifecycle.spec.ts

Large diffs are not rendered by default.

81 changes: 49 additions & 32 deletions apps/web/e2e/fixture-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ function initialState() {
sessionCreateDelayMs: 0,
sessionCreateStatus: 201,
sessionCreateResponseLoss: 0,
sessionCreateStreamMissingIdentity: 0,
sessionCreateStreamCloseDelayMs: 120,
sessionListDelayMs: 0,
sessionListStatus: 200,
Expand Down Expand Up @@ -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);
Expand All @@ -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"
Expand Down Expand Up @@ -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}`,
Expand All @@ -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 });
Expand All @@ -1096,52 +1133,32 @@ 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}`,
session_id: created.id,
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}`,
Expand Down
3 changes: 2 additions & 1 deletion apps/web/e2e/vault-credentials.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 } : {}),
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ import "./SessionInitialInputEditor.css";
export interface SessionInitialInputEditorProps {
draft: SessionInitialInputDraft;
disabled?: boolean;
required?: boolean;
showTextField?: boolean;
onChange: (draft: SessionInitialInputDraft) => void;
}

export function SessionInitialInputEditor({
draft,
disabled = false,
required = false,
showTextField = true,
onChange,
}: SessionInitialInputEditorProps) {
Expand Down Expand Up @@ -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}
/>
<small>Optional. Nonblank input is preserved exactly and starts the initial Turn during Session creation.</small>
<small>{required ? "Required without an Environment. " : "Optional. "}Nonblank input is preserved exactly and starts the initial Turn during Session creation.</small>
</label>
) : draft.mode === "text" ? (
<p className="session-initial-input-switch-note">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type { VaultCatalog } from "../../vaults/vault-catalog";
import {
genericSessionStartError,
safeSessionStartError,
sessionCreationUsesStream,
SessionStartDialog,
} from "./SessionStartDialog";

Expand Down Expand Up @@ -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(/<button[^>]+disabled=""[^>]*>Create Session<\/button>/u);
});

it("honors a compatible preselected Agent and exposes whole-field overrides", () => {
Expand Down
26 changes: 13 additions & 13 deletions apps/web/src/features/sessions/create/SessionStartDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import { SessionInitialInputEditor } from "./SessionInitialInputEditor";
import {
projectSessionInitialInput,
sessionInitialInputError,
sessionInitialInputDraftReducer,
} from "./session-initial-input";
import {
Expand Down Expand Up @@ -81,13 +82,6 @@ export interface SessionStartDialogProps {

export const genericSessionStartError = "Agent Core could not create the Session. Review the Core connection and try again.";

export function sessionCreationUsesStream(
requestedStream: boolean,
environment: AgentEnvironmentInput,
): boolean {
return requestedStream || environment.type === "openai_hosted";
}

export function safeSessionStartError(error: unknown): string {
return error instanceof AgentCoreError && error.message.trim()
? error.message
Expand Down Expand Up @@ -277,6 +271,7 @@ export function SessionStartDialog({
|| applicableManualVaultIds.length > 0
));
const initialInput = projectSessionInitialInput(details.initialInput);
const initialInputError = initialInput.ok ? sessionInitialInputError(initialInput.input, environmentType) : initialInput.error;
const workspaceError = environmentType === "self_hosted" ? environment.error : null;
const formDisabled = disabled || submitting;
const canSubmit = open
Expand All @@ -287,7 +282,7 @@ export function SessionStartDialog({
&& !vaultPlan?.blocker
&& !environmentAdmissionBlocker
&& !templateNetworkBlocker
&& initialInput.ok
&& !initialInputError
&& Boolean(environment.input);
const advancedNeedsAttention = Boolean(
agentValidation.overrideError
Expand Down Expand Up @@ -336,6 +331,7 @@ export function SessionStartDialog({
agentMode,
selectedAgent,
vaultCatalog,
environment.input.type,
);
setMetadataError(validation.metadataError ?? null);
setAgentError(validation.agentError ?? null);
Expand Down Expand Up @@ -363,7 +359,7 @@ export function SessionStartDialog({
const common = {
environment: environment.input,
metadata: validation.request.metadata,
stream: sessionCreationUsesStream(validation.request.stream, environment.input),
stream: validation.request.stream,
vaultIds: submittedVaultPlan.vaultIds,
manualVaultIds: sorted(submittedManualVaultIds),
...(validation.request.input === undefined ? {} : { input: validation.request.input }),
Expand All @@ -381,12 +377,14 @@ export function SessionStartDialog({
...(validation.request.agent === undefined ? {} : { agent: validation.request.agent }),
};
const attempt = beginSessionCreateAttempt(draft, attemptRef.current);
const retry = attemptRef.current?.fingerprint === attempt.fingerprint;
attemptRef.current = attempt;
submittingRef.current = true;
setSubmitting(true);
setRequestError(null);
try {
await onSubmit({ ...draft, idempotencyKey: attempt.idempotencyKey } as SessionStartInput);
// A creation SSE retry has no created event. JSON recovers the same Session ID.
await onSubmit({ ...draft, stream: retry ? false : draft.stream, idempotencyKey: attempt.idempotencyKey } as SessionStartInput);
onClose();
} catch (error) {
setRequestError(safeSessionStartError(error));
Expand Down Expand Up @@ -521,7 +519,9 @@ export function SessionStartDialog({
value={details.initialInput.text}
onChange={(event) => updateInitialText(event.target.value)}
rows={4}
placeholder="Optional first message…"
placeholder={environmentType === "none" ? "Write the first message…" : "Optional first message…"}
aria-required={environmentType === "none"}
aria-describedby={`${formId}-first-message-help`}
disabled={formDisabled}
/>
) : (
Expand All @@ -530,7 +530,7 @@ export function SessionStartDialog({
<button className="button outline" type="button" onClick={() => setAdvancedOpen(true)} disabled={formDisabled}>Edit messages</button>
</span>
)}
<small>Optional. A nonblank message starts the first Turn during Session creation.</small>
<small id={`${formId}-first-message-help`}>{environmentType === "none" ? "Required without an Environment. " : "Optional. "}A nonblank message starts the first Turn during Session creation.</small>
</div>

<fieldset className="session-environment-options" disabled={formDisabled}>
Expand Down Expand Up @@ -730,7 +730,7 @@ export function SessionStartDialog({
</section>
) : null}

<SessionInitialInputEditor draft={details.initialInput} disabled={formDisabled} showTextField={false} onChange={(draft) => updateDetails("initialInput", draft)} />
<SessionInitialInputEditor required={environmentType === "none"} draft={details.initialInput} disabled={formDisabled} showTextField={false} onChange={(draft) => updateDetails("initialInput", draft)} />
</section>
) : null}
</div>
Expand Down
Loading
Loading