Skip to content
Closed
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
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ members = [
]

[workspace.package]
version = "0.1.56"
version = "0.1.57"
edition = "2024"
publish = false

Expand Down
26 changes: 23 additions & 3 deletions apps/gui/frontend/src/features/project/ApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { PendingApproval } from "~/types";
*/
export function ApprovalCard(props: { projectId: string; approval: PendingApproval }): JSX.Element {
const { actions } = useWorkspace();
const isPermissionGrant = () => props.approval.tool.toLowerCase() === "permissions";

const answer = (allow: boolean, remember = false): void => {
void actions
Expand All @@ -32,6 +33,16 @@ export function ApprovalCard(props: { projectId: string; approval: PendingApprov
for (const key of ["command", "file_path", "path", "url"]) {
if (typeof record[key] === "string") return record[key] as string;
}
const permissions = record.permissions;
if (permissions && typeof permissions === "object") {
const fileSystem = (permissions as Record<string, unknown>).fileSystem;
if (fileSystem && typeof fileSystem === "object") {
const write = (fileSystem as Record<string, unknown>).write;
if (Array.isArray(write) && write.every((path) => typeof path === "string")) {
return write.join(", ");
}
}
}
}
return null;
};
Expand Down Expand Up @@ -73,7 +84,7 @@ export function ApprovalCard(props: { projectId: string; approval: PendingApprov
onClick={() => answer(true)}
class="rounded-lg bg-primary px-[13px] py-[5px] font-semibold text-[12px] text-primary-content transition-colors hover:bg-az-primary-hover"
>
Allow once
{isPermissionGrant() ? "Allow for session" : "Allow once"}
</button>
<button
type="button"
Expand All @@ -84,10 +95,14 @@ export function ApprovalCard(props: { projectId: string; approval: PendingApprov
* parent directory — and answers matching asks itself from now
* on, each auto-allow audited in the Agent I/O panel.
*/
title="Remembers this kind of call for this project — the same command family or the same directory — and allows it automatically from now on"
title={
isPermissionGrant()
? "Remembers this exact set of writable paths for this project and allows it automatically on later runs"
: "Remembers this kind of call for this project — the same command family or the same directory — and allows it automatically from now on"
}
class="rounded-lg border border-primary/50 px-3 py-[5px] font-semibold text-[12px] text-primary transition-colors hover:border-primary hover:bg-primary/10"
>
Always allow similar
{isPermissionGrant() ? "Always allow these paths" : "Always allow similar"}
</button>
<button
type="button"
Expand All @@ -98,6 +113,11 @@ export function ApprovalCard(props: { projectId: string; approval: PendingApprov
</button>
<span class="text-[11.5px] text-az-muted">· the run is paused until you decide</span>
</div>
<Show when={isPermissionGrant()}>
<p class="text-[11px] text-az-muted leading-[1.45]">
Add a folder to Working directories in Settings to make it writable from the start.
</p>
</Show>
</div>
</div>
);
Expand Down
32 changes: 32 additions & 0 deletions apps/gui/frontend/src/features/project/MessageBody.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,26 @@ describe("MessageBody", () => {
const { container } = render(() => <MessageBody body="text" />);
expect(container.querySelector("[data-selectable]")).toBeTruthy();
});

it("visually marks a standalone Prompt Syntax authoring directive", () => {
const directive = '<ps @agency:items.state(id: "item-869382d3", status: "active")>';
const { container } = render(() => (
<MessageBody body={`Working on it.\n${directive}\nContinuing.`} />
));

const marked = container.querySelector("[data-ps-directive]");
expect(marked).toHaveTextContent("Prompt Syntax");
expect(marked).toHaveTextContent(directive);
});

it("leaves misframed and quoted Prompt Syntax inert", () => {
const directive = '<ps @agency:items.state(id: "item-a", status: "active")>';
const body = `Attached to prose: ${directive}\n\n> ${directive}\n\n ${directive}`;
const { container } = render(() => <MessageBody body={body} />);

expect(container.querySelector("[data-ps-directive]")).toBeNull();
expect(container.textContent).toContain(directive);
});
});

/*
Expand Down Expand Up @@ -140,6 +160,18 @@ describe("fenced blocks", () => {
// And its own inline code still reads as code.
expect([...container.querySelectorAll("code")].map((c) => c.textContent)).toContain("ask");
});

it("keeps Prompt Syntax inert inside either Markdown fence marker", () => {
const directive = '<ps @agency:items.retire(id: "item-a")>';
const body = `\`\`\`\`text\n${directive}\n\`\`\`\n\`\`\`\`\n~~~~\n${directive}\n~~~~`;
const { container } = render(() => <MessageBody body={body} />);

expect(container.querySelector("[data-ps-directive]")).toBeNull();
expect([...container.querySelectorAll("pre code")].map((node) => node.textContent)).toEqual([
`${directive}\n\`\`\``,
directive,
]);
});
});

describe("InlineText", () => {
Expand Down
56 changes: 50 additions & 6 deletions apps/gui/frontend/src/features/project/MessageBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,19 @@ export async function copyText(text: string): Promise<boolean> {
* the difference: everything outside one is wrapped prose, and everything inside
* one is text whose line breaks are the content.
*/
type Block = { kind: "code"; text: string; lang: string } | { kind: "prose"; text: string };
type Block =
| { kind: "code"; text: string; lang: string }
| { kind: "directive"; text: string }
| { kind: "prose"; text: string };

/** The same explicit authoring-line boundary Rust promotes. */
export function isPromptSyntaxDirectiveLine(line: string): boolean {
if (line.startsWith(" ") || line.startsWith("\t")) return false;
const trimmed = line.trim();
if (trimmed.startsWith(">")) return false;
const afterTag = trimmed.slice(3);
return trimmed.startsWith("<ps") && /^\s/.test(afterTag) && trimmed.endsWith(">");
}

/**
* Split a body into fenced blocks and the prose between them.
Expand All @@ -89,6 +101,7 @@ export function splitBlocks(body: string): Block[] {
let code: string[] | null = null;
let lang = "";
let indent = "";
let marker = "";

const flushProse = () => {
const text = prose.join("\n");
Expand All @@ -111,22 +124,36 @@ export function splitBlocks(body: string): Block[] {
};

for (const line of body.split("\n")) {
const fence = /^(\s*)```(.*)$/.exec(line);
const fence = /^(\s*)(`{3,}|~{3,})(.*)$/.exec(line);
if (!fence) {
if (code === null) prose.push(line);
else code.push(deindent(line));
if (code !== null) {
code.push(deindent(line));
} else if (isPromptSyntaxDirectiveLine(line)) {
flushProse();
blocks.push({ kind: "directive", text: line.trim() });
} else {
prose.push(line);
}
continue;
}
if (code === null) {
flushProse();
code = [];
indent = fence[1];
lang = fence[2].trim();
} else {
marker = fence[2];
lang = fence[3].trim();
} else if (
fence[2][0] === marker[0] &&
fence[2].length >= marker.length &&
fence[3].trim().length === 0
) {
blocks.push({ kind: "code", text: code.join("\n"), lang });
code = null;
lang = "";
indent = "";
marker = "";
} else {
code.push(deindent(line));
}
}

Expand All @@ -146,6 +173,8 @@ export function MessageBody(props: { body: string; class?: string }): JSX.Elemen
{(block) =>
block.kind === "code" ? (
<CodeBlock text={block.text} lang={block.lang} />
) : block.kind === "directive" ? (
<PromptSyntaxDirective text={block.text} />
) : (
<For
each={block.text
Expand All @@ -165,6 +194,21 @@ export function MessageBody(props: { body: string; class?: string }): JSX.Elemen
);
}

/** A promoted reverse-channel action, visibly distinct from ordinary prose. */
function PromptSyntaxDirective(props: { text: string }): JSX.Element {
return (
<div
data-ps-directive
class="flex min-w-0 items-center gap-2 overflow-x-auto rounded-lg border border-primary/25 bg-primary/6 px-2.5 py-2"
>
<span class="shrink-0 rounded bg-primary/12 px-1.5 py-0.5 font-semibold text-[10px] text-primary uppercase tracking-[.05em]">
Prompt Syntax
</span>
<code class="whitespace-pre font-mono text-[11.5px] text-az-body">{props.text}</code>
</div>
);
}

/**
* A fenced block: line breaks preserved, and copyable without a drag.
*
Expand Down
2 changes: 1 addition & 1 deletion apps/gui/frontend/src/features/project/TranscriptPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export function TranscriptPane(props: {
<span class="text-[11px] text-az-muted">
{AGENT_LABELS[streamingAgent()]} · writing…
</span>
<p class={`whitespace-pre-wrap ${AGENT_TEXT}`}>{text()}</p>
<MessageBody body={text()} class={AGENT_TEXT} />
</div>
)}
</Show>
Expand Down
85 changes: 84 additions & 1 deletion apps/gui/src/directives.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub struct Surface {
pub delimiter: &'static str,
/// 13.2.2. The vendor-extension namespace whose references are live.
pub namespace: &'static str,
/// 13.2.2. Closed and declared. An unlisted verb parses to nothing.
/// 13.2.2. Closed and declared. An unlisted authored verb is refused.
pub verbs: &'static [&'static str],
/// 13.2.2. Verbs and values reserved to principals above the agent.
pub reserved: &'static [&'static str],
Expand Down Expand Up @@ -129,6 +129,19 @@ pub enum Outcome {
Refused { what: String, code: String },
}

/// What a standalone `<ps …>` authoring segment resolved to.
///
/// Ordinary prose is not represented here at all. Once a line explicitly
/// enters the declared surface, however, it must become either a directive or
/// a typed refusal. Treating an unknown or malformed reference as ordinary
/// text would violate the reverse-channel fill contract and leave the agent
/// unable to correct it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Authored {
Directive(Directive),
Refused(Outcome),
}

impl Outcome {
/// One line for the receipt.
#[must_use]
Expand Down Expand Up @@ -258,6 +271,50 @@ pub fn parse(line: &str) -> Option<Directive> {
None
}

/// Resolve one explicit authoring segment, including its failure path.
///
/// `None` means the line never designated itself as this surface. A line that
/// does start with the declared `<ps ` delimiter always returns an outcome:
/// unknown references fail binding, while a known verb with an invalid shape
/// fails syntax validation. Both are receipts rather than silent raw text.
#[must_use]
pub fn parse_authored(line: &str) -> Option<Authored> {
let trimmed = line.trim();
let after_tag = trimmed.strip_prefix("<ps")?;
if !after_tag.chars().next().is_some_and(char::is_whitespace) {
return None;
}
if let Some(directive) = parse(trimmed) {
return Some(Authored::Directive(directive));
}

let has_bidi = trimmed.chars().any(|char| {
matches!(
char,
'\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
)
});
let inner = after_tag.strip_suffix('>').unwrap_or(after_tag).trim();
let verb = inner.split_once('(').map_or(inner, |(verb, _)| verb).trim();
let named = if verb.is_empty() {
"authored Prompt Syntax segment".to_string()
} else {
verb.chars().take(96).collect()
};
let known = SURFACE
.verbs
.iter()
.any(|candidate| verb.eq_ignore_ascii_case(&format!("@{}:{candidate}", SURFACE.namespace)));
Some(Authored::Refused(Outcome::Refused {
what: named,
code: if has_bidi || known {
"SYNTAX_INVALID".into()
} else {
"ENTITY_NOT_FOUND".into()
},
}))
}

/// Whether the agent is allowed to set this status.
#[must_use]
pub fn settable(status: &str) -> bool {
Expand Down Expand Up @@ -381,6 +438,32 @@ mod tests {
assert!(parse(r#"<ps @agency:items.state(status: "new")>"#).is_none());
}

#[test]
fn an_authored_segment_never_fails_as_silent_text() {
let valid =
parse_authored(r#"<ps @agency:items.state(id: "item-869382d3", status: "active")>"#);
assert!(matches!(
valid,
Some(Authored::Directive(Directive::ItemState { ref id, .. }))
if id == "item-869382d3"
));

let unknown = parse_authored(r#"<ps @agency:items.destroy(id: "item-a3f9")>"#);
assert!(matches!(
unknown,
Some(Authored::Refused(Outcome::Refused { ref code, .. }))
if code == "ENTITY_NOT_FOUND"
));

let malformed = parse_authored(r#"<ps @agency:items.state(status: "active")>"#);
assert!(matches!(
malformed,
Some(Authored::Refused(Outcome::Refused { ref code, .. }))
if code == "SYNTAX_INVALID"
));
assert_eq!(parse_authored("ordinary prose"), None);
}

/// The owner closes an item. The agent can report that it shipped
/// something; it cannot report that the thing works, because it is not the
/// one looking at the screen.
Expand Down
Loading
Loading