Preserve literal acceptance text and surface inconsistent chat idle states
This commit is contained in:
parent
cd3ac87cd8
commit
a49ac10617
|
|
@ -2088,6 +2088,23 @@ fn redact_sensitive_text_values(input: &str) -> String {
|
|||
.any(|delimiter| before.ends_with(delimiter))
|
||||
};
|
||||
let has_hyphenated_count_lead = token_phrase_has_lead("one-");
|
||||
// An explicitly literal/exact token is a requested text value (for
|
||||
// example an acceptance identifier), not a diagnostic credential pair.
|
||||
// Keep this grammatical exception separate from credential syntax:
|
||||
// assignments, quoted values/keys, CLI and compound keys still redact,
|
||||
// as do the independent Bearer, key-prefix and JWT scanners above.
|
||||
let is_literal_token_reference = key == "token"
|
||||
&& !key_is_compound
|
||||
&& whitespace_start == start + key.len()
|
||||
&& separator > whitespace_start
|
||||
&& !has_assignment_separator
|
||||
&& bytes[whitespace_start..separator]
|
||||
.iter()
|
||||
.all(|value| matches!(value, b' ' | b'\t'))
|
||||
&& quoted_value_start(separator).1.is_none()
|
||||
&& ["literal ", "exact "]
|
||||
.iter()
|
||||
.any(|lead| token_phrase_has_lead(lead));
|
||||
let is_benign_token_noun_phrase = key == "token"
|
||||
&& (!key_is_compound || has_hyphenated_count_lead)
|
||||
&& whitespace_start == start + key.len()
|
||||
|
|
@ -2150,7 +2167,8 @@ fn redact_sensitive_text_values(input: &str) -> String {
|
|||
|| (token_phrase_has_tail("can equal") && token_phrase_has_lead("one ")));
|
||||
let has_whitespace_separator = separator > whitespace_start
|
||||
&& (key != "authorization" || key_is_compound || has_authorization_scheme)
|
||||
&& !is_benign_token_noun_phrase;
|
||||
&& !is_benign_token_noun_phrase
|
||||
&& !is_literal_token_reference;
|
||||
if !has_assignment_separator && !has_whitespace_separator {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -3159,6 +3177,68 @@ mod tests {
|
|||
assert_eq!(sanitized["accessToken"], json!("[REDACTED]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_handoff_preserves_literal_acceptance_identifiers() {
|
||||
let description = "The document body must contain the literal token CHAT250ed7e4dc071. No code changes needed.";
|
||||
let plan = "## Plan\n- Include the literal token CHAT250ed7e4dc071 in the document body.\n- Save the output document.";
|
||||
let input = json!({
|
||||
"title": "Write project description",
|
||||
"description": description,
|
||||
"initialPlan": plan,
|
||||
"idempotencyKey": "write-description-1",
|
||||
});
|
||||
assert_eq!(
|
||||
sanitize_semantic_tool_input("create_task", &input).unwrap(),
|
||||
input
|
||||
);
|
||||
for text in [
|
||||
description,
|
||||
plan,
|
||||
"Must include the literal token `CHAT66e7813a4f9d1` somewhere in the text.",
|
||||
"Include the exact token ACCEPTANCE-42 in the final output.",
|
||||
] {
|
||||
assert_eq!(redact_text(text), text);
|
||||
assert_eq!(
|
||||
sanitize_value(&json!({"body": text})),
|
||||
json!({"body": text})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_token_prose_does_not_exempt_credential_syntax_or_shapes() {
|
||||
for text in [
|
||||
"auth token opaque-credential",
|
||||
"the token opaque-credential",
|
||||
"literal token=opaque-credential",
|
||||
"literal token:opaque-credential",
|
||||
"literal --token opaque-credential",
|
||||
"literal access_token opaque-credential",
|
||||
"literal \"token\" opaque-credential",
|
||||
"literal token \"opaque-credential\"",
|
||||
] {
|
||||
assert!(!redact_text(text).contains("opaque-credential"), "{text}");
|
||||
}
|
||||
for secret in [
|
||||
"sk-proj-secretvalue123456",
|
||||
"ghp_secretvalue12345678901234567890",
|
||||
"eyJhbGciOiJIUzI1NiJ9.c2VjcmV0LWNsYWlt.signaturesecret",
|
||||
] {
|
||||
let text = format!("Include the literal token {secret} in the document.");
|
||||
assert!(!redact_text(&text).contains(secret), "{text}");
|
||||
}
|
||||
let input = json!({
|
||||
"description": "Include the literal token ACCEPTANCE-42. Authorization: Bearer opaque-credential",
|
||||
"token": "opaque-credential",
|
||||
});
|
||||
let safe = sanitize_semantic_tool_input("create_task", &input).unwrap();
|
||||
assert!(safe["description"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("ACCEPTANCE-42"));
|
||||
assert!(!safe.to_string().contains("opaque-credential"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_redaction_preserves_benign_token_system_prose() {
|
||||
let prose = "Offer a simple token system so guests can exchange items even when their contributions differ in quantity.";
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
chatQuestionPresentation,
|
||||
chatRunFailure,
|
||||
chatTaskCompletionFailure,
|
||||
createChatIdleFailureDetector,
|
||||
collectChatRunEvidence,
|
||||
readRunningChatLog,
|
||||
readChatOutputDocument,
|
||||
|
|
@ -19,6 +20,7 @@ import type { RunnerApi } from "./api.js";
|
|||
import { chatMarker } from "./chat-cases.js";
|
||||
import { runnerMatrix } from "./catalog.js";
|
||||
import { isPublicRunnerScreenshotRoute } from "./screenshot-policy.js";
|
||||
import { classifyFailure, shouldRetryFailure } from "./failure-classifier.js";
|
||||
|
||||
const source: ChatIssue = {
|
||||
id: "chat",
|
||||
|
|
@ -265,6 +267,28 @@ describe("chat acceptance contracts", () => {
|
|||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
it("fails stable contradictory idle states promptly without paid retries or transient false alarms", () => {
|
||||
const detect = createChatIdleFailureDetector(3);
|
||||
const settled = {
|
||||
resolved: true,
|
||||
status: "blocked",
|
||||
conversationState: "waiting",
|
||||
providerRunCount: 3,
|
||||
activeRuns: [] as string[],
|
||||
};
|
||||
expect(detect(settled)).toBeUndefined();
|
||||
expect(detect({ ...settled, activeRuns: ["running"] })).toBeUndefined();
|
||||
expect(detect(settled)).toBeUndefined();
|
||||
const failure = detect(settled);
|
||||
expect(failure).toContain("chat_idle_state_invariant");
|
||||
expect(classifyFailure(failure)).toBe("candidate_failure");
|
||||
expect(shouldRetryFailure(classifyFailure(failure))).toBe(false);
|
||||
expect(detect({ ...settled, status: "in_review" })).toBeUndefined();
|
||||
expect(detect(settled)).toBeUndefined();
|
||||
expect(detect({ ...settled, providerRunCount: 2 })).toBeUndefined();
|
||||
expect(detect({ ...settled, status: "in_progress" })).toBeUndefined();
|
||||
expect(detect(settled)).toBeUndefined();
|
||||
});
|
||||
it("fails promptly on terminal provider failures while permitting only expected cancellations", () => {
|
||||
expect(chatRunFailure([run])).toBeUndefined();
|
||||
expect(chatRunFailure([{ ...run, status: "running" }])).toBeUndefined();
|
||||
|
|
|
|||
|
|
@ -126,6 +126,34 @@ export function chatTaskCompletionFailure(
|
|||
return chatRunFailure(runs);
|
||||
}
|
||||
|
||||
/** Require two settled observations so an in-flight finalization is not a failure. */
|
||||
export function createChatIdleFailureDetector(minimumProviderRuns: number) {
|
||||
let priorInconsistentState: string | undefined;
|
||||
return (state: {
|
||||
resolved: boolean;
|
||||
status?: string;
|
||||
conversationState?: string;
|
||||
providerRunCount: number;
|
||||
activeRuns: string[];
|
||||
}): string | undefined => {
|
||||
const inconsistentState =
|
||||
state.resolved &&
|
||||
state.providerRunCount >= minimumProviderRuns &&
|
||||
state.activeRuns.length === 0 &&
|
||||
state.conversationState === "waiting" &&
|
||||
["blocked", "done", "cancelled"].includes(state.status ?? "")
|
||||
? `${state.status}:${state.providerRunCount}`
|
||||
: undefined;
|
||||
const stable =
|
||||
inconsistentState !== undefined &&
|
||||
inconsistentState === priorInconsistentState;
|
||||
priorInconsistentState = inconsistentState;
|
||||
return stable
|
||||
? `chat_idle_state_invariant: conversation is ${state.status} while waiting after ${state.providerRunCount} settled provider runs`
|
||||
: undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/** Match the shared question form's durable/native presentation, including custom labels. */
|
||||
export function chatQuestionPresentation(
|
||||
payload: AskUserQuestionsPayload,
|
||||
|
|
@ -254,6 +282,7 @@ export async function runChatFlow(input: {
|
|||
a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id),
|
||||
);
|
||||
const idle = async (minimumProviderRuns: number) => {
|
||||
const inconsistentIdle = createChatIdleFailureDetector(minimumProviderRuns);
|
||||
await pollUntil({
|
||||
label: "chat turn settles to waiting",
|
||||
deadlineAt: Date.now() + 240_000,
|
||||
|
|
@ -274,7 +303,7 @@ export async function runChatFlow(input: {
|
|||
failure: chatRunFailure(runs, caseId === "stop-new-resume"),
|
||||
};
|
||||
},
|
||||
reject: (state) => state.failure,
|
||||
reject: (state) => state.failure ?? inconsistentIdle(state),
|
||||
accept: (state) =>
|
||||
!state.failure &&
|
||||
state.resolved &&
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export function classifyFailure(error: unknown): FailureClass {
|
|||
}
|
||||
if (PERMANENT.test(message)) return "permanent_infrastructure";
|
||||
if (
|
||||
/chat_idle_state_invariant/.test(message) ||
|
||||
NON_RETRYABLE_SESSION_CLOSE.test(message) ||
|
||||
NON_RETRYABLE_ACPX_SESSION_OPEN.test(message)
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue